Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Shared terms match the skilld CLI glossary (`skilld-dev/skilld/GLOSSARY.md`). Th
| Skill | GitHub Repository, `layers/registry` | Repository 1β€”N Skill | "skill" |
| Agent | the user's tool | Agent 1β€”N Agent target | "your agent" |
| Repository | GitHub | Repository 1β€”N Skill | "repository" |
| repository purpose | `repository_purpose`, `layers/registry` | Repository 1β€”1 current machine finding, enforced by primary key | not shown |
| Artifact | skilld.dev API | Skill commit 1β€”1 Artifact | not shown |
| linked file | Artifact attestation | Artifact 1β€”N linked file | not shown |
| symbolic link | Skill folder, `symbolic-links` check result | Skill folder 1β€”N symbolic link | not shown |
Expand Down Expand Up @@ -68,6 +69,19 @@ Collisions

**Never:** package host, registry entry. `repo` stays in identifiers and existing UI labels.

### repository purpose

**Is:** a model's reading of what a Repository primarily publishes, with source evidence at one commit.

**Values:** `skill-pack`, `software`, `directory`, `mirror`, `uncertain`. File counts do not decide these values.

**Use for:** recorded machine evidence and holding new directory or mirror admissions for human review.
It grants no trust or search indexing. Existing Skills retain source refresh.

**Never:** indexing repository, indexing report, repository quality, admission decision.

**Casing:** lowercase in prose. Internal identifiers use `repositoryPurpose` or `repository_purpose`.

### Artifact

**Is:** immutable Skill bytes resolved from one exact source commit, delivered by the skilld.dev API to the skilld CLI.
Expand Down
3 changes: 2 additions & 1 deletion docs/arch/cron.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Generated from literal `defineScheduledTask` declarations. Run `pnpm cron:docs` after schedule changes.

Tasks: 23. Unique Cloudflare triggers: 15.
Tasks: 24. Unique Cloudflare triggers: 15.

| Task | Cron | Source |
| --- | --- | --- |
Expand All @@ -11,6 +11,7 @@ Tasks: 23. Unique Cloudflare triggers: 15.
| `ai-generate-submit` | `15 * * * *` | `layers/registry/server/tasks/ai-generate-submit.ts` |
| `ai-ready:cron` | `*/5 * * * *` | `server/tasks/ai-ready-cron.ts` |
| `backfill-skill-assets` | `*/5 * * * *` | `layers/registry/server/tasks/backfill-skill-assets.ts` |
| `classify-repository-purpose` | `0 * * * *` | `layers/registry/server/tasks/classify-repository-purpose.ts` |
| `cleanup-auto-index-rate-limits` | `0 * * * *` | `layers/registry/server/tasks/cleanup-auto-index-rate-limits.ts` |
| `detect-star-surges` | `30 4 * * *` | `layers/registry/server/tasks/detect-star-surges.ts` |
| `drain-skill-dirty` | `*/5 * * * *` | `layers/registry/server/tasks/drain-skill-dirty.ts` |
Expand Down
87 changes: 87 additions & 0 deletions docs/runbooks/repository-purpose.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Repository purpose

Jev reads repository purpose from bounded source evidence at one Git commit.
The stored finding covers organizations and individual maintainers.
It records directories, mirrors, original Skill packs, software, and uncertain evidence separately.

## Read a finding

```sql
SELECT owner,repo,purpose,probability,reason,model,source_commit,prompt_version,evaluated_at
FROM repository_purpose
WHERE owner='posthog' AND repo='posthog';
```

The `evidence` column holds description, README excerpt, path samples, Skill excerpts, and measured counts.
Counts are null when GitHub truncates the tree. A partial tree never proves a full count.
The `answer` column preserves the typed model answer and option probabilities.
The reason comes from the selected criterion or an explicit uncertainty rule. Jev does not generate prose.

## Admission

New directory, mirror, and uncertain findings require human review before the ingestion reads all Skill contents.
Existing Skills, eligible human decisions, named positive trust overrides, and verified Owners retain the existing admission path.
Software and Skill-pack findings continue through the existing rules. They earn no new trust or SEO indexing.
The leaderboard review surface covers individual Owners with discovered inventory.
Use the query below for held findings outside that surface, including organizations and new submissions.
Do not edit a machine finding as approval.

```sql
SELECT p.owner,p.repo,p.purpose,p.reason,p.evidence,p.source_commit
FROM repository_purpose p
WHERE p.purpose IN ('directory','mirror','uncertain')
AND NOT EXISTS(SELECT 1 FROM skills s WHERE s.owner=p.owner AND s.repo=p.repo)
AND NOT EXISTS(SELECT 1 FROM skill_repo_eligibility e
WHERE e.owner=p.owner AND e.repo=p.repo AND e.status='eligible')
AND NOT EXISTS(SELECT 1 FROM repo_trust_overrides t
WHERE t.owner=p.owner AND t.repo=p.repo
AND t.tier IN ('official','trusted-author','trusted-curator')
AND length(trim(t.reviewed_by))>0 AND length(trim(t.reason))>0)
ORDER BY p.evaluated_at;
```

If a human approves source admission, record an existing positive `repo_trust_overrides` decision through a reviewed migration.
Set the actual reviewer, reason, source, and review time. This decision permits source admission for the whole Repository.
Use `trusted-curator` only when the human approves that trust level. Keep rejected findings held.
After the migration deploys, submit the Repository again through the search box or `POST /api/repos/index`.
The new job checks the human decision before calling Jev. The decision never changes the machine finding.

The default Skills directory retains its separate `skill_repo_focus` screen.
Repository purpose does not replace that screen or the `repo_kind` field.

## Refresh

The hourly task dispatches up to 25 durable classification jobs.
It prioritizes truncated trees, large Skill inventories, and repositories with stored Skills.
It includes metadata-only repositories with detected Skills, including held large inventories.
The task avoids active jobs and waits one day after a failed job.

A changed source commit or prompt version invalidates the cached model answer.
Findings expire after seven days, so an unchanged source still receives periodic model checks.
The source reader uses one recursive tree request. It never expands a truncated tree.
It streams bounded prefixes from the README and two representative Skills at the same source commit.
Provider or source failures retry through the durable queue. Missing purpose evidence records `uncertain`.
An uncertain result never becomes permission to admit a repository.

## Evaluate a prompt

Prepare a JSON array with `name`, `expected`, and `evidence` for each case.
The evaluator parses each evidence object against the same source limits.
Use public source snapshots and keep reports outside the repository.

```sh
pnpm exec tsx scripts/evaluate-repository-purpose.ts INPUT.json OUTPUT.json
```

Set `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` with Workers AI Read access.
Or pass `--binding` to use Wrangler's authenticated remote Workers AI binding.
The evaluator records answers and source evidence. It writes no database rows.
Include a large application, small directory, original Skill pack, mirror, single root Skill, and uncertain evidence.
Include source text that tries to instruct the model. Check that it stays evidence.
If a prompt changes, update `REPOSITORY_PURPOSE_PROMPT_VERSION` and repeat the eval before delivery.

## Cull

A machine finding never removes existing Skills.
Use existing human admission and Skill indexability procedures to remove an unsuitable surface.
Keep the human reason separate from the machine evidence.
34 changes: 34 additions & 0 deletions layers/registry/server/tasks/classify-repository-purpose.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { resolveCloudflareBindings } from '@harlan-zw/nuxt-cloudflare/bindings'
import { createRegistryJobsRuntime } from '~~/server/utils/registry-jobs-runtime'
import { runObservedScheduledTask } from '~~/server/utils/scheduled-run'
import { reportJobRun } from '~~/server/utils/sync-job-reporter'
import { prepareJob } from '#cf-jobs/app'
import { observedSchedulePolicy } from '#shared/schedule-policy'
import { listRepositoryPurposeCandidates } from '../utils/repository-purpose-effect'

export default defineScheduledTask({
name: 'classify-repository-purpose',
cron: '0 * * * *',
description: 'Record repository purpose. Check large repositories first.',
async run({ context }) {
const env = resolveCloudflareBindings<Cloudflare.Env>(context)
if (!env?.DB)
return { result: { error: 'no-db' } }
return runObservedScheduledTask({ db: env.DB, env, context, policy: observedSchedulePolicy('classify-repository-purpose') }, async () => {
const startedAt = Date.now()
const now = Math.floor(startedAt / 1000)
const candidates = await listRepositoryPurposeCandidates(env.DB, now)
const jobs = await Promise.all(candidates.map(input => prepareJob({ name: 'registry/repository-purpose', payload: input })))
const batch = jobs.length > 0
? await createRegistryJobsRuntime(env as Cloudflare.Env & Record<string, unknown>)
.createBatch({ name: `repository-purpose:${now}`, jobs })
: null
await reportJobRun(env.DB, 'classify-repository-purpose', {
cron: '0 * * * *',
status: batch?.dispatched.some(item => item.status !== 'sent') ? 'partial' : 'ok',
durationMs: Date.now() - startedAt,
})
return { result: { queued: jobs.length, batchId: batch?.batchId ?? null } }
})
},
})
2 changes: 2 additions & 0 deletions layers/registry/server/utils/discovery-candidates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,8 @@ export const TERMINAL_DISCOVERY_REJECTION_REASONS = [
'no_supported_skill_paths',
'root_skill_unsupported',
'trust_inputs_insufficient',
// A named human decision must clear this hold. Queue retries cannot approve it.
'repository_purpose_review_required',
// Permanent sync failures: retrying a repository upstream reports as gone
// (or one whose tree exceeds the API's limits) burns the full attempt budget
// to learn nothing new, then trips the exhausted-retries alarm.
Expand Down
37 changes: 35 additions & 2 deletions layers/registry/server/utils/github-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -788,16 +788,49 @@ export async function getRawFile(
ref: string,
path: string,
bindings: GithubBindings,
options?: { maxBytes: number, timeoutMs?: number },
): Promise<string | null> {
const headers = new Headers()
headers.set('User-Agent', 'skilld.dev')
const credential = await credentialOf(bindings).current()
if (credential.token)
headers.set('Authorization', `Bearer ${credential.token}`)
const res = await fetch(`${RAW_BASE}/${owner}/${repo}/${ref}/${path}`, { headers })
const res = await fetch(`${RAW_BASE}/${owner}/${repo}/${ref}/${path}`, {
headers,
signal: options?.timeoutMs ? AbortSignal.timeout(options.timeoutMs) : undefined,
})
if (!res.ok)
return null
return res.text()
if (!options)
return res.text()
if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes <= 0)
throw new Error('The file prefix limit must be a positive integer.')
if (!res.body)
return ''
const reader = res.body.getReader()
const parts: Uint8Array[] = []
let remaining = options.maxBytes
try {
while (remaining > 0) {
const chunk = await reader.read()
if (chunk.done)
break
const part = chunk.value.subarray(0, remaining)
parts.push(part)
remaining -= part.length
}
}
finally {
await reader.cancel()
}
const bytes = new Uint8Array(options.maxBytes - remaining)
let offset = 0
for (const part of parts) {
bytes.set(part, offset)
offset += part.length
}
// A prefix can end inside a UTF-8 character. Leave that character out.
return new TextDecoder().decode(bytes, { stream: remaining === 0 })
}

export function logRateLimit(label: string, info: RateLimitInfo | null): void {
Expand Down
1 change: 1 addition & 0 deletions layers/registry/server/utils/repository-move.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ const REPOSITORY_TABLES: KeyedTable[] = [
{ table: 'repo_trust_overrides' },
{ table: 'skill_repo_eligibility' },
{ table: 'skill_repo_focus' },
{ table: 'repository_purpose' },
{ table: 'skill_repo_review_sync_outbox' },
{ table: 'supported_repos' },
{ table: 'skillgen_repositories' },
Expand Down
122 changes: 122 additions & 0 deletions layers/registry/server/utils/repository-purpose-effect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import type { GithubBindings } from './github-client'
import type { RepositoryPurposeEvidence, RepositoryPurposeFinding } from './repository-purpose'
import { isRegistrySkillPath } from '#shared/skill-path'
import { getRawFile, getRepoSummary, getTree } from './github-client'
import {
classifyRepositoryPurpose,
decideRepositoryPurposeAdmission,
REPOSITORY_PURPOSE_MODEL,
REPOSITORY_PURPOSE_PROMPT_VERSION,
REPOSITORY_PURPOSE_REFRESH_SECONDS,
} from './repository-purpose'

export type RepositoryPurposeJudge = (state: RepositoryPurposeEvidence) => Promise<unknown>
export interface RepositoryIdentity { owner: string, repo: string }

export async function listRepositoryPurposeCandidates(db: D1Database, now: number): Promise<RepositoryIdentity[]> {
const result = await db.prepare(`
SELECT r.owner,r.repo FROM repos r
LEFT JOIN repository_purpose p ON p.owner=r.owner AND p.repo=r.repo
WHERE r.broken_since IS NULL AND r.repo_skill_count>0
AND (p.owner IS NULL OR p.prompt_version!=?1 OR p.model_id!=?4 OR p.evaluated_at<?2 OR r.pushed_at>p.evaluated_at)
AND NOT EXISTS (
SELECT 1 FROM jobs j WHERE j.job_type='registry/repository-purpose'
AND json_extract(j.payload,'$.owner')=r.owner AND json_extract(j.payload,'$.repo')=r.repo
AND ((j.completed_at IS NULL AND j.failed_at IS NULL) OR j.failed_at>?3)
)
ORDER BY r.tree_truncated_at IS NOT NULL DESC, r.repo_skill_count>100 DESC,
EXISTS(SELECT 1 FROM skills s WHERE s.owner=r.owner AND s.repo=r.repo) DESC,
COALESCE(p.evaluated_at,0),r.owner,r.repo LIMIT 25
`).bind(REPOSITORY_PURPOSE_PROMPT_VERSION, now - REPOSITORY_PURPOSE_REFRESH_SECONDS, now - 86400, REPOSITORY_PURPOSE_MODEL).all<RepositoryIdentity>()
return result.results
}

export async function readRepositoryPurposeEvidence(
input: RepositoryIdentity,
bindings: GithubBindings,
): Promise<RepositoryPurposeEvidence> {
const summary = await getRepoSummary(input.owner, input.repo, bindings)
if (!summary.data?.headCommitSha || !summary.data.headTreeSha || summary.data.meta.private)
throw new Error(`Repository purpose source unavailable: ${summary.status}`)
const { meta, headCommitSha, headTreeSha } = summary.data
const tree = await getTree(meta.owner.login, meta.name, headTreeSha, bindings)
if (!tree.data)
throw new Error(`Repository purpose tree unavailable: ${tree.status}`)
const blobs = tree.data.tree.filter(entry => entry.type === 'blob')
const readme = blobs.find(entry => /^readme(?:\.md|\.markdown|\.rst|\.txt)?$/i.test(entry.path))
const skillFiles = blobs.filter(entry => isRegistrySkillPath(entry.path))
.sort((a, b) => a.path.localeCompare(b.path))
const read = async (file: typeof readme, limit: number) => {
if (!file)
return null
const content = await getRawFile(meta.owner.login, meta.name, headCommitSha, file.path, bindings, { maxBytes: limit * 4, timeoutMs: 10_000 })
if (content === null)
throw new Error(`Repository purpose file unavailable: ${file.path}`)
return { path: file.path, content: content.slice(0, limit) }
}
const skills = await Promise.all(skillFiles.slice(0, 2).map(file => read(file, 2000)))
return {
...input,
sourceCommit: headCommitSha,
description: meta.description?.slice(0, 1000) ?? null,
readme: await read(readme, 6000),
treeComplete: !tree.data.truncated,
fileCount: tree.data.truncated ? null : blobs.length,
skillCount: tree.data.truncated ? null : skillFiles.length,
paths: [...blobs.slice(0, 40), ...skillFiles.slice(0, 40)].map(entry => entry.path.slice(0, 200)),
skills: skills.filter((file): file is NonNullable<typeof file> => file !== null),
}
}

export async function persistRepositoryPurpose(db: D1Database, finding: RepositoryPurposeFinding, now: number): Promise<void> {
await db.prepare(`
INSERT INTO repository_purpose (owner,repo,purpose,probability,reason,model,model_id,
source_commit,prompt_version,evidence,answer,evaluated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(owner,repo) DO UPDATE SET
purpose=excluded.purpose,probability=excluded.probability,reason=excluded.reason,
model=excluded.model,model_id=excluded.model_id,source_commit=excluded.source_commit,
prompt_version=excluded.prompt_version,evidence=excluded.evidence,answer=excluded.answer,
evaluated_at=excluded.evaluated_at
WHERE excluded.evaluated_at >= repository_purpose.evaluated_at
`).bind(finding.evidence.owner, finding.evidence.repo, finding.purpose, finding.probability, finding.reason, finding.model, REPOSITORY_PURPOSE_MODEL, finding.sourceCommit, finding.promptVersion, JSON.stringify(finding.evidence), JSON.stringify(finding.answer), now).run()
}

export async function refreshRepositoryPurpose(deps: {
db: D1Database
readEvidence: (input: RepositoryIdentity) => Promise<RepositoryPurposeEvidence>
judge: RepositoryPurposeJudge
}, input: RepositoryIdentity, now: number): Promise<RepositoryPurposeFinding> {
const evidence = await deps.readEvidence(input)
const cached = await deps.db.prepare(`
SELECT purpose,probability,reason,model,source_commit,prompt_version,evidence,answer
FROM repository_purpose WHERE owner=? AND repo=? AND source_commit=?
AND prompt_version=? AND model_id=? AND evaluated_at>?
`).bind(input.owner, input.repo, evidence.sourceCommit, REPOSITORY_PURPOSE_PROMPT_VERSION, REPOSITORY_PURPOSE_MODEL, now - REPOSITORY_PURPOSE_REFRESH_SECONDS).first<{ purpose: RepositoryPurposeFinding['purpose'], probability: number, reason: string, model: string, source_commit: string, prompt_version: string, evidence: string, answer: string }>()
if (cached) {
return { _tag: 'classified', purpose: cached.purpose, probability: cached.probability, reason: cached.reason, model: cached.model, sourceCommit: cached.source_commit, promptVersion: cached.prompt_version, evidence: JSON.parse(cached.evidence), answer: JSON.parse(cached.answer) }
}
const result = await classifyRepositoryPurpose(evidence, deps.judge)
if (result._tag === 'rejected')
throw new Error(`Repository purpose classification failed: ${result.reason}`)
await persistRepositoryPurpose(deps.db, result, now)
return result
}

export async function checkRepositoryPurposeAdmission(
db: D1Database,
input: RepositoryIdentity & { ownerVerified: boolean },
classify: () => Promise<RepositoryPurposeFinding>,
) {
const row = await db.prepare(`
SELECT EXISTS(SELECT 1 FROM skills WHERE owner=?1 AND repo=?2) AS stored,
EXISTS(SELECT 1 FROM skill_repo_eligibility WHERE owner=?1 AND repo=?2 AND status='eligible') AS eligible,
EXISTS(SELECT 1 FROM repo_trust_overrides WHERE owner=?1 AND repo=?2
AND tier IN ('official','trusted-author','trusted-curator')
AND length(trim(reviewed_by))>0 AND length(trim(reason))>0) AS reviewed
`).bind(input.owner, input.repo).first<{ stored: number, eligible: number, reviewed: number }>()
if (row?.stored || row?.eligible || row?.reviewed || input.ownerVerified)
return { _tag: 'continue' as const }
const finding = await classify()
return decideRepositoryPurposeAdmission({ purpose: finding.purpose, hasStoredSkills: false, humanEligible: false, ownerVerified: false })
}
Loading
Loading