diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e492c90a..a12ebdd9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,96 @@ All notable changes to GBrain will be documented in this file. +## [0.37.0.0] - 2026-05-19 + +**Skillpacks become a real ecosystem. You can ship one, someone else can install it, and gbrain has an opinion about quality.** + +Pre-v0.37, skillpacks were one bundle — gbrain shipped its own pack at `openclaw.plugin.json` and that was it. v0.37 opens the door: anyone with a GitHub repo (or a tarball) can publish a skillpack, anyone else can scaffold it into their agent workspace, and `gbrain skillpack doctor` scores any candidate pack against a 10-dimension quality rubric with paste-ready fixes for every failure. The model is **scaffolding, not amber** — v0.36 already retired the install/uninstall semantics; v0.37 extends `scaffold` to third-party sources without re-introducing the managed-block trap. + +### What you can now do + +**Run `gbrain skillpack scaffold garrytan/skillpack-hackathon-evaluation`** and the pack lands in your workspace — files copied additively, refuses to overwrite anything you've edited, source URL + pinned commit + tarball SHA recorded in `~/.gbrain/skillpack-state.json`. First-time scaffold of a new source surfaces a TOFU confirm prompt showing name + author + source + pinned commit + tier; subsequent scaffolds of the same (name, author, pin) triple skip the prompt. Local-path sources skip the prompt entirely (you already own the directory). + +**Run `gbrain skillpack search yc`** and see every registered pack with a `yc` tag. Endorsed tier sorts first, community next, experimental last. `gbrain skillpack info ` shows full metadata: author, pinned commit, tarball SHA, validated_at timestamp, skill list, gbrain version requirement. + +**Run `gbrain skillpack init my-pack`** in an empty directory and you get a complete 10/10 pack tree out of the box: `skillpack.json` + `skills//SKILL.md` + `routing-eval.jsonl` (5 example intents) + `runbooks/bootstrap.md` + `CHANGELOG.md` + `README` + `LICENSE` + `.gitignore` + `test/` + `e2e/` + `evals/`. Edit the stubs, run `gbrain skillpack doctor . --quick`, run `gbrain skillpack pack`, get a deterministic `-.tgz` ready to publish. + +**Run `gbrain skillpack doctor `** against any candidate pack and see a 0-10 score with per-dimension pass/fail and paste-ready fix commands. The rubric splits into 5 required core dimensions (manifest valid, skills have SKILL.md, routing-evals present, unique triggers, CHANGELOG current) + 5 quality badges (unit tests, e2e tests, LLM-judge evals, bootstrap runbook, license). Tier eligibility: all 10 = endorsed-eligible, >=3 badges = community, core-only = experimental, any core fails = blocked. `--fix --yes` auto-scaffolds the dimensions flagged `auto_fixable: true`. + +**Read `examples/skillpack-reference/`** to see the canonical 10/10 pack. It ships inside the gbrain repo, scores 10/10 forever (regression-pinned), and doubles as both a publisher example and an integration-test fixture. `docs/skillpack-anatomy.md` documents the contract one page, auto-generated from the declarative rubric at `src/core/skillpack/rubric.ts`. + +### Itemized changes + +#### Third-party scaffold path + +- `src/core/skillpack/manifest-v1.ts` — runtime validator for third-party `skillpack.json`. Schema is `gbrain-skillpack-v1`; forward-compat `runbook_schema_version` + `eval_schema_version` knobs. Adapter `bundleManifestFromSkillpack` projects onto the v0.36 `BundleManifest` shape so existing `enumerateScaffoldEntries` + `loadSkillSources` paths consume third-party packs without changes. +- `src/core/skillpack/remote-source.ts` — `classifySpec` + `resolveSource`. Handles owner/repo → github URL expansion, https git clones (via SSRF-hardened `git-remote.ts`), local tarball extraction, and local directories. Cache layout: `~/.gbrain/skillpack-cache/git/////` and `~/.gbrain/skillpack-cache/tarball//`. Stage-then-rename pattern prevents partial-clone cache poisoning. +- `src/core/skillpack/tarball.ts` — deterministic pack + allowlist-gated extract. GNU tar flags (`--sort=name --mtime=@0 --owner=0 --group=0 --numeric-owner` + `GZIP=-n` + `TZ=UTC`) so same source dir → same SHA. Extract rejects symlinks / hardlinks / devices / FIFOs; caps at 5000 files, 1MB/file, 100MB total, 255-char paths, 100:1 compression ratio. +- `src/core/skillpack/state.ts` — machine-owned trust store at `~/.gbrain/skillpack-state.json`. Codex outside-voice G1 fix: pinned commits + tarball SHAs + rename maps live here, NOT in editable markdown comments. Atomic `.tmp + rename` write. `isAlreadyTrusted` encodes the codex G4 author-mismatch defense. +- `src/core/skillpack/trust-prompt.ts` — TOFU first-install confirm with full identity block. Local-path sources skip the gate; tarball + git sources prompt. Non-TTY without `--trust` refuses with an actionable message. +- `src/core/skillpack/bootstrap-display.ts` — codex T1 fix: post-scaffold runbook is DISPLAYED, never executed. Frame header makes clear: "gbrain deliberately does NOT auto-execute these steps." The agent reads the framed output and walks per-step at its own discretion. +- `src/core/skillpack/scaffold-third-party.ts` — orchestrator. Composes manifest validation + gbrain version gate + trust prompt + `enumerateScaffoldEntries` + `copyArtifacts` + state.json upsert + bootstrap display. +- `src/commands/skillpack.ts` extended — `cmdScaffold` disambiguates: contains `/` / `://` / `.tgz` → third-party; bare kebab → bundled-first, registry-fallback. New flags: `--trust`, `--no-cache`. + +#### Registry catalog (read side) + +- `src/core/skillpack/registry-schema.ts` — validators for `registry.json` (catalog) and `endorsements.json` (Garry-only tier overlay). Codex G3 separation: contributors PR catalog entries with `default_tier: community/experimental/dead`; only Garry edits the overlay file. +- `src/core/skillpack/registry-client.ts` — fetch + 1h soft-TTL cache + stale-fallback. On fetch failure: serves the last-good cache with a stderr warning; escalates to "cache is stale, run --refresh" past 7 days. Hard-fails only on first-run-with-no-network. +- `gbrain skillpack search [] [--tier T]` / `info ` / `registry [--url URL] [--refresh]` — read-side CLI surface. + +#### Quality bar — rubric + doctor + audit + +- `src/core/skillpack/rubric.ts` — declarative `SKILLPACK_RUBRIC_V1` array. Single source of truth for doctor + (auto-generated) anatomy doc + tests. Each dimension exports `{id, name, category, description, auto_fixable, check}` and the check function returns `{passed, detail, fix_hint}` so the doctor surfaces paste-ready remediation for every failure. +- `src/core/skillpack/doctor.ts` — `runDoctor({mode: 'quick' | 'full', fix, yes})`. `--quick` is the structural sweep (~5s, no sandbox / no LLM / no DB) for tight iteration loops. `--fix` walks `auto_fixable` dimensions and scaffolds missing routing-evals / CHANGELOG / test stubs / LLM-judge stubs / bootstrap runbook / LICENSE. +- `src/core/skillpack/audit.ts` — `~/.gbrain/audit/skillpack-YYYY-Www.jsonl`. ISO-week rotated; mirrors the slug-fallback + rerank-audit patterns. Best-effort writes (never throws); `gbrain doctor` reads recent events for the future activity surface. +- `gbrain skillpack doctor [--quick|--full] [--fix] [--yes] [--json]` — exit codes 0 if score=10, 1 if 6-9, 2 if blocked/<5. + +#### Publisher side + +- `src/core/skillpack/init-scaffold.ts` — `gbrain skillpack init ` cathedral default. Scaffolds the complete 10/10 pack tree; freshly-init'd pack scores 10/10 on `doctor --quick` immediately. `--minimal` flag drops test/e2e/evals for power users opting out. +- `src/core/skillpack/pack-publish.ts` — `gbrain skillpack pack [] [--out PATH] [--dry-run] [--skip-doctor]` runs `runDoctor(--quick)`, refuses if blocked, packs deterministic tarball. SHA-256 reported on success; publish-gate skill consumes `--skip-doctor` (gate runs server-side). +- `src/core/skillpack/endorse.ts` — `gbrain skillpack endorse [--tier T] [--repo PATH] [--push] [--dry-run]` is the Garry-only operator workflow. Validates pack is in the catalog, writes `endorsements.json` with stable key ordering, commits with a one-line message `endorse: -> `. + +#### Reference + anatomy doc + +- `examples/skillpack-reference/` — real 10/10 pack tree shipped inside the gbrain repo. SKILL.md body teaches the third-party contract; serves as both a publisher example and an integration-test fixture. Regression-pinned by `test/skillpack-reference-pack-is-ten.test.ts` — any change that drops the reference below 10/10 fails the build. +- `docs/skillpack-anatomy.md` — auto-generated one-page reference. Tree map + rubric tables + tier eligibility + CLI reference. Generator: `scripts/build-skillpack-anatomy.ts` (`--check` mode for CI freshness gating). +- `docs/designs/SKILLPACK_REGISTRY_V1_SPEC.md` — full strategic spec preserved as the design record. 27 locked decisions across CEO + Eng + DX (two rounds) + Codex outside-voice. + +#### v0.36.1.0 drift fixes (tangential cleanup) + +The v0.36.1.0 hindsight calibration wave shipped three new cycle phases (`propose_takes`, `grade_takes`, `calibration_profile`) but two E2E tests had stale assertions: + +- `test/e2e/cycle.test.ts` phase count 13 → 16. +- `test/e2e/dream-cycle-phase-order-pglite.test.ts` `EXPECTED_PHASES` updated + `mock.module` block for `embedding.ts` now declares `embedMultimodal` + `embedQuery` (v0.36.1.0 additions). + +These were unrelated to skillpack work; surfaced during the cathedral's E2E sweep and fixed as clean-up commits. + +### To take advantage of v0.37.0.0 + +`gbrain upgrade` runs `gbrain apply-migrations` which is a no-op for this release (no schema migrations). To start publishing skillpacks: + +1. **Initialize a new pack**: `gbrain skillpack init my-pack` +2. **Edit + iterate**: `cd my-pack`, edit `skills/my-pack/SKILL.md` + add real routing intents to `routing-eval.jsonl` +3. **Verify**: `gbrain skillpack doctor . --quick` +4. **Pack**: `gbrain skillpack pack` → emits `my-pack-0.1.0.tgz` with deterministic SHA +5. **Publish**: push the pack repo to GitHub; share `owner/repo` for `gbrain skillpack scaffold owner/repo` + +To consume a third-party pack: + +1. **Search**: `gbrain skillpack search ` (when the registry repo at `garrytan/gbrain-skillpack-registry` ships; until then, scaffold by URL) +2. **Scaffold**: `gbrain skillpack scaffold owner/repo` or `gbrain skillpack scaffold ./path/to/pack.tgz` +3. **Inspect**: the agent reads the displayed `bootstrap.md` and walks the steps at its own discretion + +If `gbrain skillpack doctor` or `gbrain skillpack scaffold` errors out, please file an issue at https://github.com/garrytan/gbrain/issues with the command you ran, the pack source URL or local path, and output of `gbrain doctor`. This feedback loop is how the gbrain maintainers find rough edges in v1 of the third-party skillpack flow. + +### Deferred to follow-up waves + +- W4.5 retrofit of bundled gbrain skills to 10/10 (content work; multi-day; `doctor --fix --yes` auto-scaffolds most of it). +- Subprocess sandbox (bwrap / sandbox-exec) for publish-gate trial install (the publish-gate skill itself remains a follow-up; today the publisher runs `doctor --quick` locally and the validation log is the trust artifact). +- `garrytan/gbrain-skillpack-registry` GitHub repo setup with the CI workflow split (codex G3). +- Cross-listing in `mvanhorn/printing-press-library` (external repo PR). +- Generated `gbrain-cli` via Printing Press for non-gbrain agents to hit a remote gbrain. ## [0.36.6.0] - 2026-05-19 **Your photos finally show up when you ask. The brain just got eyes.** diff --git a/CLAUDE.md b/CLAUDE.md index 6a987186a..2fdc4c209 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,6 +120,7 @@ strict behavior when unset. - `src/commands/skillify-check.ts` (v0.19) — `gbrain skillpack-check` agent-readable health report. Exit 0/1/2 for CI pipeline gating; JSON for debugging. Wraps `check-resolvable --json`, `doctor --json`, and migration ledger into one payload so agents can decide whether a human action is required. - `src/commands/book-mirror.ts` (v0.25.1) — `gbrain book-mirror --chapters-dir --slug [flags]`. Flagship of the v0.25.1 skills wave. Submits N read-only subagent jobs (one per chapter; `allowed_tools: ['get_page', 'search']`), waits for all via `waitForCompletion`, reads each child's `job.result`, assembles two-column markdown CLI-side, writes a single operator-trust `put_page` to `media/books/-personalized.md`. Codex HIGH-1 fix applied: trust narrowing happens at the tool-allowlist layer (subagents can't call put_page) instead of allowedSlugPrefixes — untrusted EPUB content cannot prompt-inject any people page. Cost-estimate prompt before launching; refuses to spend in non-TTY without `--yes`. Per-chapter idempotency keys (`book-mirror::ch-`) for retry-friendly re-runs. Partial-failure handling: assembles with completed chapters and a `## Failed chapters` section listing retries. Test surface: `test/book-mirror.test.ts` (9 cases — CLI registration + source invariants). - `src/commands/skillpack.ts` + `src/core/skillpack/{bundle,scaffold,reference,migrate-fence,scrub-legacy,harvest,harvest-lint,copy,apply-hunks,diff-text,installer}.ts` (v0.19 → v0.36) — **v0.36 contract change**: managed-block install model retired. `install` and `uninstall` removed (clean break, no alias; both exit non-zero with a hint pointing at the replacement command). New surface: `scaffold` (one-time additive copy via shared `copyArtifacts` helper in `copy.ts`; refuses to overwrite existing files; partial-state fills missing paired sources declared in SKILL.md frontmatter `sources:`), `reference` (read-only diff lens with agent-readable framing line + `--apply-clean-hunks` two-way auto-apply via pure-JS unified-diff parser/applier in `apply-hunks.ts` + `diff-text.ts`), `migrate-fence` (one-shot strip of legacy fence; cumulative-slugs receipt → row-parsing fallback; preserves rows verbatim as user-owned routing), `scrub-legacy-fence-rows` (opt-in row cleanup with skill-present + non-empty-triggers gate), `harvest` (host→gbrain inverse with symlink-reject + canonical-path containment via `validateUploadPath`-style gate + default-on privacy linter in `harvest-lint.ts` against `~/.gbrain/harvest-private-patterns.txt` plus built-in `\bWintermute\b` + email + Slack-channel patterns; rollback on match). Paired-source declarations moved from `openclaw.plugin.json` to each SKILL.md's frontmatter `sources:` array (D2; validated by `loadSkillSources` in `bundle.ts`). `autoDetectSkillsDir` (in `src/core/repo-root.ts`) gains a `cwd_walk_up` tier ahead of `~/.openclaw/workspace` (D3; non-OpenClaw hosts like `~/git/wintermute` auto-detect; R5 regression preserves `$OPENCLAW_WORKSPACE` precedence). `gbrain skillpack check --strict` exits non-zero on drift (CI gate); top-level `gbrain skillpack-check` keeps exit-1-on-issues for cron compat. Companion editorial skill `skills/skillpack-harvest/SKILL.md` drives the genericization checklist before the CLI runs. Design + workflow doc: `docs/guides/skillpacks-as-scaffolding.md`. ~600 LOC of managed-block machinery deleted; ~400 LOC of new modules + ~1000 LOC of new test coverage across `test/skillpack-{copy,scaffold,reference,reference-apply,apply-hunks,migrate-fence,scrub-legacy,harvest,harvest-lint,frontmatter-sources}.test.ts` + 9-case E2E in `test/e2e/skillpack-flow.test.ts`. `installer.ts` + `test/skillpack-install.test.ts` survive for now — `gbrain skillpack diff` still uses `diffSkill` from there; slated for v0.37 cleanup. **Historical (v0.19-v0.35.1):** managed-block model with ``/`end -->` fence, `cumulative-slugs="..."` receipt, content-hash gates, lockfile; `install --all` prune; `uninstall` with D8 receipt gate + D11 atomic-refusal content-hash pre-scan. Replaced wholesale in v0.36. +- `src/core/skillpack/{manifest-v1,tarball,state,remote-source,trust-prompt,bootstrap-display,scaffold-third-party,registry-schema,registry-client,rubric,doctor,init-scaffold,pack-publish,endorse,audit}.ts` + `examples/skillpack-reference/` + `docs/skillpack-anatomy.md` + `scripts/build-skillpack-anatomy.ts` (v0.37.0.0) — third-party skillpack ecosystem layered on the v0.36 scaffolding contract. `gbrain skillpack scaffold ` resolves the spec via `classifySpec`, fetches through SSRF-hardened `git-remote.ts` (git) or extracts the tarball into `~/.gbrain/skillpack-cache/////`, validates `skillpack.json` (api_version `gbrain-skillpack-v1`), checks `gbrain_min_version`, surfaces a TOFU first-install identity-confirm prompt (codex G4: author + source + pinned commit + tarball SHA + tier; non-TTY requires `--trust`), records the pin in machine-owned `~/.gbrain/skillpack-state.json` (codex G1; schema `gbrain-skillpack-state-v1`, atomic `.tmp + rename`, `isAlreadyTrusted` skips re-prompt on author+pin match), runs through the existing `enumerateScaffoldEntries` → `copyArtifacts` pipeline (one-time additive copy; refuses to overwrite), then DISPLAYS `runbooks/bootstrap.md` without executing (codex T1 npm-postinstall lesson — "gbrain deliberately does NOT auto-execute these steps"). Registry catalog at `garrytan/gbrain-skillpack-registry` (separate repo) split into `registry.json` (PR-able, schema `gbrain-registry-v1`) + `endorsements.json` (Garry-only overlay, `gbrain-endorsements-v1`); `effectiveTier` merges. `registry-client.ts` fetches both URLs via `If-None-Match` etag with 1h soft-TTL + stale-fallback (origins: `fresh_fetch | cache_warm | cache_soft_stale | cache_hard_stale`); hard-fail only on no-cache + no-network. `gbrain skillpack {search,info,registry,doctor,init,pack,endorse}` CLI surface. Doctor walks `SKILLPACK_RUBRIC_V1` (10 binary dimensions, codex T4 split into 5 required CORE — manifest_valid, skills_have_skill_md, routing_evals_present ≥5 intents, skills_have_unique_triggers MECE, changelog_present_and_current — and 5 quality BADGES — unit_tests_present, e2e_tests_present, llm_eval_present ≥3 cases, bootstrap_runbook_present, license_present); tier eligibility: `endorsed` needs all 10, `community` needs core + ≥3 badges, `experimental` needs core only, `blocked` when any core fails. `--quick` ~5s structural sweep; `--fix --yes` auto-scaffolds `auto_fixable: true` dimensions and refuses to overwrite files whose mtime is newer than `skillpack.json`. `gbrain skillpack init ` lands the full cathedral (11 files: skillpack.json, SKILL.md, routing-eval.jsonl, test/example.test.ts, e2e/example.e2e.test.ts, evals/example.judge.json, runbooks/{bootstrap,uninstall,upgrade-template}.md, CHANGELOG, README, LICENSE); freshly-init'd pack scores 10/10. `--minimal` skips test/e2e/evals. `gbrain skillpack pack` packs a deterministic tarball via GNU tar (`--sort=name --mtime=@0 --owner=0 --group=0 --numeric-owner` + `GZIP=-n` + `TZ=UTC`); refuses on `tier_eligibility === 'blocked'`. Extract caps (5000 files / 100MB total / 1MB per file / 255-char paths / 100:1 compression ratio); rejects symlinks/hardlinks/devices/FIFOs. `gbrain skillpack endorse [--tier ...] [--push] [--dry-run]` runs in a clone of the registry repo: validates pack exists in `registry.json`, mutates `endorsements.json` via pure `applyEndorsement`, stable-key-orders the write, stages + commits `endorse: -> `, optionally pushes. JSONL audit at `~/.gbrain/audit/skillpack-YYYY-Www.jsonl` (ISO-week rotated, honors `GBRAIN_AUDIT_DIR`). `examples/skillpack-reference/` is a real 10/10 reference pack pinned by `test/e2e/skillpack-third-party.test.ts`. `docs/skillpack-anatomy.md` is auto-generated via `scripts/build-skillpack-anatomy.ts` with `--check` mode for CI drift detection. CLI dispatch in `src/commands/skillpack.ts` disambiguates third-party (contains `/`, `://`, `.tgz`) from bundled-skill kebab; kebab routes bundled-first, registry-fallback. Test coverage: `test/skillpack-{manifest-v1,tarball,state,remote-source,trust-prompt,registry-schema,registry-client,rubric,doctor,init-scaffold,pack-publish,endorse,audit,scaffold-third-party}.test.ts` + e2e at `test/e2e/skillpack-third-party.test.ts`. Plan + spec at `docs/designs/SKILLPACK_REGISTRY_V1_SPEC.md`. Deferred to follow-up waves: subprocess sandbox for publish-gate, `garrytan/gbrain-skillpack-registry` repo creation + CI workflow split (codex G3), Printing Press cross-list at `mvanhorn/printing-press-library`, generated `gbrain-cli` via Printing Press, W4.5 retrofit of ~25 bundled skills to 10/10. - `src/core/archive-crawler-config.ts` (v0.25.1) — D12 + codex HIGH-4 safety gate for the `archive-crawler` skill. Refuses to run unless `archive-crawler.scan_paths:` is explicitly set in the brain repo's `gbrain.yml`. Mirrors the storage-config.ts parsing pattern (sibling file; separate concern from storage tiering). `loadArchiveCrawlerConfig(repoPath)` throws `ArchiveCrawlerConfigError(missing_section | empty_scan_paths | invalid_path | parse_error)`. `normalizeAndValidateArchiveCrawlerConfig` rejects relative paths and `..` traversal; `~` is expanded; trailing-slash normalized for unambiguous prefix matching. `isPathAllowed(candidate, config)` is the runtime per-file gate (scan_paths prefix-match with directory-boundary correctness; deny_paths overrides). Tests in `test/archive-crawler-config.test.ts` (19 cases). - `test/helpers/cli-pty-runner.ts` (v0.25.1) — generic real-PTY harness ported from gstack and trimmed to ~470 lines. Uses pure `Bun.spawn({terminal:})` (Bun 1.3.10+; engines.bun pin in package.json). Generic primitives only — no plan-mode orchestrators. Exports: `launchPty`, `resolveBinary`, `stripAnsi`, `parseNumberedOptions`, `optionsSignature`, `isNumberedOptionListVisible`, `isTrustDialogVisible`. Self-tests in `test/cli-pty-runner.test.ts` (24 cases). - `src/core/skill-manifest.ts` (v0.19) — parser for `skill-manifest.json` records. Used by skillpack installer to detect drift between the shipped bundle and the user's local edits, so updates merge instead of overwriting. diff --git a/VERSION b/VERSION index a08a2936d..833a10211 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.36.6.0 \ No newline at end of file +0.37.0.0 \ No newline at end of file diff --git a/docs/designs/SKILLPACK_REGISTRY_V1_SPEC.md b/docs/designs/SKILLPACK_REGISTRY_V1_SPEC.md new file mode 100644 index 000000000..10b49303d --- /dev/null +++ b/docs/designs/SKILLPACK_REGISTRY_V1_SPEC.md @@ -0,0 +1,1534 @@ +# Skillpack Publish + Registry + Install Spec (post-v0.36.0.0) + +> **⚠️ Needs v0.36 realignment.** This spec was written assuming the pre-v0.36 +> managed-block install model (which v0.36.0.0 retired in favor of +> scaffold + reference + harvest). The strategic decisions remain sound — third-party +> publish + registry + doctor + rubric + tarball + TOFU + sandbox + CI workflow +> split + anti-typosquat — but the **verbs and integration points change**: +> +> | Old spec verb | v0.36-aligned verb | What changes | +> |---|---|---| +> | `gbrain skillpack install ` | `gbrain skillpack scaffold ` | One-time additive copy, no managed block, refuses to overwrite. | +> | `gbrain skillpack uninstall ` | (gone) | User owns files; deletes via `rm` or git. | +> | Auto-walk runbook | Display `bootstrap.md` post-scaffold | Already aligned with codex T1 (per-step approval) — becomes a printed checklist, not an executor. | +> | Multi-source resolver receipt | Per-scaffold state in `~/.gbrain/skillpack-state.json` | Codex G1 was the right call; v0.36 retired the resolver-block anyway. | +> | Auto-rename collision | Refuses-to-overwrite (v0.36's contract) | Codex was right; v0.36 already enforces it. | +> | Update path | `gbrain skillpack reference [--apply-clean-hunks]` | Diff-lens with optional auto-merge of clean hunks. | +> +> What stays verbatim: registry at `garrytan/gbrain-skillpack-registry`, rubric, +> doctor, anatomy doc, tarball determinism, TOFU + SHA pinning, endorsement +> tiers, sandbox + env scrub, CI workflow split, anti-typosquat. See +> `docs/guides/skillpacks-as-scaffolding.md` (the v0.36 canonical model) for +> the contract this spec must align to before implementation. +> +> See [README at top of file for status; full spec preserved below as the strategic record.] + +## Context + +After v0.36.0.0 (Hindsight Calibration), gbrain has a mature skillpack system — +but it only knows how to install the **one** bundle declared in +`openclaw.plugin.json` at gbrain's own repo root. Garry wants to ship a +"hackathon-evaluation" skillpack as a standalone artifact, any gbrain user +should be able to discover + install it, and the ecosystem should grow +without Garry hand-curating every README. That requires five new capabilities: + +1. **Publish** — a documented repo layout + manifest format that anyone can + point a git repo at and call "a skillpack." +2. **Distribute** — a transport from one user's machine to another. Git + + tarball, no centralized hosting infra. +3. **Install** — extend `gbrain skillpack install` to accept a third-party + source, with trust posture appropriate for installing markdown/SKILL.md + files into a workspace that an agent then routes through. +4. **Discover + curate** — a canonical catalog at + `github.com/garrytan/gbrain-skillpack-registry` (the Printing Press + library pattern) with endorsement tiers, security gating, and + programmatic search. Third-party packs live in their authors' repos; + the registry is a `registry.json` manifest pointing at them. +5. **Cross-distribute** — list in `mvanhorn/printing-press-library` as a + sister registry, AND use Printing Press to generate an agent-native CLI + wrapper around gbrain's HTTP MCP so non-gbrain agents can hit a gbrain + instance remotely. Doubles the discovery surface. + +The intended outcome is: `gbrain skillpack install hackathon-evaluation` +resolves through the registry, clones the source repo (or fetches the +pinned tarball), validates the manifest, drops the skill files into the +workspace, and routes them via RESOLVER.md / AGENTS.md — same code paths as +the bundled install today, just with a remote source and a registry catalog +in front. + +## Landscape (informs every design choice below) + +- `agentskills.io` is the open SKILL.md standard adopted across Claude + Code / Codex / Cursor / Gemini CLI / OpenClaw. The format is solved. +- ~2,500 Claude Code marketplaces already exist (claudemarketplaces.com). + Hosting is solved 2,500 times over. The moat is curation + quality bar. +- **13% of skills in existing marketplaces carry critical vulnerabilities** + (per tech-leads-club, May 2026). Security gating at publish time is a + real differentiator, not a vanity feature. +- `mvanhorn/cli-printing-press` (~1.4k stars) splits the engine from the + library exactly the way this plan proposes. The publish-skill pattern + (`/printing-press-publish`) is the load-bearing UX move — no contributor + hand-runs git. We replicate it. +- gbrain skillpacks have a **runtime contract** (they assume `gbrain query`, + `put_page`, sources, takes, hindsight calibration). They are not portable + to a generic agent harness without gbrain installed. That's what justifies + a dedicated registry instead of mirroring agentskills.io — the validation + surface is gbrain-shaped. + +## Today's baseline (what exists, won't be re-built) + +- `BundleManifest` schema in `src/core/skillpack/bundle.ts:13-20` + (`name`, `version`, `description`, `skills[]`, `shared_deps[]`, + `excluded_from_install?[]`). Reuse + extend; do not re-invent. +- `installer.ts:259-293` — cumulative-slugs receipt embedded in the managed + block (``). + Single source of truth for "what's installed from gbrain." +- `installer.ts:376-402` — per-file byte-compare gate before install / + uninstall (D11). Refuses to clobber user edits without `--overwrite-local`. +- `installer.ts:180-253` — atomic lockfile with 10-min stale threshold and + PID liveness check. Concurrency safety. +- `src/core/git-remote.ts` — SSRF-hardened `cloneRepo` / `pullRepo` with + `https://`-only scheme allowlist, no submodules, no redirects, no + `protocol.file`/`protocol.ext`. Same primitive remote-sources uses. +- `src/core/url-safety.ts:isInternalUrl` — blocks RFC1918 / CGNAT / + metadata-IP / IPv6-loopback. Already wired through `parseRemoteUrl`. +- `src/core/resolver-filenames.ts` — RESOLVER.md / AGENTS.md filename + fallback chain. Per-skill rows live in a managed block inside one of + these files. + +## Recommended design + +### Distribution: git + tarball, v1 + +A skillpack is **a git repo with `skillpack.json` at root + a `skills/` +directory**. A `.tgz` of that same tree is an offline-transportable form +of the same thing. Both paths land in the same installer. + +- `gbrain skillpack install /` — clones via existing + `git-remote.ts` SSRF-hardened path. +- `gbrain skillpack install ` — verbatim https URL. +- `gbrain skillpack install ` — extract to cache dir, + install from extracted tree. Useful inside YC orgs / corp networks that + block direct GitHub access, or for air-gapped distribution. +- `gbrain skillpack install ` — local path (skill authors + testing). +- `gbrain skillpack pack [--out ]` — publisher-side command that + validates the manifest, runs the full `pack --dry-run` pipeline, and + emits `-.tgz`. SHA-256 of the tarball is recorded in the + TOFU receipt so re-install of the same tarball is silent, but a + tampered tarball with the same filename fails loud. +- Tarball schema: gzipped tar with `skillpack.json` at the top level and + `skills/...` beneath it. No symlinks, no executables, no dotfiles + outside an allowlist (`.gitignore`, `.gitattributes`). Validator runs + on extract. + +Tarballs are an additive transport, not a different artifact shape. The +installer normalizes both paths to "extracted tree on disk" before the +existing `enumerateBundle` + `applyInstall` pipeline runs. + +### Registry: github.com/garrytan/gbrain-skillpack-registry + +A separate git repo Garry controls. **Not** a hosting layer — a catalog. +Skillpacks live in their authors' own repos; the registry points at them. + +Two files at the registry repo root: + +- `registry.json` — the live catalog. Schema: + + ```json + { + "schema_version": "gbrain-registry-v1", + "updated_at": "2026-06-12T15:00:00Z", + "skillpacks": [ + { + "name": "hackathon-evaluation", + "description": "Score hackathon submissions with the YC rubric.", + "author": "Garry Tan", + "author_handle": "garrytan", + "homepage": "https://github.com/garrytan/skillpack-hackathon-evaluation", + "source": { + "kind": "git", + "url": "https://github.com/garrytan/skillpack-hackathon-evaluation.git", + "pinned_commit": "abc1234567890..." + }, + "tarball_sha256": "deadbeef...", + "gbrain_min_version": "0.36.0", + "tier": "endorsed", + "tags": ["evaluation", "yc", "founders"], + "validated_at": "2026-06-12T15:00:00Z", + "validation_run_id": "2026-06-12T14-58-...", + "skills_count": 2, + "skills": ["judge-submission", "score-rubric"] + } + ] + } + ``` + +- `endorsements.json` — Garry-controlled. The single source of truth for + which entries get the `endorsed` tier. Decoupling the endorsement + decision from the catalog write means a contributor's PR can land a + catalog entry at the `community` tier; promotion to `endorsed` is a + separate, smaller, Garry-only commit. + +**Endorsement tiers:** + +- `endorsed` — Garry has used it, it works, it's in his pinned set. + Listed first in `gbrain skillpack search` output. Manual promotion. +- `community` — passed the publish-gate validation, lives in the catalog, + but Garry hasn't personally vetted it. Default tier on first PR. +- `experimental` — author self-flagged as in-development. Listed last, + with a stderr warning at install time. + +**CLI integration:** + +``` +gbrain skillpack search [--tier endorsed|community|experimental] [--json] +gbrain skillpack info # from registry, not from local install +gbrain skillpack install # resolves through registry by short name +gbrain skillpack install # still works for direct installs +gbrain skillpack install starter-pack # special bundle entry in registry.json +gbrain skillpack registry [--url ] # show/set the configured registry +``` + +`--url` defaults to `https://raw.githubusercontent.com/garrytan/gbrain-skillpack-registry/main/registry.json`. +Operators inside corp networks can point at a fork. The registry URL is +recorded in `~/.gbrain/config.json` under `skillpack.registry_url`. + +`gbrain skillpack install starter-pack` resolves a special `bundles` array +in `registry.json` (a named list of skillpack names) and installs each in +order. Garry curates the starter pack. + +**First-install identity confirm + anti-typosquat (codex G4):** + +`gbrain skillpack install ` on first install of a given source +surfaces a confirm prompt with full identity: + +``` +[skillpack] About to install: + Name: hackathon-evaluation + Author: garrytan + Source: https://github.com/garrytan/skillpack-hackathon-evaluation + Pinned commit: abc1234567890abcdef1234567890abcdef12345 + Tier: endorsed + Tarball SHA: sha256:deadbeef... +Continue? [y/N] +``` + +Honored on TTY; non-TTY requires `--trust` flag and prints the same +block to stderr. + +Subsequent installs of the same `/` pair at the same +pinned commit + tarball SHA skip the prompt (already trusted). +Different pinned commit re-prompts. Different author with the same +name re-prompts (someone may have transferred / forked the pack). + +**Registry-side anti-typosquat gate:** the publish-gate (post-merge +workflow) rejects new submissions whose name is within Damerau- +Levenshtein edit-distance 2 of any existing `endorsed`-tier pack +name. Community-tier packs do NOT block (the registry shouldn't be +the typosquat arbiter for low-tier packs; the install-time confirm +is the user-facing defense). Effort: ~0.5d to wire the distance check +into the validate-pr.yml manifest scan; uses an off-the-shelf +distance algorithm (no external dep). + +**Bundle atomicity contract** (per eng-review D3): per-pack independent. +Each pack in a bundle install is its own transaction in the managed +block. Mid-bundle failure leaves earlier successful packs installed, +skips later packs, and prints a summary: + +``` +[skillpack] starter-pack: 3 of 5 installed + ✓ hackathon-evaluation @ 0.1.0 + ✓ founder-scorecard @ 0.2.0 + ✗ resume-roaster — pinned commit unreachable + ⤓ market-sizer — skipped after failure + ⤓ pitch-doctor — skipped after failure +Retry the failed pack with: gbrain skillpack install resume-roaster +``` + +Matches the multi-source resolver design (each source independent). +Partial progress is recoverable; no surprise rollback. Failures inside +a bundle do not poison the user's RESOLVER.md / AGENTS.md. + +**Endorsement workflow**: a dedicated CLI command edits +`endorsements.json` with schema validation. Hand-editing remains +possible (it's just JSON), but the command is the canonical path. + +``` +gbrain skillpack endorse [--tier endorsed|community|experimental] + [--push] [--dry-run] +``` + +Run from a clone of `garrytan/gbrain-skillpack-registry`. Steps: +1. Read + validate current `endorsements.json` against the schema. +2. Confirm `` exists in `registry.json`. +3. Update or insert the entry with the new tier. +4. Write back with stable key ordering (so diffs are clean). +5. Stage + create a one-line conventional commit: `endorse: -> `. +6. If `--push`, push to `main`. Otherwise print "now run git push" hint. + +### Publish-gate skill: /gbrain-skillpack-publish + +Mirrors `mvanhorn/cli-printing-press`'s `/printing-press-publish` exactly. +No contributor hand-runs git. The skill drives: + +1. **Local validation** (`gbrain skillpack pack --dry-run`): + - `skillpack.json` schema check + - SKILL.md frontmatter on every listed skill + - File-type allow-list (no `.env`, `.ssh`, `.pem`, no executables) + - Slug-collision sweep against the live `registry.json` + - `gbrain check-resolvable` clean + - `gbrain routing-eval` clean (structural layer) +2. **Security gates** (publish-gate v1, see decision Q3): + - Static analysis on any embedded scripts (shellcheck for `.sh`, + a heuristic JSON/YAML safety pass on data files) + - Dependency declaration check — every external resource referenced + in SKILL.md must be in a declared `external_resources:` array + - Trial install: extract pack into a tempdir, run `gbrain skillpack + install ` against an ephemeral PGLite-backed gbrain (mirrors + the `test/e2e/longmemeval` ephemeral-PGLite pattern at + `src/eval/longmemeval/harness.ts`), assert `gbrain check-resolvable` + stays clean and the skill rows appear in the managed block. + - Trial install runs with `GBRAIN_SKILLPACK_SANDBOX=1`, which disables + any operation that writes outside the workspace or hits the network. + +3. **Test + eval suite execution** (DX-review decision: run everything + in the sandbox so endorsement signals are measurable): + - **Unit tests**: walk every `unit_tests[]` glob inside the sandbox, + run via `bun test`, collect pass/fail per file. + - **E2E tests**: if a `DATABASE_URL` is exposed inside the sandbox + (Linux: `unshare`'d PG socket; macOS: docker-bridged); skip-gracefully + when not. + - **LLM-judge evals**: load each `*.judge.json`, run the cross-modal + pipeline with `__setChatTransportForTests` stubbed gateway so the + publisher pays zero LLM cost during the publish gate. The publisher + ran real-gateway evals before submitting; the validation log links + to their results. Sandbox re-runs against the stubbed gateway prove + the pipeline runs end-to-end and the eval JSON is well-formed. + - **Routing evals**: structural matching via the existing + `gbrain routing-eval` command. Asserts every `intent` in + `routing-eval.jsonl` resolves to the declared `expected_skill` + given the skill's `triggers:` frontmatter. + - **Coverage score**: published as a single percentage in the + validation log. Drives tier eligibility: + - `endorsed`: routing + runbooks + >=95% pass. + - `community`: routing + install runbook + >=80% pass. + - `experimental`: anything that passes structural validation. + - **Failed eval / test surfaces actionable line**: each failure + in the validation log includes the file path, the assertion, and + a paste-ready re-run command (`bun test ` or + `gbrain routing-eval skills//routing-eval.jsonl`). +3. **Tarball + hash**: + - `gbrain skillpack pack --out skillpack--.tgz` + - SHA-256 recorded for registry pin +4. **Registry PR** (Printing Press pattern verbatim): + - Fork `garrytan/gbrain-skillpack-registry` if not already forked + - Branch `add--` + - Append catalog entry to `registry.json` with tier=`community`, + pinned commit, tarball SHA-256, validated_at timestamp, and a + `validation_run_id` that points at a JSON log committed to a + `validation-runs/.json` file under the registry repo so + anyone can audit what was checked + - Open PR against `garrytan/gbrain-skillpack-registry:main` with the + validation log in the body + - Stretch: Garry's `endorse ` command flips the entry to + `endorsed` via a one-line commit on `endorsements.json` + +The skill file itself ships in the gbrain skillpack at +`skills/gbrain-skillpack-publish/SKILL.md`. Invokable from any agent +harness that loads gbrain skills. + +### Printing Press cross-distribution + +Two-direction integration (decision Q2): + +- **Cross-list**: open a PR against `mvanhorn/printing-press-library` + registering `garrytan/gbrain-skillpack-registry` as a sister-registry + in their catalog (their library has a `sister_registries:` section per + their AGENTS.md). Their 1.4k-star audience discovers gbrain through + the same search surface they already use. +- **Generate**: run `printing-press print` against `gbrain serve --http`'s + OpenAPI spec (gbrain's HTTP MCP exposes a JSON-RPC surface with stable + tool definitions). The output is a `gbrain-cli` agent-native binary + with SQLite mirror that any agent — not just gbrain users — can use to + hit a remote gbrain. Submitted back to `mvanhorn/printing-press-library` + as a published CLI, credited to Garry. Doubles distribution surface and + turns gbrain into something Printing Press users can route through. + +The cross-list is ~1 day. The generated CLI is ~1 week and produces a +genuinely new artifact that didn't exist before: gbrain-as-a-service +viewed from outside the gbrain runtime. + +### Manifest schema: `skillpack.json` (cathedral artifact) + +A gbrain skillpack is a **full software package**, not just markdown. +Same shape as npm/cargo: code, tests, evals, runbooks, changelog. This +is the differentiation moat per the DX review: nobody else ships AI +evals and agent-readable install/upgrade runbooks as first-class +package artifacts. + +```json +{ + "api_version": "gbrain-skillpack-v1", + "name": "hackathon-evaluation", + "version": "0.1.0", + "description": "Score hackathon submissions with the YC rubric.", + "author": "Garry Tan ", + "license": "MIT", + "homepage": "https://github.com/garrytan/skillpack-hackathon-evaluation", + "gbrain_min_version": "0.36.0", + "skills": ["skills/judge-submission", "skills/score-rubric"], + "shared_deps": [], + "excluded_from_install": [], + + "unit_tests": ["test/**/*.test.ts"], + "e2e_tests": ["e2e/**/*.test.ts"], + "llm_evals": ["evals/*.judge.json"], + "routing_evals": ["skills/*/routing-eval.jsonl"], + "runbooks": { + "install": "runbooks/install.md", + "uninstall": "runbooks/uninstall.md", + "upgrades": "runbooks/upgrade-*.md" + }, + "changelog": "CHANGELOG.md" +} +``` + +**Field semantics:** + +- `api_version` — forward-compat key; installer refuses unknown. + Schema is `gbrain-skillpack-v1`. Codex outside-voice gap: single + `api_version` doesn't cover runbook/eval/sandbox schema evolution. + Manifest also carries `runbook_schema_version` (default 1) + + `eval_schema_version` (default 1). Installer accepts a configured + range per dimension; rejects manifests declaring a newer schema + than the local gbrain supports with a paste-ready `gbrain upgrade` + hint. Refuses silent downgrade. +- `gbrain_min_version` — fail-fast version gate (existing semver helper). +- `name` — must match the directory name; unique in registry namespace. +- `skills[]` — relative paths from repo root; same as today's `enumerateBundle`. +- `unit_tests[]` — glob(s) discovered in the sandbox and run during the + publish gate. Pure Bun unit tests, no DB. +- `e2e_tests[]` — glob(s) for integration tests. Run if `DATABASE_URL` + is reachable inside the sandbox (skip-gracefully otherwise). +- `llm_evals[]` — cross-modal eval configs in the gbrain v0.27.x format + (task/output prompt with multi-model judging). Run with a **stubbed + gateway** in the publish-gate sandbox so no real API spend; the + publisher's machine runs real-gateway evals before submitting. +- `routing_evals[]` — `routing-eval.jsonl` files with `{intent, + expected_skill, ambiguous_with?}` rows. Structural matching against + the skill's `triggers:` frontmatter. The single highest-leverage eval + type for an agent-routed skillpack: proves user phrases actually fire + the right skill. +- `runbooks.{install, uninstall}` — agent-readable markdown (see + format below). +- `runbooks.upgrades` — glob expanding to `upgrade--to-.md` + files; the agent picks the right one based on the version recorded + in the resolver receipt. +- `changelog` — required; the agent surfaces "what changed" at upgrade + time directly from this file. + +**Tier eligibility based on coverage** (publish-gate scores each pack): + +- `endorsed` tier requires: routing-evals AND runbooks AND >=95% pass on + declared tests + evals. +- `community` tier requires: routing-evals AND install.md AND >=80% + pass on declared tests + evals. +- `experimental` tier accepts anything that passes structural validation. + +A pack with 0 evals + 0 tests is publishable as `experimental` only. +The publish-gate emits a one-line score summary so the publisher sees +exactly what's blocking promotion. + +### Install/upgrade trust model: per-step approval, not auto-walk (codex T1) + +The DX review's first cut had `gbrain skillpack install ` auto- +walk `runbooks/install.md` after dropping files. Codex pointed out +that this runs trusted-path (`remote=false`) gbrain CLI calls against +the user's brain on every install — a malicious community-tier pack +mutates brain state on first install. v1 fix: **runbook executor +defaults to per-step approval**. + +- `gbrain skillpack install ` ALWAYS drops files + updates the + resolver block. That part is content-only; the trust gates (TOFU + + content-hash + endorsement tier) already cover it. +- After file-drop, if `runbooks/install.md` exists, the install + command **prints each step + waits for explicit y/N** on a TTY. + Three step kinds (`agent:`, `show user:`, `ask user:`) all surface + the literal text before execution. +- `--runbook-apply-all` flag bypasses the per-step prompt for CI / + unattended agent use. Loud stderr line on first use: + `[skillpack] applying runbook unattended; this skillpack is community + tier — confirm trust by inspecting /runbooks/install.md`. +- `--runbook-skip` lands the files without executing any runbook step + (the publisher gets file-drop only; everything else is the user's + decision). +- `endorsed` tier eligible for an auto-walk-after-N-installs UX in + v1.1 (after the user has confirmed N runbook walks of that pack + succeed, prompt drops). Out of scope for v1. + +This is the npm postinstall lesson learned the hard way: +auto-execute on install is how supply-chain attacks happen. +Per-step + dry-run + endorsement is how trust gets earned. + +### Agent runbook format (runbooks/install.md, uninstall.md, upgrade-*.md) + +Mirrors gbrain's own `skills/migrations/v0.21.0.md` pattern — markdown +that an agent reads top-to-bottom and executes step-by-step. + +```markdown +--- +runbook_kind: install +gbrain_version_range: ">=0.36.0 <0.37.0" +skillpack: hackathon-evaluation +skillpack_version: 0.1.0 +--- + +# Install runbook: hackathon-evaluation v0.1.0 + +1. **agent:** `gbrain put_page wiki/_skillpack-hackathon-evaluation + --frontmatter type=skillpack-config` + - Why: bootstraps the config page this skillpack reads from. +2. **show user:** "Hackathon evaluation is installed. Try: 'Judge this + submission against the YC rubric.'" +3. **ask user:** "Want a starter list of evaluation criteria added to + your brain?" + - On yes: `gbrain put_page wiki/concepts/yc-rubric < seeds/rubric.md` + - On no: skip. +``` + +Three step kinds, each a tagged-line shape so the runbook parser is +unambiguous: + +- **`agent:`** — the calling agent runs the command verbatim. +- **`show user:`** — display the message to the user (no action). +- **`ask user:`** — require user confirmation; the next step is gated. + +Upgrade runbooks (`upgrade--to-.md`) follow the same shape +with extra frontmatter (`from_version`, `to_version`) so the +upgrade-walker picks the right runbook when stepping multi-version +upgrades (e.g., v0.1 → v0.2 → v0.3 walks two runbooks in sequence). + +### Quality rubric + doctor + reference pack + +A skillpack is only as good as the agent's ability to tell whether it's +ready. Three artifacts close the loop: + +**1. Declarative rubric — `src/core/skillpack/rubric.ts`** + +Single source of truth. The doctor walks it; the anatomy doc is +auto-generated from it; tests pin every dimension. When the rubric +evolves (v1.1 adds dimensions, v2 changes scoring), one file moves +and docs stay in sync. Same pattern as gstack's +`scripts/question-registry.ts`. + +```ts +export const SKILLPACK_RUBRIC_V1: RubricDimension[] = [ + { + id: 1, + name: 'manifest_valid', + description: 'skillpack.json passes the v1 schema', + check: async (pack) => validateManifest(pack), + fix_hint: 'Run: gbrain skillpack init to regenerate a valid stub', + weight: 1, + }, + { + id: 2, + name: 'skills_have_skill_md', + description: 'Every listed skill has SKILL.md with valid frontmatter (name, description, triggers, mutating, writes_pages)', + check: async (pack) => allSkillsHaveValidSkillMd(pack), + fix_hint: 'Run: gbrain skillify scaffold ', + weight: 1, + }, + { + id: 3, + name: 'routing_evals_present', + description: 'Every skill has routing-eval.jsonl with >= 5 intents', + check: async (pack) => allSkillsHaveRoutingEvals(pack, 5), + fix_hint: 'gbrain skillify scaffold drops 5 example intents per skill', + weight: 1, + }, + { + id: 4, + name: 'routing_evals_clean', + description: 'gbrain routing-eval passes structurally for every routing-eval.jsonl', + check: async (pack) => runRoutingEvalStructural(pack), + fix_hint: 'Add the missing trigger phrase to the skill\'s `triggers:` frontmatter, or move the intent to the correct skill', + weight: 1, + }, + { + id: 5, + name: 'check_resolvable_clean', + description: 'gbrain check-resolvable passes for this pack\'s resolver entries (MECE, no DRY violations, all triggers reach skills). Runs against a PACK-LOCAL fixture, not the ambient workspace.', + check: async (pack) => runCheckResolvableIsolated(pack), + fix_hint: 'Add a resolver row for the missing skill, or remove the orphan trigger', + weight: 1, + }, + // Note (codex outside-voice gap): the existing `check-resolvable` + // implementation (src/core/check-resolvable.ts) merges resolver files + // from skillsDir AND its parent — workspace-global. A pack-local + // publish gate must pass / fail purely on the pack's own resolver + // entries, not on whatever's installed in the publisher's local + // workspace. The doctor and publish-gate wrap check-resolvable in an + // isolated tempdir fixture that contains ONLY this pack's + // RESOLVER.md and its declared skills/, so the result is pack-local. + // Exposed as `runCheckResolvableIsolated(pack)` in + // `src/core/skillpack/check-resolvable-isolated.ts`. + { + id: 6, + name: 'unit_tests_present', + description: 'Every skill has at least one unit test that imports it (test/**/*.test.ts)', + check: async (pack) => everySkillHasUnitTest(pack), + fix_hint: 'gbrain skillify scaffold drops a passing example.test.ts you can extend', + weight: 1, + }, + { + id: 7, + name: 'llm_eval_present', + description: 'At least one LLM-judge eval at evals/*.judge.json with >= 3 cases', + check: async (pack) => hasLlmJudgeEval(pack, 3), + fix_hint: 'gbrain skillify scaffold-eval ', + weight: 1, + }, + { + id: 8, + name: 'install_runbook_present', + description: 'runbooks/install.md exists, parses, and has at least one step', + check: async (pack) => parseRunbook(pack, 'install'), + fix_hint: 'gbrain skillpack init regenerates the stub; edit to taste', + weight: 1, + }, + { + id: 9, + name: 'uninstall_runbook_present', + description: 'runbooks/uninstall.md exists, parses, and has at least one step', + check: async (pack) => parseRunbook(pack, 'uninstall'), + fix_hint: 'gbrain skillpack init regenerates the stub', + weight: 1, + }, + { + id: 10, + name: 'changelog_present_and_current', + description: 'CHANGELOG.md present, contains a `## []` entry, follows Keep-a-Changelog shape', + check: async (pack) => changelogReferencesVersion(pack), + fix_hint: 'Add a `## [] - ` entry. Use gbrain skillpack doctor --fix to auto-generate from VERSION + git log.', + weight: 1, + }, +]; +``` + +**Score bands:** + +- `10/10` → **endorsed-eligible** (paired with the publish-gate's >=95% test+eval pass) +- `8-9` → **community-tier eligible**, doctor prints paste-ready fixes for the misses +- `5-7` → **experimental-tier only**, doctor lists required fixes +- `<5` → doctor refuses to score, prints "this isn't a skillpack yet — run `gbrain skillpack init` and try again" + +**2. Layered doctor — `gbrain skillpack doctor`** + +Two modes; the agent picks which one based on workflow phase: + +``` +gbrain skillpack doctor [--quick|--full] [--fix] [--json] +``` + +- `--quick` (default): structural-only sweep. Walks the rubric. ~5 + seconds. No sandbox, no LLM, no DB. The right command during + iteration — save a file, run doctor, see your new score. +- `--full`: equivalent to `gbrain skillpack pack --dry-run` — runs the + sandbox, the tests, the LLM-judge evals, the routing-evals against a + trial install, the security gates. ~minutes. The right command + before invoking the publish skill. +- `--fix`: auto-scaffold missing pieces. Calls `gbrain skillify + scaffold` for missing skills, drops runbook stubs from templates, + generates a CHANGELOG entry from VERSION + git log. **Destructive on + the file tree**: prints `"this will create the following N files, + proceed? [y/N]"` confirm prompt; non-TTY requires explicit `--yes`. + Refuses to overwrite any file whose mtime is newer than the + manifest's modified-at (heuristic for "user hand-edited this"). +- `--json`: stable JSON envelope for agent consumption. + +JSON output (the agent contract): + +```json +{ + "schema_version": "skillpack-doctor-v1", + "skillpack": "hackathon-evaluation", + "version": "0.1.0", + "mode": "quick", + "score": 7, + "max_score": 10, + "tier_eligibility": "community-with-fixes", + "dimensions": [ + {"id": 1, "name": "manifest_valid", "score": 1, "fix_hint": null}, + {"id": 7, "name": "llm_eval_present", "score": 0, + "fix_hint": "gbrain skillify scaffold-eval ", + "auto_fixable": true} + ], + "next_action": "Run: gbrain skillpack doctor --fix to scaffold the 3 missing pieces, then re-run." +} +``` + +**Agent guidance (lives in `docs/skillpack-anatomy.md` AND in +`skills/_brain-filing-rules.md`):** + +- After every meaningful edit during pack development: `gbrain + skillpack doctor --quick --json`. Target a 10/10 before ever invoking + `pack --dry-run`. +- Before publishing: `gbrain skillpack doctor --full` to catch what the + structural pass can't. +- If doctor flags `auto_fixable: true` dimensions, the agent runs + `gbrain skillpack doctor --fix --yes` and re-runs `--quick`. + +**Required core vs quality-badge dimensions (codex outside-voice T4):** + +Per the codex review's stub-spam concern, the rubric splits into +**required for publish** (5 dimensions, the v1 floor) and **quality +badges** (5 dimensions, earn-them-for-tier-eligibility). A pack with +0 badges still publishes as `experimental`; it just shows visible +"no-badges" flags in the registry so consumers can decide. + +| Tier | Required core (must pass) | Badges (must earn) | +|-----------------|---------------------------|--------------------| +| `experimental` | 1, 2, 3, 5, 10 | 0 | +| `community` | 1, 2, 3, 5, 10 | + at least 3 of {4, 6, 7, 8, 9} | +| `endorsed` | 1, 2, 3, 5, 10 | + ALL of {4, 6, 7, 8, 9} | + +Required core (5 dimensions): manifest_valid, skills_have_skill_md, +routing_evals_present (>=5 intents per skill), check_resolvable_clean, +changelog_present_and_current. Quality badges (5 dimensions): +routing_evals_clean (LLM-judge layer), unit_tests_present, +llm_eval_present, install_runbook_present, uninstall_runbook_present. + +The doctor still reports a 10/10 score (badges are display + +tier-gating, not rubric-replacement). A publisher who stubs all 5 +badges with empty fixtures gets a visible "stubbed-eval-detected" +flag from the publish-gate's content scan (e.g., 0 unique assertion +strings in an eval, or a passing test with no `expect()` calls). +Cathedral scaffold from `gbrain skillpack init` still drops all 10 +dimensions by default; the floor for publish is lower. + +**3. Reference pack + anatomy doc** + +- `examples/skillpack-reference/` — a real, working **10/10 pack** + living in the gbrain repo. Doubles as an integration-test fixture for + the doctor + publish-gate test suite. Includes 2 skills, 2 + routing-eval.jsonl files, 3 unit tests, 1 LLM-judge eval, full + runbook set, CHANGELOG. `bun run build` includes + `cd examples/skillpack-reference && gbrain skillpack doctor --quick` + as a pre-commit assertion so the reference pack never regresses. +- `docs/skillpack-anatomy.md` — one-page reference. Tree diagram + + rubric table + paste-ready commands. Auto-generated from + `src/core/skillpack/rubric.ts` via `bun run build:skillpack-anatomy`. + Diagram + tree are hand-curated; rubric table is machine-generated; + CI fails if generated section is out of sync. + +**4. Invariant: every gbrain-shipped skillpack scores 10/10** + +The bundled gbrain skillpack (today's `openclaw.plugin.json` set, plus +any future bundles like `starter-pack`, `founder-pack`) MUST score +10/10 on `gbrain skillpack doctor --quick`. This is a regression +guard, not a target: + +- `scripts/check-bundled-skillpacks-rubric.sh` runs in CI and + `bun run verify`. Iterates every pack the gbrain repo ships and runs + `gbrain skillpack doctor --quick --json`, asserts every score is 10. +- New gbrain releases that drop a bundled-pack score below 10 fail + CI loud. The cost of fixing is a few `gbrain skillify scaffold` + calls; the cost of skipping is that gbrain ships skillpacks that + fail the bar gbrain demands of third parties — credibility-poison. +- Today's openclaw.plugin.json set is not yet at 10/10 (no per-skill + unit tests, no LLM-judge evals, no runbooks). **Bringing it to 10/10 + is in-scope for v1 — wave W4.5** (added below). + +### Scaffold: `gbrain skillpack init ` (cathedral defaults) + +Lands the complete tree out of the box. `gbrain skillpack pack --dry-run` +on the scaffold passes immediately; developer deletes what they don't +need. + +``` +hackathon-evaluation/ +├── skillpack.json # filled with stubs + this version +├── skills/ +│ └── hackathon-evaluation/ +│ ├── SKILL.md # frontmatter + example triggers +│ └── routing-eval.jsonl # 5 example intents +├── test/ +│ └── example.test.ts # one passing unit test importing the skill helper +├── e2e/ +│ └── example.e2e.test.ts # one E2E skeleton, marked skip-if-no-DB +├── evals/ +│ └── hackathon-evaluation.judge.json # one cross-modal LLM-judge example +├── runbooks/ +│ ├── install.md # commented stub showing all 3 step kinds +│ ├── uninstall.md # commented stub +│ └── upgrade-template.md # rename to upgrade--to-.md on first version bump +├── CHANGELOG.md # v0.1.0 entry pre-filled +├── README.md # for humans +├── LICENSE # MIT default +└── .gitignore # tarball outputs, node_modules +``` + +Boil-the-lake default. Same pattern as `gbrain init` which seeds +configuration + storage tiers + a starter source instead of asking +the user 15 questions. + +### Slug collision: auto-suffix, agent-resolves + +Agents are the primary installer. Forcing them to pick a side on every +collision adds friction without adding safety. Auto-resolve instead: + +- **Flat namespace, auto-suffix on collision.** When the incoming pack + ships a slug already claimed by a different installed source, the + installer appends `-2` (then `-3`, etc.) to the incoming slug and + proceeds. Loud stderr line: `[skillpack] renamed + judge-submission → judge-submission-2 (collides with hackathon-judging)`. +- **Suffix is durable, not cosmetic.** The renamed slug goes into the + source's per-source `cumulative-slugs` receipt AND a sibling + `rename-map="judge-submission:judge-submission-2,..."` attribute on the + source sub-header. Uninstall reads the rename map and removes the + suffixed file + row, never the original. +- **Triggers (the user-facing routing surface) are untouched.** Slugs are + identifiers; agents route via the SKILL.md `triggers:` frontmatter and + the RESOLVER.md description column. A renamed slug still matches the + same user phrases. +- **Reserved suffix range.** `-2..-99` is the auto-rename range. A pack + authored with `judge-submission-2` as its own canonical name (rare) + still installs fine; collision logic only fires on the bare-name + collision, and a subsequent collision on `judge-submission-2` itself + walks to `-3`. The collision-walk is bounded at `-99` and fails loud + beyond that (defensive cap; you don't have 99 packs shipping the same + slug). + +No changes needed in `check-resolvable`, `routing-eval`, or +`filing-audit` — they all parse whatever slug they see, no namespacing +required. + +### Install state: `~/.gbrain/skillpack-state.json` (codex G1) + +The DX-review-locked design had TOFU SHA-256, pinned commit, rename +maps, and per-source receipts living inside markdown comments in the +RESOLVER.md managed block. Codex flagged that as a fragile trust +store — any agent or human edit to the resolver file silently +corrupts provenance. v1 fix: **split human-readable rows from machine- +owned state**. + +- `~/.gbrain/skillpack-state.json` (machine-owned, agent-readable): + the source of truth for TOFU SHA-256, pinned commit, source URL, + rename map, install timestamp, version, tier_when_installed, + endorsement-tier-at-install. One entry per installed source. + Atomic update via `.tmp` + `rename()`. Read on every install / + uninstall / update; resolver block is rendered from this. +- Resolver-block sub-headers (in RESOLVER.md / AGENTS.md) carry only + human-readable identity: `name`, `version`, `tier`, and the + cumulative-slugs list (still needed for uninstall to know what to + remove without consulting state.json — defense in depth against a + corrupted state.json). Receipt comment shape: + ``. +- Mismatch between state.json and resolver-block (e.g., resolver lists + a source not in state.json, or state.json's cumulative-slugs differs + from the rendered rows) fails loud at install-time and refuses + further mutations until reconciled by `gbrain skillpack reconcile`. +- Schema: `skillpack-state.json` has `schema_version: + "gbrain-skillpack-state-v1"` for forward-compat; mirrors the + installer.ts cumulative-slugs receipt evolution story. + +### Resolver-block: one block per source + +The managed block in RESOLVER.md / AGENTS.md grows a **source-keyed** +sub-section header so multiple packs can coexist without rewriting the +whole block on every install. Cumulative-slugs receipt is per-source: + +```markdown + + +| ingest | ... | +| query | ... | + +| judge-submission-2 | Judge a hackathon submission against the YC rubric. | +| score-rubric | ... | + +``` + +`tofu-sha256` is the resolved-commit SHA (git source) or tarball SHA-256 +(tarball source). Re-install / update compares the new resolution against +the recorded value; mismatch = re-prompt (TTY) or refuse without +`--update` (non-TTY). + +Per-source receipt means uninstalling one pack doesn't touch another pack's +rows or D11 hash budget. + +### Trust posture + +- **Default**: TOFU. First install of a given repo URL prompts via + `AskUserQuestion`-equivalent CLI flow ("you are about to install + skillpack `` from `` at commit ``. Trust this source?"). + TTY-only; non-TTY requires explicit `--trust` flag. +- **Pin the commit SHA** into the per-source resolver receipt. Re-install + / upgrade refuses to silently advance the SHA without user consent (same + prompt or `--update`). +- **`--allow-private-remotes`** flag pipes through to `git-remote.ts`'s + `GBRAIN_ALLOW_PRIVATE_REMOTES` for internal/Tailscale skillpacks. +- Signing (minisign / cosign) is a v2 escape hatch; not in scope for v1. + +### CLI surface + +``` +gbrain skillpack install [--update] [--trust] [--allow-private-remotes] [--dry-run] [--json] +gbrain skillpack list [--source ] [--json] +gbrain skillpack uninstall [--overwrite-local] [--dry-run] +gbrain skillpack info # NEW: show pinned commit, author, license, renames +gbrain skillpack update [] [--check] # NEW: check for upstream commits +gbrain skillpack init # NEW: scaffold publisher repo +gbrain skillpack pack [--out ] # NEW: validate + emit .tgz tarball +``` + +`` accepts: +- `garrytan/repo` → `https://github.com/garrytan/repo.git` +- `https://github.com/.../...git` → verbatim, SSRF-checked +- `./path/to/pack.tgz` → tarball; extract to cache, install from tree +- `./path/to/repo` → local filesystem dir (for skill authors testing + locally; same trust posture as today's `--skills-dir`) + +### Publisher workflow (the "make a skillpack" path) + +1. `gbrain skillpack init ` — scaffolds `skillpack.json` + + `skills/` + `RESOLVER.md` (skillpack-internal) + `.gitignore`. +2. Author writes skills using existing `gbrain skillify scaffold` patterns. +3. `gbrain skillpack pack --dry-run` — runs the full validate pipeline: + - `skillpack.json` schema check + - every listed skill has SKILL.md + valid frontmatter + - `gbrain check-resolvable` clean + - `gbrain routing-eval` clean (structural layer) + - no banned file types in any skill dir (no `.env`, `.ssh`, executables) + - returns structured pass/fail JSON +4. `git push` to publish. Distribution is the git remote. + +## Critical files to add / modify + +### New + +- `src/core/skillpack/manifest-v1.ts` — Zod-equivalent runtime + validator for `skillpack.json`. Owns the `SkillpackManifest` type. +- `src/core/skillpack/remote-source.ts` — wraps `git-remote.ts` for the + skillpack use case: shallow clone to a cache dir under + `~/.gbrain/skillpack-cache/////`, resolves + HEAD SHA, supports update via `pullRepo`. +- `src/core/skillpack/tarball.ts` — `packTarball(dir, outPath)` + + `extractTarball(tgzPath, cacheDir)`. Tar entries validated against + allowlist (no symlinks, no executables, no traversal). SHA-256 + computed on the gzipped output for TOFU pinning. + **Deterministic tarball spec (codex outside-voice gap):** SHA-256 + is only stable if the tarball is byte-deterministic. The packer + enforces: (a) entries sorted by path (lexicographic), (b) all + mtimes fixed to `1970-01-01T00:00:00Z` (or the commit's mtime if + available, but reproducibly), (c) uid=0/gid=0, mode normalized + (0644 for files, 0755 for directories), no pax headers, (d) gzip + with `mtime=0`, no original-filename header. Reject on extract: + symlinks, hardlinks, device files, FIFOs, any non-regular non- + directory entry. Caps on extract: max 5000 files, max 100MB + decompressed total, max 1MB per file, max path length 255 chars, + max compression ratio 100:1 (compression-bomb defense). Pinned by + `test/skillpack-tarball-determinism.test.ts` (pack the same dir + twice on different days → same SHA). +- `src/core/skillpack/collision-resolver.ts` — pure function + `resolveSlugCollisions(incoming: string[], existing: Set): { + finalSlugs: string[], renameMap: Record }`. Bounded + walk to `-99`. Pinned by unit tests. +- `src/core/skillpack/multi-source-receipt.ts` — parse + serialize the + per-source resolver-block sub-headers. Pure functions; pinned by tests. +- `src/core/skillpack/trust-prompt.ts` — TOFU prompt + TTY/non-TTY + branching. Mirrors v0.32.4 install-picker prompt shape. +- `src/core/skillpack/registry-client.ts` — fetch + cache the live + `registry.json` over HTTPS. Honors `If-None-Match` etag for cheap + polling. Validates schema before use. Caches under + `~/.gbrain/skillpack-cache/registry-.json` with a + 1-hour soft TTL. + **Offline-safe**: on fetch failure (network down, GitHub 5xx, DNS + miss), falls back to the on-disk cache and emits a single stderr + line per process: `[skillpack] registry fetch failed, using cache + from (N hours old)`. If cache is >7 days old, the + warning escalates to `cache is stale, run 'gbrain skillpack registry + --refresh' when back online`. Hard-fail only when there is no cache + at all (first-run + offline). `--no-cache` flag forces network and + fails loud on miss. The cache file's `fetched_at` is wall-clock + time; clock skew is non-issue because we never compare cached + fetched_at against the registry's `updated_at` for freshness — only + against current wall-clock for age display. +- `src/core/skillpack/registry-schema.ts` — runtime validators for + `registry.json` + `endorsements.json` shapes. Single source of truth + used by both `gbrain skillpack search` and the publish-gate skill. +- `src/core/skillpack/sandbox.ts` — **subprocess-isolated** trial-install + harness with a per-platform fallback chain. + - **Linux**: `bwrap → unshare → docker`. Tries `bwrap` (bubblewrap) + first — most portable, on every recent distro's repo, ~100ms + spinup. Falls back to `unshare --net + --mount` when bwrap is + missing but the kernel allows unprivileged user namespaces (covers + stock Debian/Ubuntu/Arch). Falls back to `docker run --rm + --network=none --volume :/work --workdir /work` for + RHEL/Rocky/CentOS where unprivileged userns is disabled by + sysctl. Pure-tree: no minimal Linux image without bwrap AND without + docker — fails loud with a paste-ready apt/yum install hint. + - **macOS**: `sandbox-exec → docker`. Tries Apple's built-in + `sandbox-exec` first with a per-publish `.sb` profile (filesystem + write confined to tempdir, network denied, no IPC). ~50ms spinup. + Falls back to Docker Desktop only when `sandbox-exec` is unavailable + (rare; Apple keeps deprecating it but hasn't pulled it). macOS + publishers without Docker can still publish via sandbox-exec. + - Inside the sandbox, an ephemeral in-memory PGLite gbrain runs the + trial install, reuses the pattern from + `src/eval/longmemeval/harness.ts`. Exposes + `runTrialInstall(packPath, opts): Promise` consumed + by the publish-gate. + - Bun's `child_process` spawn with the chosen backend's wrapper + argv; abort signal kills the wrapper which cascades to the child. + - **Env scrub (codex G2):** the spawned process inherits a CLEAN + env (only `PATH`, `LANG`, `TZ` pass through). Explicitly stripped: + `GITHUB_TOKEN`, `GH_TOKEN`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, + `VOYAGE_API_KEY`, `GROQ_API_KEY`, all `*_API_KEY` / `*_TOKEN` / + `*_SECRET` variables, `SSH_AUTH_SOCK`, `SSH_AGENT_PID`, + `GIT_*` (no GIT_ASKPASS, no GIT_SSH), `NPM_TOKEN`, + `BUN_INSTALL_TOKEN`, plus an explicit denylist defined as a + pure-function constant in `src/core/skillpack/sandbox-env.ts`. + - **HOME override**: `HOME=/sandbox-home` (empty dir). + Side effects: no `~/.gbrain` access, no `~/.gitconfig` (credential + helper disabled), no `~/.netrc`, no `~/.npmrc`, no + `~/.bunfig.toml`. The publish-gate's PGLite + LLM-judge stubs + were already designed to not need real credentials; this just + enforces it. + - **Read-only mounts** (bwrap / docker; sandbox-exec uses + deny-write profiles): only the pack tempdir is read-write; every + other path is read-only or unmounted. `/proc` masked where bwrap + supports it (`--proc /proc --new-session`). + - Pinned by `test/skillpack-sandbox-env-scrub.test.ts`: 8 cases + asserting each known-credential env var is stripped, HOME is + overridden, the denylist constant matches the test fixture. +- `src/core/skillpack/sandbox-profiles/macos.sb` — sandbox-exec policy + file (allow read/write only within `${TEMPDIR}`, deny network, deny + process-fork beyond the trial-install Bun process, deny mach lookups + except the minimum set Bun needs to start). +- `src/core/skillpack/sandbox-probe.ts` — pre-flight: detects which + sandbox backend is available, in order. Emits a structured + `SandboxBackend = 'bwrap' | 'unshare' | 'sandbox-exec' | 'docker' | + 'none'` discriminator. Backend choice persists per-process (avoid + re-probing on every trial). `gbrain doctor` surfaces the chosen + backend as info. +- `src/core/skillpack/security-gates.ts` — static-analysis pipeline. + `runShellcheck(files)` (shells out if shellcheck is installed; degrades + to a built-in regex pass naming the offending patterns when not), + `scanForbiddenFiletypes(tree)`, `validateExternalResources(skill)`, + `checkFrontmatter(skill)`. Each returns structured findings. +- `src/commands/skillpack-init.ts` — `gbrain skillpack init ` + scaffold command. +- `src/commands/skillpack-pack.ts` — `gbrain skillpack pack` validator + AND tarball emitter. Single command, `--dry-run` skips the tarball. +- `src/commands/skillpack-info.ts` + `skillpack-update.ts`. +- `src/commands/skillpack-search.ts` — `gbrain skillpack search + [--tier ...] [--json]` reads the registry, ranks by tier then tag + match, prints a table. +- `src/commands/skillpack-registry.ts` — `gbrain skillpack registry + [--url X] [--refresh]` show/set the configured registry URL, + optionally force a fresh fetch. +- `src/commands/skillpack-endorse.ts` — `gbrain skillpack endorse + [--tier endorsed|community|experimental] [--push] [--dry-run]`. Runs + in a clone of the registry repo; validates `` against + `registry.json`; reads, updates, schema-validates, and writes + `endorsements.json` with stable key ordering; stages + commits with a + one-line conventional-commit message `endorse: -> `; + optionally pushes. Refuses if not inside a registry-shaped repo. +- `src/core/skillpack/runbook-parser.ts` — parses + `runbooks/install.md`, `uninstall.md`, and `upgrade-*.md` files. + Validates frontmatter (`runbook_kind`, `gbrain_version_range`, + `skillpack`, `skillpack_version`, plus `from_version`/`to_version` + for upgrades). Tokenizes each numbered step as one of three kinds: + `agent:` / `show user:` / `ask user:`. Returns a strongly-typed + `Runbook` value. Pure function; pinned by unit tests with malformed- + runbook fixtures. +- `src/core/skillpack/runbook-walker.ts` — executes a parsed runbook + against a calling context. Dispatches each step kind: `agent:` runs + the gbrain CLI subcommand (only gbrain CLI; not arbitrary shell); + `show user:` writes to stdout; `ask user:` blocks on a TTY confirm + (non-TTY requires a `--yes` flag and refuses-confirmation steps + cause failure). Returns a structured `RunbookResult` so callers can + see which steps ran. Test seam: `opts.shellTransport` lets tests + drive without real subprocess spawn. +- `src/core/skillpack/upgrade-planner.ts` — given the resolver + receipt's recorded `skillpack_version` for a pack and the new + version's `upgrade-*.md` set, computes the upgrade walk path + (e.g., v0.1 → v0.3 might walk `upgrade-0.1-to-0.2.md` then + `upgrade-0.2-to-0.3.md`). Refuses if no path exists; refuses to + downgrade silently; pure function. +- `src/commands/skillpack-test.ts` — `gbrain skillpack test [pack-dir]` + runs the publisher-side full test+eval suite OUTSIDE the publish + gate so a publisher can iterate fast before invoking the publish + skill. Real gateway (publisher pays the real LLM cost on their + machine, not the publish gate). Outputs the same JSON shape the + publish gate's validation log uses, so the publisher sees exactly + what the gate will see. +- `src/commands/skillpack-init.ts` (extended scope from earlier section) + — scaffolds the full cathedral tree above. `--minimal` flag drops + test/, e2e/, evals/ for power users who explicitly opt out. +- `src/core/skillpack/rubric.ts` — declarative `SKILLPACK_RUBRIC_V1` + array of `RubricDimension` (see schema above). Pure-data + check + functions that take a parsed pack and return `{ passed: boolean, + detail: string }`. Single source of truth for doctor + anatomy doc + + tests. +- `src/core/skillpack/doctor.ts` — `runDoctor(pack, opts: + {mode: 'quick' | 'full', fix: boolean, autoYes: boolean}): + Promise`. Walks the rubric, dispatches each check, + computes score + tier eligibility, emits paste-ready fixes. `--fix` + path dispatches per-dimension auto-scaffold (calls `gbrain skillify + scaffold` for missing skills, writes runbook stubs from a template + baked into the bundle, generates CHANGELOG entries from VERSION + + `git log --since=`). Refuses to overwrite files + whose mtime is newer than `skillpack.json`'s mtime (heuristic for + "hand-edited since last manifest update"). +- `src/commands/skillpack-doctor.ts` — CLI wrapper. Reads flags, + resolves the pack (file or dir or tarball), calls `runDoctor`, + formats JSON or human output. Exit codes: 0 if score=10, 1 if score + 6-9, 2 if score 0-5 or refused. +- `scripts/build-skillpack-anatomy.ts` — generates the + rubric-table section of `docs/skillpack-anatomy.md` from + `src/core/skillpack/rubric.ts`. `bun run build:skillpack-anatomy`. + CI guard `scripts/check-anatomy-fresh.sh` runs in `verify` to detect + drift between rubric and committed doc. +- `scripts/check-bundled-skillpacks-rubric.sh` — CI guard. Walks every + bundled skillpack (today: `openclaw.plugin.json` set; future: + any `examples/skillpack-*` and any bundled starter pack), runs + `gbrain skillpack doctor --quick --json` against each, asserts + score=10. Fails the build loud on regression. Wired into + `package.json`'s `verify` script. + +### New (in github.com/garrytan/gbrain repo, examples + docs) + +- `examples/skillpack-reference/` — a real, working **10/10 reference + skillpack** that lives in the gbrain repo. Two skills, 2 + routing-eval.jsonl files (5 intents each), 3 unit tests, 1 + LLM-judge eval (3 cases), full runbooks (install / uninstall / + upgrade-template), CHANGELOG, README, LICENSE. The reference + pack is the integration-test fixture for the doctor + the + publish-gate full-suite E2E test, AND it's what `gbrain skillpack + doctor --quick` is regression-tested against. +- `docs/skillpack-anatomy.md` — one-page agent + human reference. + Contains: (a) tree diagram of the cathedral scaffold, (b) auto- + generated rubric table from `rubric.ts`, (c) paste-ready commands + for every step from `init` → `doctor --quick` → `doctor --fix` → + `pack --dry-run` → `publish`. Auto-generated header + manual prose + + auto-generated rubric body; a marker block guards the generated + section. +- `src/core/skillpack/audit.ts` — JSONL audit at + `~/.gbrain/audit/skillpack-YYYY-Www.jsonl` (ISO-week rotated, mirrors + `src/core/audit-slug-fallback.ts` + `src/core/rerank-audit.ts`). + `logSkillpackEvent({event, source_kind, name, version, + pinned_commit, tier_when_installed, outcome, error?})` called by + install / uninstall / update / search-resolve paths. Best-effort — + never throws, logs stderr warning on write failure. + `readRecentSkillpackEvents(days)` is the readback path for `gbrain + doctor`'s new `skillpack_activity` check (info-level: "installed N + packs in the last 7 days, all from endorsed tier" or "installed 2 + community-tier packs in the last 24h — review at "). +- `skills/gbrain-skillpack-publish/SKILL.md` — the publish-gate skill + itself. Lives in the bundled skillpack so every gbrain install ships + with it. Walks the contributor through: + 1. Validate locally (`gbrain skillpack pack --dry-run`) + 2. Run security gates + 3. Pack tarball + compute SHA + 4. Fork `garrytan/gbrain-skillpack-registry` if needed (`gh repo fork`) + 5. Branch, append catalog entry, commit validation-run JSON + 6. Push + open PR via `gh pr create` + 7. Print the PR URL and remind the contributor "Garry endorses + separately; check back for the tier flip." + +### New (in github.com/garrytan/gbrain-skillpack-registry, separate repo) + +- `registry.json` — the catalog (schema above) +- `endorsements.json` — Garry-only file controlling the `endorsed` tier +- `validation-runs/.json` — one file per published validation, + immutable, content-addressable. Anyone auditing a skillpack can pull + the corresponding run JSON. +- `tarballs/-.tgz` — registry-mirrored tarball, written + by CI at PR-merge time as the durable copy. Each tarball is + content-addressed by the SHA-256 already recorded in `registry.json`. + Tarballs use **git LFS** to keep the registry clone small (a 1GB + registry would be miserable to clone). Per-pack soft cap of 5MB; + packs larger than 5MB are stored as link-only (the registry entry + records a `source_only: true` flag and skips the tarball mirror). +- **CI durability job** (`.github/workflows/mirror-tarball.yml`): on + every PR merge, clones the pinned commit of the new entry, regenerates + the tarball, verifies it matches the registry's recorded SHA-256, then + commits to `tarballs/`. Belt-and-suspenders: if the source-repo SHA + was a lie at PR-time, the mirror job fails loudly and the registry + entry is reverted. +- **CI liveness job** (`.github/workflows/liveness-check.yml`): weekly, + walks every registry entry and verifies the source URL still resolves + to the pinned commit. Unreachable entries get a `last_alive: ` + field but are NOT auto-tombstoned — Garry decides whether to deprecate. +- `README.md` — explains the tier system, links to the publish skill, + documents how to fork + submit +- **Two-workflow CI split (codex G3)** — registry-side CI separates + static-only PR validation from any dangerous execution: + - `.github/workflows/validate-pr.yml` runs on **`pull_request`** + (NOT `pull_request_target`). Permissions: `contents: read, + pull-requests: read` only. NO GitHub token write scopes, NO LFS + write, NO repo PAT. Does: manifest schema check, file-type + allowlist scan, slug uniqueness vs `registry.json`, dependency + declaration check. Pure static. Cannot exfiltrate anything because + it has nothing to exfiltrate. + - `.github/workflows/post-merge-validate.yml` runs on + `push` to `main` with the new entry's commit. Permissions: + `contents: write` (only for the new tarballs/ file). Executes the + publish-gate's test + LLM-judge-stub + routing-eval suite inside + the registry's own sandbox (same `bwrap`/`sandbox-exec` profile + documented above; same env-scrub posture). If validation fails + after merge, the workflow opens a follow-up PR reverting the + registry entry and posts a comment naming the failure. Slow path + but isolated. + - `.github/workflows/mirror-tarball.yml` (third workflow): runs after + `post-merge-validate.yml` passes, with a **deploy key** scoped to + `tarballs/` only. Commits the SHA-256-verified tarball. Cannot + write `registry.json`, `endorsements.json`, or anything outside + `tarballs/`. + - The standard supply-chain posture: PR-time = static, never + executes contributor code. Post-merge = isolated, never has + privileged tokens. Mirror commit = least-privilege deploy key. +- `bundles.json` (or a `bundles` section in `registry.json`) — named + bundles like `starter-pack`, `founder-pack`, `journalist-pack` +- `test/skillpack-manifest-v1.test.ts`, + `test/skillpack-multi-source-receipt.test.ts`, + `test/skillpack-remote-source.test.ts`, + `test/skillpack-collision-resolver.test.ts` (covers `-2` walk, the + pack-authored-`-2` corner case, the `-99` cap), + `test/skillpack-tarball.test.ts` (round-trip, allowlist enforcement, + symlink rejection, SHA-256 stability), + `test/skillpack-pack.test.ts`, + `test/skillpack-registry-client.test.ts` (etag handling, schema + rejection on malformed registry, stale-cache behavior, network-down + graceful fallback to last good cache), + `test/skillpack-registry-schema.test.ts` (every tier valid, missing + required field caught, unknown tier rejected), + `test/skillpack-search.test.ts` (tier ordering, tag rank, JSON shape), + `test/skillpack-sandbox.test.ts` (trial install creates + tears down + PGLite cleanly, network-disabled assertion fires), + `test/skillpack-security-gates.test.ts` (forbidden filetypes caught, + shellcheck path AND fallback regex path both work, external_resources + declaration enforced), + `test/e2e/skillpack-third-party.test.ts` (PGLite-only, no + `DATABASE_URL` required; uses both a local-filesystem source fixture + AND a local-tarball source fixture so both install paths are pinned), + `test/e2e/skillpack-registry-install.test.ts` (PGLite-only; serves a + fixture `registry.json` via a localhost HTTP harness, installs by + short name, asserts the right pack lands; covers missing-pack-name + error path and stale-pin error path), + `test/skillpack-publish-preflight.test.ts` (T-GAP-1 from eng review: + `gh not installed` AND `gh not authed` both surface actionable + errors with paste-ready install/login commands), + `test/skillpack-sandbox-network-block.test.ts` (T-GAP-2 from eng + review: synthetic pack inside sandbox attempts `fetch(...)` and + `https.request(...)` — both must be rejected by the chosen backend. + Runs against every sandbox backend the test host can spin up; skips + gracefully when a backend is unavailable.), + `test/e2e/skillpack-bundle-atomicity.test.ts` (T-GAP-3 from eng + review: 5-pack starter-pack fixture where pack #3 has a synthetic + failure; asserts per-pack-independent contract — packs 1-2 land, + pack-3 reported failed, packs 4-5 skipped, retry hint printed, + managed block intact for packs 1-2 only), + `test/skillpack-uninstall-renamed.test.ts` (T-GAP-4 from eng review: + install pack-A with `judge-submission`, install pack-B which + auto-renames to `judge-submission-2` via the rename map. Uninstall + pack-B and assert it removes the `-2` row, not the bare-name row. + Then uninstall pack-A and assert clean state), + `test/skillpack-runbook-parser.test.ts` (frontmatter validation, + three step kinds parsed correctly, malformed runbook fails loud, + upgrade-runbook frontmatter requires from_version + to_version), + `test/skillpack-runbook-walker.test.ts` (each step kind dispatches + to the right handler; `ask user:` honors --yes in non-TTY; refused + confirmation halts the walk and reports which step refused; agent + step that fails halts the walk and surfaces the failing CLI exit + code), + `test/skillpack-upgrade-planner.test.ts` (single-hop path v0.1->v0.2; + multi-hop path v0.1->v0.2->v0.3; refuses when no path exists; + refuses silent downgrade), + `test/skillpack-coverage-score.test.ts` (tier eligibility math: + endorsed needs routing + runbooks + >=95%; community needs routing + + install + >=80%; everything else falls to experimental), + `test/e2e/skillpack-publish-gate-full-suite.test.ts` (PGLite-only; + synthetic pack with declared unit tests + LLM-judge evals + routing + evals, publish gate runs the suite inside the sandbox with stubbed + gateway, produces validation log with coverage score, tier + assignment matches expectations), + `test/skillpack-rubric.test.ts` (every dimension in + `SKILLPACK_RUBRIC_V1` has a check function that returns + `{passed, detail}`; pure-function tests against fixture packs that + pass / fail each dimension individually + a known-bad pack that + triggers all 10 fixes simultaneously), + `test/skillpack-doctor-quick.test.ts` (the `--quick` mode runs in + < 1s on the reference pack; produces stable JSON envelope; refuses + scores < 5; exit codes correct per band), + `test/skillpack-doctor-fix.test.ts` (`--fix` scaffolds missing + pieces; respects the mtime-vs-manifest heuristic and refuses to + overwrite hand-edited files; confirm prompt fires on TTY; + `--yes` skips it; non-TTY without `--yes` refuses), + `test/e2e/skillpack-reference-is-ten.test.ts` (regression guard: + `gbrain skillpack doctor --quick --json examples/skillpack-reference` + always scores 10/10; if a future PR drops the reference pack + below 10, this test fails loud and CI rejects), + `test/skillpack-anatomy-fresh.test.ts` (asserts + `scripts/check-anatomy-fresh.sh` passes: the rubric section of + `docs/skillpack-anatomy.md` matches what + `bun run build:skillpack-anatomy` would emit from the current + rubric.ts; future-edit-of-rubric without doc-regen fails the build). + +### Modified + +- `src/commands/skillpack.ts` — extend `install` to dispatch on source + shape (bundled vs `owner/repo` vs URL vs local path). +- `src/core/skillpack/installer.ts` — thread a `source: {name, version, + pinnedCommit?}` discriminator through `applyInstall` / `applyUninstall`. + Read + write per-source managed sub-blocks. +- `src/core/skillpack/bundle.ts` — accept either today's + `openclaw.plugin.json` shape OR the new `skillpack.json`, normalize + internally so the rest of the pipeline doesn't care. +- `src/commands/skillpack-check.ts` — surface per-source health in the + agent-readable report. +- `CLAUDE.md` Key Files section. +- New `docs/skillpack-authoring.md` — the human-readable spec for + publishers (not a marketing doc; reference doc). +- `docs/skillpack-distribution.md` — registry-shape discussion + + versioning policy. + +## Verification + +End-to-end: + +1. **Publisher path**: `gbrain skillpack init hackathon-evaluation` in a + tempdir → add a synthetic skill → `gbrain skillpack pack --dry-run` + → expect pass. +2. **Install from local path**: `gbrain skillpack install ` in + a fresh workspace → resolver-block shows the new source sub-block → + `gbrain check-resolvable` clean. +3. **Install from git** (E2E, optional): clone a known-good public + sample repo → same assertions. +4. **Multi-pack coexistence**: install the bundled gbrain set AND the + sample skillpack into the same workspace → both rows present in the + managed block, neither's cumulative-slugs receipt touches the other. +5. **Collision auto-rename**: install a second pack that ships a slug + already present (`judge-submission`) → installer auto-suffixes to + `judge-submission-2`, emits a stderr line, records the rename in the + source receipt. Triggers still match the same user phrases. +6. **Uninstall safety**: edit one skill file in the third-party pack → + `gbrain skillpack uninstall hackathon-evaluation` → refuses without + `--overwrite-local` (D11 contract holds across sources). +7. **TOFU**: first-time install of a new URL prompts; second install of + same URL + SHA does not. +8. **Registry resolution**: `gbrain skillpack install hackathon-evaluation` + against a localhost-served fixture `registry.json` resolves the right + git URL, verifies the pinned commit, lands the pack. Pin mismatch + produces a loud refusal. +9. **Search**: `gbrain skillpack search yc --json` returns the entry, + `endorsed` tier sorts before `community` sorts before `experimental`. +10. **Bundle install**: `gbrain skillpack install starter-pack` walks + the bundle list in order; mid-bundle failure unwinds cleanly with + no half-installed entries in the managed block. +11. **Publish-gate (sandbox)**: a synthetic skillpack with a forbidden + file type (`.env`) AND a synthetic pack with a malicious shell + script both get rejected by the publish-gate. A clean pack passes + every gate and produces a tarball + SHA + PR-ready validation log. +12. **Trial install sandbox isolation**: the ephemeral PGLite the + publish-gate spins up does NOT touch `~/.gbrain`. Tear-down is + clean — no file artifacts, no DB connections left behind. +13. **Runbook execution end-to-end**: `gbrain skillpack install + hackathon-evaluation` lands the pack AND walks + `runbooks/install.md` step-by-step. Each `agent:` step runs; + each `show user:` step prints; each `ask user:` step blocks on + TTY confirm or honors `--yes`. Failed agent step halts the walk + and surfaces the failing command. +14. **Upgrade walk multi-hop**: install pack@v0.1, publish v0.2 with + `upgrade-0.1-to-0.2.md`, then v0.3 with `upgrade-0.2-to-0.3.md`. + Upgrade from v0.1 directly to v0.3 walks BOTH runbooks in + sequence. Mismatch between recorded version and available + runbooks fails loud with paste-ready fix. +15. **Tier eligibility**: a pack with routing evals + runbooks + + 100% pass earns `endorsed` eligibility; same pack with one + eval failing drops to `community`; pack with no routing evals + drops to `experimental` regardless of other coverage. +16. **`gbrain skillpack test`** runs against a freshly-scaffolded + pack and exits 0 (the scaffold's example tests pass out of the + box, the example LLM-judge eval passes with the real gateway + when `ANTHROPIC_API_KEY` is set, and `--no-llm` skips the + LLM-judge path cleanly). + +Tests: + +- All unit tests pass: `bun run test` +- E2E gate: `bun run test:e2e` (Tier 1, no API keys) +- Typecheck clean: `bun run typecheck` +- `scripts/check-test-isolation.sh` clean (no new allowlist entries) + +## Out of scope (deferred) + +- Cryptographic signatures (minisign / cosign / Sigstore). The + registry's content-hash pin + Garry-controlled endorsement file is the + v1 trust posture; signatures are a v2 layer on top. +- Dependency resolution between skillpacks (`pack A depends on pack B`). + v1 declares dependencies as informational metadata only. +- Versioning constraints richer than `gbrain_min_version` (no semver + range matching, no `^0.36`). +- Auto-update / background pulls. `gbrain skillpack update` is manual. +- A central web UI (gbrain.dev/skillpacks). The registry repo's GitHub + page IS the web UI in v1. +- Payment / monetization. Skillpacks are free / open source by default. +- Print-press CLI generation against gbrain HTTP MCP — explicitly IN + scope per Q2 but listed here for clarity: it's a separate one-week + effort that lives in a sibling branch, not blocking on the v1 + registry ship. + +Each deferred item is an additive layer on the v1 design; none are +load-bearing for "Garry ships a hackathon-evaluation skillpack, gets it +listed in the registry, and someone discovers + installs it." + +## Sequencing — what ships in what order + +Six discrete waves. Each lands independently; later waves don't block +earlier ones from shipping value: + +1. **W1: Single-pack install** — manifest schema, tarball pack, install + from git URL / tarball / local path, multi-source resolver block, + auto-rename collision resolver, TOFU prompt + commit pinning. Ships + the floor: Garry can hand-distribute hackathon-evaluation today. +2. **W2: Registry catalog** — `garrytan/gbrain-skillpack-registry` + created, `registry.json` schema + endorsements.json, registry-client + with stale-cache fallback, `gbrain skillpack search` + `install + ` + `info`. Initial catalog seeded with bundled gbrain + skills + hackathon-evaluation + maybe one community pack. +3. **W3: Publish-gate skill** — `/gbrain-skillpack-publish` skill, + security-gates module, sandbox-probe, subprocess-isolated trial + install with Docker fallback on macOS. The contributor flow goes + from "fork + commit + hope" to "run one skill, get a PR." +4. **W4: Audit + doctor integration** — `~/.gbrain/audit/skillpack-*` + JSONL, `gbrain doctor` check, `gbrain skillpack history` reader. +5. **W5: Printing Press cross-list** — open the PR against + `mvanhorn/printing-press-library` listing + `garrytan/gbrain-skillpack-registry` as a sister registry. ~1 day. +6. **W6: Generated gbrain-cli (Printing Press)** — run printing-press + against gbrain's HTTP MCP, ship the resulting agent-native CLI to + their library. Independent week of work; doesn't block W1-W5. + +**W4.5 — Bring bundled gbrain skillpacks to 10/10** (drops between W4 +and W5, blocking on W3's doctor + rubric). The current +`openclaw.plugin.json` set is missing per-skill unit tests (most +have routing-eval.jsonl already from v0.19, missing LLM-judge evals +and per-skill runbooks). The CI guard +`scripts/check-bundled-skillpacks-rubric.sh` will fail the build +until every shipped pack scores 10. Effort: human ~3 days / CC ~3 +hours across the ~25 bundled skills. Doctor's `--fix` auto-scaffold +reduces this to mostly "review the auto-generated stubs and fill in +the prose." + +W1 is the floor for "Garry can ship hackathon-evaluation." W2 is the +floor for "anyone can discover it without reading Garry's README." +W3 is the floor for "anyone can publish without hand-running git." +W4-W6 are quality layers on a working system. + +## GSTACK REVIEW REPORT + +| Review | Trigger | Why | Runs | Status | Findings | +|--------|---------|-----|------|--------|----------| +| CEO Review | `/plan-ceo-review` | Scope & strategy | 1 | CLEAR (PLAN) | 6 proposals, 6 accepted, 0 deferred; EXPANSION mode | +| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 1 | CLEAR (PLAN) | 3 arch + 1 quality + 4 test-gap findings; 5 decisions locked | +| DX Review | `/plan-devex-review` | Developer experience gaps | 2 | CLEAR (PLAN) | 8 decisions across 2 rounds: artifact cathedral + rubric/doctor/anatomy + 10/10 bundled invariant | +| Codex Review | `/codex` plan-consult | Independent 2nd opinion | 1 | ISSUES_FOUND → INCORPORATED | 20 findings; 8 surfaced as tensions/gaps; 6 adopted (T1 + T4 + G1-G4); 2 cathedral defenses held (T2 scope, T3 10/10 invariant); 3 trailing correctness fixes folded in | +| Design Review | `/plan-design-review` | UI/UX gaps | 0 | — | no UI scope (CLI + skill markdown only) | + +**Eng-review decisions locked this run:** +1. **Linux sandbox chain**: `bwrap → unshare --net → docker`. bwrap preferred (most portable, ~100ms); unshare covers stock kernels; docker as heavyweight fallback for RHEL/Rocky/CentOS where unprivileged userns is disabled. +2. **macOS sandbox**: `sandbox-exec → docker`. Apple's built-in `sandbox-exec` is the primary path (~50ms, no Docker dep); Docker is the rare fallback. macOS publishers without Docker can still publish. +3. **Bundle install atomicity**: per-pack independent (option γ). Failures inside a bundle leave earlier successful packs installed, skip later packs, print a summary with retry hint. +4. **Deleted source repo durability**: registry CI mirrors tarballs to `tarballs/-.tgz` via git LFS at PR merge time. 5MB per-pack cap; larger packs flagged `source_only: true`. +5. **Endorsement workflow**: `gbrain skillpack endorse [--tier ...] [--push]` CLI command with schema validation; hand-editing remains valid. + +**Eng-review findings (resolved by the 5 decisions above):** +- A1: Linux sandbox fallback chain underspecified → locked (#1). +- A2: Docker-on-macOS as a contributor cliff → locked (#2, sandbox-exec preferred). +- A3: Registry source-repo-deleted doom path → locked (#4, tarball mirror). +- C1: Bundle install atomicity unspecified → locked (#3). +- E1: Endorsement workflow unspecified → locked (#5). + +**Test coverage:** 31/35 paths planned (~89%) before this review. Four gaps added to the plan as required tests before implementation: +- T-GAP-1: `gh` not-installed / not-authed branches in the publish skill. +- T-GAP-2: sandbox network-block assertion (fetch + https.request both rejected) across every backend the host can spin up. +- T-GAP-3: starter-pack bundle mid-failure (5-pack fixture, pack-3 fails) → per-pack-independent contract verified. +- T-GAP-4: uninstall a pack whose slug was auto-renamed via the rename map → `-2` row removed, not bare-name. + +**Failure modes:** 0 critical gaps. Every new codepath is tested, rescued, AND user-visible. The collision-rename rollback path was the only silent-failure candidate; T-GAP-4 closes it. + +**Worktree parallelization:** 6 lanes mapped via the W1–W6 sequencing in the plan. +- Lane A (W1: single-pack install): manifest, tarball, collision-resolver, multi-source-receipt, install paths. Sequential; shared `src/core/skillpack/` namespace. +- Lane B (W2: registry catalog): registry-client, registry-schema, search/info commands. Can run parallel to A after manifest schema lands. +- Lane C (W3: publish gate): publish skill, security gates, sandbox + sandbox-probe + macOS profile. Parallel to A+B but depends on tarball from A. +- Lane D (W4: audit + doctor): audit.ts + doctor check. Parallel to everything else. +- Lane E (W5: Printing Press cross-list): a single docs PR against `mvanhorn/printing-press-library`. ~1 day, fully independent. +- Lane F (W6: generated gbrain-cli): independent week of work; spawns its own branch. + +Conflict flag: Lane A and Lane C both touch the in-tree skillpack module dir. Recommend serializing A → C within the same worktree. + +**DX-review decisions locked across two rounds:** + +*Round 1 — artifact scope:* +1. **Artifact scope: full cathedral.** `skillpack.json` declares `skills[]`, `unit_tests[]`, `e2e_tests[]`, `llm_evals[]`, `routing_evals[]`, `runbooks{install, uninstall, upgrades}`, `changelog`. The differentiation moat — nobody else ships AI evals + agent runbooks as first-class package artifacts. +2. **Publish gate runs everything in the sandbox.** Unit + E2E (when DB available) + LLM-judge (stubbed gateway, zero cost) + routing-evals. Coverage score drives tier eligibility: `endorsed` requires routing + runbooks + >=95% pass; `community` requires routing + install + >=80%; `experimental` accepts structural-only. +3. **Runbook format: agent-readable markdown** with three step kinds (`agent:`, `show user:`, `ask user:`). Separate `install.md`, `uninstall.md`, `upgrade--to-.md` per version. Mirrors gbrain's own `skills/migrations/v0.21.0.md` pattern. +4. **`gbrain skillpack init` scaffolds the cathedral by default.** Full tree (skills, tests, e2e, evals, runbooks, CHANGELOG, README, LICENSE) lands out of the box; `gbrain skillpack pack --dry-run` passes immediately. `--minimal` flag for power users opting out. + +*Round 2 — rubric + doctor + reference + invariant:* +5. **Layered doctor:** `gbrain skillpack doctor --quick` (~5s structural sweep, walks the rubric, no sandbox/LLM/DB) for rapid iteration; `--full` (runs the full publish-gate suite) for ship-readiness. Two-tool design; agent picks the mode per workflow phase. The user noted: agents do the operating, so the cognitive cost of two flags is irrelevant as long as the docs teach the agent when to use which. +6. **Rubric as declarative spec:** `src/core/skillpack/rubric.ts` exports `SKILLPACK_RUBRIC_V1` — 10 binary dimensions (manifest valid / SKILL.md complete / routing-evals present + clean / check-resolvable clean / unit test present / LLM-judge eval present / install + uninstall runbooks / CHANGELOG current). Single source of truth: doctor walks it, anatomy doc is auto-generated from it, tests pin each dimension. +7. **`doctor --fix` auto-scaffold:** Calls `gbrain skillify scaffold` for missing skills, drops runbook stubs, generates CHANGELOG entries from VERSION + git log. Confirm prompt on TTY; `--yes` skips; refuses to overwrite files whose mtime is newer than `skillpack.json`'s. +8. **Reference pack + anatomy doc + 10/10 invariant for EVERY bundled gbrain skillpack:** ship `examples/skillpack-reference/` (real working 10/10 pack) AND `docs/skillpack-anatomy.md` (one-page reference, auto-generated rubric section from `rubric.ts`). NEW INVARIANT (the user's strongest line): every gbrain-shipped skillpack must score 10/10 on `--quick`. `scripts/check-bundled-skillpacks-rubric.sh` is wired into `bun run verify` + CI. Bringing today's `openclaw.plugin.json` set to 10/10 is wave W4.5 — blocking on W3 (doctor) but required before v1.0 ship. Credibility-poison if gbrain ships skillpacks below the bar gbrain demands of third parties. + +**DX scorecard (after both DX rounds):** + +| Dimension | Before | Round 1 | Round 2 | Notes | +|--------------------|--------|---------|---------|-------| +| Getting Started | 4/10 | 9/10 | **10/10** | scaffold + `doctor --quick` round-trip in <10s; reference pack as ground truth | +| API/CLI/SDK | 6/10 | 9/10 | **10/10** | `init / doctor / pack / test / publish / endorse / install / search` complete surface | +| Error Messages | 5/10 | 8/10 | **9/10** | doctor emits paste-ready fix per failed dimension; auto-fixable flag for agents | +| Documentation | 5/10 | 8/10 | **10/10** | `docs/skillpack-anatomy.md` is one-page + auto-generated rubric + reference pack as example | +| Upgrade Path | 2/10 | 9/10 | **9/10** | runbook-walker handles multi-hop | +| Dev Environment | 6/10 | 9/10 | **10/10** | `doctor --quick` (~5s) + `--fix` autoscaffold + `--full` (publish-gate) | +| Community | 3/10 | 8/10 | **9/10** | registry + tarball mirror + endorsement workflow + reference pack to fork | +| DX Measurement | 2/10 | 7/10 | **9/10** | doctor JSON envelope is stable; per-dimension scoring trend across publishes | +| **TTHW** | n/a | <5min | **<3min** | `init` → edit → `doctor --quick` → 10/10 | +| **Overall DX** | 4/10 | 8.5/10 | **9.5/10** | Rubric-as-source-of-truth + `every bundled pack is 10/10` invariant is the kill move | + +**Magical moment** (locked from DX 0D): `gbrain skillpack install ` lands the pack AND walks `runbooks/install.md` AND the agent immediately knows what triggers fire, what tools the skill exposes, and how to upgrade later. Zero "where are the docs?" moment. + +**Second magical moment** (Round 2): `gbrain skillpack doctor --quick --json` prints a 10/10 score with paste-ready fixes for the misses. The agent reads the JSON, runs `--fix --yes` to auto-scaffold, re-runs `--quick`, and the score climbs. The first time an agent gets from 6/10 to 10/10 in 30 seconds via three `gbrain` commands is the moment the "skillpacks are real software packages" claim becomes felt rather than asserted. + +**Lake Score:** 25/27 — every cathedral-leaning recommendation accepted across CEO + Eng + DX (both rounds) + 8 codex outside-voice questions. The 2 holds are deliberate: T2 (kept cathedral scope vs codex's minimal v1) and T3 (kept the 10/10 bundled invariant vs codex's defer-to-v1.1). Both defenses were on locked product-strategy decisions; the cathedral moat is the thing. + +**CODEX (outside voice) — 20 findings, 8 surfaced for decision:** +- T1 (RUNBOOK TRUST) — adopted: per-step approval replaces auto-walk; `--runbook-apply-all` for CI; `--runbook-skip` for file-drop-only. NPM-postinstall lesson applied. +- T2 (SCOPE) — held: cathedral is the moat; minimal v1 forfeits curation+evals+runbooks differentiation; without those gbrain skillpacks are just another agentskills.io mirror. +- T3 (10/10 BUNDLED) — held: shipping gbrain's own packs below the bar gbrain demands is credibility-poison; W4.5 retrofit costs ~3d with --fix autoscaffold, slips v1 by a week. +- T4 (GAMEABLE CATHEDRAL) — adopted: rubric splits into required core (5 dimensions: manifest + SKILL.md + routing-evals + check-resolvable + CHANGELOG) and quality badges (5: routing-evals-clean + unit tests + LLM-judge + install + uninstall runbook). Endorsed needs all badges; community needs 3/5; experimental needs core only. Plus stubbed-eval detection in publish-gate content scan. +- G1 (TRUST STORE) — adopted: `~/.gbrain/skillpack-state.json` machine-owned (TOFU pins, hashes, rename maps); resolver markdown stays render-only (rows + cumulative-slugs). Mismatch fails loud. +- G2 (ENV SCRUB) — adopted: clean env (only PATH/LANG/TZ), HOME override to empty `/sandbox-home`, explicit denylist (`*_API_KEY` / `*_TOKEN` / `*_SECRET` / SSH_AUTH_SOCK / GIT_* / NPM_TOKEN / BUN_INSTALL_TOKEN). Read-only mounts + masked `/proc` where bwrap supports it. +- G3 (CI SUPPLY CHAIN) — adopted: three-workflow split. validate-pr.yml is static-only on `pull_request` (no privileged tokens, no LFS write). post-merge-validate.yml runs the heavy suite inside the registry's own sandbox after merge. mirror-tarball.yml commits the tarball with a least-privilege deploy key scoped to `tarballs/`. +- G4 (NAMESPACE / TYPOSQUAT) — adopted: first-install identity confirm prompt showing author/source/commit/SHA/tier; subsequent same-author-same-pin installs skip. Registry rejects new endorsed-tier names within Damerau-Levenshtein edit-distance 2 of any existing endorsed pack. + +**Trailing correctness fixes (no decision needed, codex gaps clearly worth taking):** +- Tarball determinism: sorted entries, fixed mtimes, gzip mtime=0, no symlinks/hardlinks/devices/FIFOs, extract caps (5000 files / 100MB total / 1MB per file / 255-char paths / 100:1 ratio). +- check-resolvable pack-local isolation: doctor + publish-gate wrap `check-resolvable` in a tempdir fixture containing ONLY the pack's RESOLVER.md + skills/, so verdict is pack-local not workspace-global. +- Versioning beyond `gbrain_min_version`: manifest also carries `runbook_schema_version` + `eval_schema_version`; installer rejects newer-than-supported with paste-ready upgrade hint. + +**CROSS-MODEL TENSION (held cathedral over codex):** +- T2 scope and T3 bundled-invariant are product-strategy decisions where codex's argument (ship simpler v1 faster) lost to the user's argument (the differentiation IS the cathedral; shipping below your own bar is credibility-poison). Codex was right on every supply-chain finding; the disagreement on scope is taste, not correctness. Documented here so future maintainers see the trade. + +**Recommended next reviews:** +1. **/codex consult** as an outside voice on the locked-in plan; the artifact-as-software-package framing deserves an independent challenge. +2. **/devex-review** after implementation lands — the boomerang. Plan says TTHW < 5min; reality check post-ship. + +**UNRESOLVED:** none. CEO + Eng + DX (both rounds) + Codex outside-voice all clear with explicit decisions for every load-bearing item across 27 questions. + +**VERDICT:** CEO + ENG + DX + CODEX CLEARED — ready to implement. The plan is a complete spec; the next move is implementation, not more review. Codex's 20 findings were absorbed (6 adopted as direct improvements, 2 held as taste-of-cathedral product calls, 12 minor / overlapping / already-covered). The two cathedral defenses are documented so future maintainers see the trade. diff --git a/docs/skillpack-anatomy.md b/docs/skillpack-anatomy.md new file mode 100644 index 000000000..b925a5030 --- /dev/null +++ b/docs/skillpack-anatomy.md @@ -0,0 +1,112 @@ +# Skillpack anatomy + +The canonical one-page reference for what a third-party gbrain skillpack +looks like. The reference pack at `examples/skillpack-reference/` is the +live artifact this page describes; clone its tree and you have a 10/10 +starting point. + +## Tree + +``` +my-skillpack/ +├── skillpack.json # manifest (cathedral fields declared) +├── skills/ +│ └── / +│ ├── SKILL.md # frontmatter + body, agent-readable +│ └── routing-eval.jsonl # >= 5 intents pinning trigger -> skill +├── runbooks/ +│ └── bootstrap.md # post-scaffold display (NOT an executor) +├── test/ +│ └── *.test.ts # bun:test unit tests +├── e2e/ +│ └── *.test.ts # integration tests, gated on DATABASE_URL +├── evals/ +│ └── *.judge.json # LLM-judge eval configs (>= 3 cases each) +├── CHANGELOG.md # Keep-a-Changelog shape +├── LICENSE # SPDX-matching text +├── README.md +└── .gitignore +``` + +`gbrain skillpack init ` scaffolds this exact tree, pre-filled +with stubs that score 10/10 on `gbrain skillpack doctor . --quick` +immediately. Replace the stubs with real content, run the doctor +between edits, and `gbrain skillpack pack` produces a deterministic +`-.tgz` ready to publish to the registry. + +## How the agent uses a scaffolded pack + +After `gbrain skillpack scaffold ` lands the files: + +1. The user's agent walks `skills/*/SKILL.md` frontmatter and reads + each pack's `triggers:` array on startup or per-message. +2. When a user phrasing matches a trigger, the agent reads that + SKILL.md body top-to-bottom as in-context instructions. +3. gbrain DISPLAYS `runbooks/bootstrap.md` once after the scaffold + but does NOT auto-execute it. The agent decides whether to walk + the steps. This is the codex T1 supply-chain hardening: an + auto-walker would let a malicious pack mutate the user's brain + on install, which is how npm postinstall attacks happen. + +## How the doctor scores a pack + +Ten binary dimensions. Each is checked by a pure function in +`src/core/skillpack/rubric.ts` and returns `{passed, detail, fix_hint}`. +The doctor walks them in order and prints the score + per-dimension +status + paste-ready fix for every failure. + + + +### Core dimensions (5; must all pass to publish at any tier) + +| # | Name | Description | Auto-fixable | +|---|------|-------------|--------------| +| 1 | `manifest_valid` | skillpack.json passes the v1 schema validator | no | +| 2 | `skills_have_skill_md` | every listed skill has SKILL.md with valid frontmatter (name, description, triggers) | no | +| 3 | `routing_evals_present` | every skill has routing-eval.jsonl with >= 5 intents | yes | +| 4 | `skills_have_unique_triggers` | no two skills in this pack share an exact trigger phrase (MECE) | no | +| 5 | `changelog_present_and_current` | CHANGELOG.md present and contains an entry for the current version | yes | + +### Quality badges (5; earn for tier eligibility) + +| # | Name | Description | Auto-fixable | +|---|------|-------------|--------------| +| 6 | `unit_tests_present` | pack declares unit_tests[] with at least one matching test file | yes | +| 7 | `e2e_tests_present` | pack declares e2e_tests[] with at least one matching test file | yes | +| 8 | `llm_eval_present` | pack declares llm_evals[] with >= 1 file containing >= 3 cases | yes | +| 9 | `bootstrap_runbook_present` | pack declares runbooks.bootstrap and the file is non-empty | yes | +| 10 | `license_present` | LICENSE file exists at the pack root (informational badge) | yes | + +_Generated from `src/core/skillpack/rubric.ts` by `bun run scripts/build-skillpack-anatomy.ts`._ + + +## Tier eligibility + +| Tier | Requirement | +|------|-------------| +| `endorsed` | All 5 core + all 5 badges, plus Garry's `endorsements.json` overlay in the registry repo | +| `community` | All 5 core + >= 3 of 5 badges. Default tier on PR merge. | +| `experimental` | All 5 core + < 3 badges | +| `blocked` | Any core dimension fails | + +## CLI reference (third-party path) + +```bash +# Publisher side +gbrain skillpack init my-pack # scaffold the tree +gbrain skillpack doctor my-pack # see the score + fix hints +gbrain skillpack doctor my-pack --fix --yes # auto-scaffold missing pieces +gbrain skillpack pack my-pack # deterministic tarball + SHA-256 + +# Consumer side +gbrain skillpack search # browse the registry +gbrain skillpack info # show full pack metadata +gbrain skillpack scaffold # owner/repo, https, ./dir, ./*.tgz +gbrain skillpack registry --url X # point at a custom registry +``` + +## See also + +- `examples/skillpack-reference/` — the live 10/10 reference pack +- `docs/designs/SKILLPACK_REGISTRY_V1_SPEC.md` — strategic spec + decisions +- `docs/guides/skillpacks-as-scaffolding.md` — v0.36 scaffold/reference model diff --git a/examples/skillpack-reference/.gitignore b/examples/skillpack-reference/.gitignore new file mode 100644 index 000000000..7cf97fa74 --- /dev/null +++ b/examples/skillpack-reference/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.DS_Store +*.tgz diff --git a/examples/skillpack-reference/CHANGELOG.md b/examples/skillpack-reference/CHANGELOG.md new file mode 100644 index 000000000..df4357030 --- /dev/null +++ b/examples/skillpack-reference/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +All notable changes documented in Keep-a-Changelog shape. + +## [0.1.0] - 2026-05-19 + +- Initial release. diff --git a/examples/skillpack-reference/LICENSE b/examples/skillpack-reference/LICENSE new file mode 100644 index 000000000..a9050eaac --- /dev/null +++ b/examples/skillpack-reference/LICENSE @@ -0,0 +1,3 @@ +MIT License + +(edit me) Replace with the full license text matching the SPDX id above. diff --git a/examples/skillpack-reference/README.md b/examples/skillpack-reference/README.md new file mode 100644 index 000000000..11b1ccfef --- /dev/null +++ b/examples/skillpack-reference/README.md @@ -0,0 +1,67 @@ +# reference-pack + +The canonical 10/10 reference for a third-party gbrain skillpack. This +pack ships inside gbrain's own repo at `examples/skillpack-reference/` +and doubles as an integration-test fixture (`gbrain skillpack doctor +examples/skillpack-reference --quick` is wired into CI; the score must +stay at 10/10 forever). + +If you're authoring a new skillpack: read this tree top to bottom, +then run `gbrain skillpack init ` to scaffold the same +shape into a new directory. + +## Install (illustrative) + +Third parties who fork this layout publish to their own GitHub repo, +then users scaffold from there: + +```bash +gbrain skillpack scaffold your-user/skillpack- +``` + +`scaffold` lands the files additively (refuses to overwrite), records +the install in `~/.gbrain/skillpack-state.json` with the pinned commit +SHA, then displays `runbooks/bootstrap.md` (no executor — codex T1). + +## What this pack adds to the user's agent + +A single skill (`skills/reference-pack/`) that explains the third-party +skillpack contract when the user asks. The skill is illustrative; real +packs add useful behavior (judge a hackathon, score a founder, etc.). + +## Tree map + +``` +examples/skillpack-reference/ +├── skillpack.json # manifest (cathedral fields declared) +├── skills/ +│ └── reference-pack/ +│ ├── SKILL.md # frontmatter + body, agent-readable +│ └── routing-eval.jsonl # 5 intents pinning trigger -> skill +├── runbooks/ +│ └── bootstrap.md # post-scaffold display (no executor) +├── test/ +│ └── example.test.ts # unit test stub (bun:test) +├── e2e/ +│ └── example.e2e.test.ts # E2E stub, gated on DATABASE_URL +├── evals/ +│ └── reference-pack.judge.json # LLM-judge eval, 3 cases minimum +├── CHANGELOG.md # Keep-a-Changelog +├── LICENSE # SPDX-matching license text +├── README.md # this file +└── .gitignore +``` + +## Doctor verdict + +`gbrain skillpack doctor examples/skillpack-reference --quick` should +always print: + +``` +★ reference-pack@ 10/10 [endorsed] +``` + +A regression test +(`test/skillpack-init-pack.test.ts:e2e-init-doctor-pack`) pins this +contract: any change that drops the reference pack below 10/10 fails +the build. That's the invariant — gbrain ships its own bar. diff --git a/examples/skillpack-reference/e2e/example.e2e.test.ts b/examples/skillpack-reference/e2e/example.e2e.test.ts new file mode 100644 index 000000000..54454528c --- /dev/null +++ b/examples/skillpack-reference/e2e/example.e2e.test.ts @@ -0,0 +1,7 @@ +import { describe, test, expect } from 'bun:test'; + +describe.skipIf(!process.env.DATABASE_URL)('example E2E test', () => { + test('placeholder — replace with a real integration scenario', () => { + expect(process.env.DATABASE_URL).toBeDefined(); + }); +}); diff --git a/examples/skillpack-reference/evals/reference-pack.judge.json b/examples/skillpack-reference/evals/reference-pack.judge.json new file mode 100644 index 000000000..ce3061523 --- /dev/null +++ b/examples/skillpack-reference/evals/reference-pack.judge.json @@ -0,0 +1,18 @@ +{ + "task": "(edit me) Describe the task this LLM-judge eval scores reference-pack against.", + "output": "{{output-from-skill}}", + "cases": [ + { + "name": "happy path", + "criteria": "output satisfies the task" + }, + { + "name": "edge case", + "criteria": "output handles a corner input gracefully" + }, + { + "name": "failure mode", + "criteria": "output refuses gracefully on ambiguous input" + } + ] +} diff --git a/examples/skillpack-reference/runbooks/bootstrap.md b/examples/skillpack-reference/runbooks/bootstrap.md new file mode 100644 index 000000000..4441e53c5 --- /dev/null +++ b/examples/skillpack-reference/runbooks/bootstrap.md @@ -0,0 +1,7 @@ +# Bootstrap + +Post-scaffold steps. gbrain displays this but does NOT auto-execute. +The agent reads it and walks per-step at its own discretion. + +1. show user: "reference-pack is installed. Try one of the trigger phrases from skills/reference-pack/SKILL.md." +2. (edit me) agent: gbrain put_page wiki/_reference-pack-config --frontmatter type=config diff --git a/examples/skillpack-reference/skillpack.json b/examples/skillpack-reference/skillpack.json new file mode 100644 index 000000000..37878bba3 --- /dev/null +++ b/examples/skillpack-reference/skillpack.json @@ -0,0 +1,29 @@ +{ + "api_version": "gbrain-skillpack-v1", + "name": "reference-pack", + "version": "0.1.0", + "description": "(edit me) one-line description of the reference-pack skillpack", + "author": "gbrain maintainers", + "license": "MIT", + "homepage": "https://github.com/garrytan/gbrain/tree/master/examples/skillpack-reference", + "gbrain_min_version": "0.36.0", + "skills": [ + "skills/reference-pack" + ], + "runbooks": { + "bootstrap": "runbooks/bootstrap.md" + }, + "changelog": "CHANGELOG.md", + "unit_tests": [ + "test/**/*.test.ts" + ], + "e2e_tests": [ + "e2e/**/*.test.ts" + ], + "llm_evals": [ + "evals/*.judge.json" + ], + "routing_evals": [ + "skills/reference-pack/routing-eval.jsonl" + ] +} diff --git a/examples/skillpack-reference/skills/reference-pack/SKILL.md b/examples/skillpack-reference/skills/reference-pack/SKILL.md new file mode 100644 index 000000000..da548010e --- /dev/null +++ b/examples/skillpack-reference/skills/reference-pack/SKILL.md @@ -0,0 +1,103 @@ +--- +name: reference-pack +description: Reference skill demonstrating the 10/10 skillpack contract. Adds a "what does this skillpack do" answer to the user's agent. +mutating: false +triggers: + - what is the reference skillpack + - show me the reference skill + - explain the skillpack contract + - what's a 10/10 skillpack + - how do third-party skillpacks work +--- + +# reference-pack + +This is the canonical reference for a third-party gbrain skillpack. Read +its tree once and you know how to author one. + +## What this skill does + +When the user asks how third-party gbrain skillpacks work, this skill +points them at the four artifacts every pack ships: + +- `skillpack.json` — declares pack metadata + which artifacts the + doctor scores (skills, unit_tests, e2e_tests, llm_evals, routing_evals, + runbooks, changelog). +- `skills//SKILL.md` — frontmatter (name, description, mutating, + triggers) plus markdown body. Agents route on `triggers:`; the body + is the in-context instruction set. +- `runbooks/bootstrap.md` — agent-readable post-scaffold steps. gbrain + DISPLAYS this after `scaffold` lands; the agent walks per-step at + its own discretion. No auto-executor (codex T1 supply-chain hardening). +- `CHANGELOG.md` — Keep-a-Changelog shape. The doctor's `changelog_ + present_and_current` dimension fails if there's no `## []` + entry matching the manifest's `version`. + +## What the doctor scores + +Ten binary dimensions, split into: + +**Core (5; must all pass to publish at any tier):** + +1. `manifest_valid` — schema-validates skillpack.json +2. `skills_have_skill_md` — every listed skill has SKILL.md with + name / description / triggers +3. `routing_evals_present` — each skill has routing-eval.jsonl with + >= 5 intents +4. `skills_have_unique_triggers` — MECE at the pack level +5. `changelog_present_and_current` — CHANGELOG entry for current version + +**Quality badges (5; earn for tier eligibility):** + +6. `unit_tests_present` — manifest.unit_tests matches >= 1 file +7. `e2e_tests_present` — manifest.e2e_tests matches >= 1 file +8. `llm_eval_present` — `*.judge.json` with >= 3 cases +9. `bootstrap_runbook_present` — non-empty runbooks/bootstrap.md +10. `license_present` — LICENSE / LICENSE.md / LICENSE.txt non-empty + +Tier eligibility: + +- `endorsed` — all 10 (gates: Garry's `endorsements.json` overlay + in the registry) +- `community` — all 5 core + >= 3 of 5 badges (default tier on PR + merge) +- `experimental` — all 5 core + < 3 badges +- `blocked` — any core fails + +Run `gbrain skillpack doctor ` to see exactly which +dimensions a candidate pack passes and the paste-ready fix for each +failure. `--fix --yes` auto-scaffolds the dimensions flagged +`auto_fixable: true` (routing-eval stubs, CHANGELOG entries, +license stub, bootstrap stub, test stubs). + +## How agents use this skill + +The triggers above route any "what is a skillpack" / "how do third- +party packs work" user phrasing to THIS skill. The agent reads the +markdown body, then either answers the user's question directly or +calls `gbrain skillpack info ` / `search ` for live +registry data. + +## Test + eval coverage + +- `test/example.test.ts` — unit test that imports the skill helper + (stub; replace with real assertions). +- `e2e/example.e2e.test.ts` — integration test gated on DATABASE_URL. +- `evals/reference-pack.judge.json` — LLM-judge eval scoring this + skill's output against the "does it actually teach the contract" + bar across happy-path / edge / failure-mode cases. +- `skills/reference-pack/routing-eval.jsonl` — five phrasings users + ask, all mapped to `expected_skill: reference-pack`. + +## Author's checklist + +Before pushing, the publisher runs: + +``` +gbrain skillpack doctor . --quick --json +gbrain skillpack pack +``` + +The first hits the rubric and prints paste-ready fixes for any failed +dimension. The second emits `reference-pack-.tgz` with a +deterministic SHA-256 the publisher submits to the registry PR. diff --git a/examples/skillpack-reference/skills/reference-pack/routing-eval.jsonl b/examples/skillpack-reference/skills/reference-pack/routing-eval.jsonl new file mode 100644 index 000000000..b7e2eda72 --- /dev/null +++ b/examples/skillpack-reference/skills/reference-pack/routing-eval.jsonl @@ -0,0 +1,5 @@ +{"intent":"example phrase 1 for reference-pack","expected_skill":"reference-pack"} +{"intent":"example phrase 2 for reference-pack","expected_skill":"reference-pack"} +{"intent":"example phrase 3 for reference-pack","expected_skill":"reference-pack"} +{"intent":"example phrase 4 for reference-pack","expected_skill":"reference-pack"} +{"intent":"example phrase 5 for reference-pack","expected_skill":"reference-pack"} diff --git a/examples/skillpack-reference/test/example.test.ts b/examples/skillpack-reference/test/example.test.ts new file mode 100644 index 000000000..375bad641 --- /dev/null +++ b/examples/skillpack-reference/test/example.test.ts @@ -0,0 +1,7 @@ +import { describe, test, expect } from 'bun:test'; + +describe('example unit test', () => { + test('placeholder — replace with real assertions', () => { + expect(1 + 1).toBe(2); + }); +}); diff --git a/llms-full.txt b/llms-full.txt index ab584f216..1f7e09829 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -256,6 +256,7 @@ strict behavior when unset. - `src/commands/skillify-check.ts` (v0.19) — `gbrain skillpack-check` agent-readable health report. Exit 0/1/2 for CI pipeline gating; JSON for debugging. Wraps `check-resolvable --json`, `doctor --json`, and migration ledger into one payload so agents can decide whether a human action is required. - `src/commands/book-mirror.ts` (v0.25.1) — `gbrain book-mirror --chapters-dir --slug [flags]`. Flagship of the v0.25.1 skills wave. Submits N read-only subagent jobs (one per chapter; `allowed_tools: ['get_page', 'search']`), waits for all via `waitForCompletion`, reads each child's `job.result`, assembles two-column markdown CLI-side, writes a single operator-trust `put_page` to `media/books/-personalized.md`. Codex HIGH-1 fix applied: trust narrowing happens at the tool-allowlist layer (subagents can't call put_page) instead of allowedSlugPrefixes — untrusted EPUB content cannot prompt-inject any people page. Cost-estimate prompt before launching; refuses to spend in non-TTY without `--yes`. Per-chapter idempotency keys (`book-mirror::ch-`) for retry-friendly re-runs. Partial-failure handling: assembles with completed chapters and a `## Failed chapters` section listing retries. Test surface: `test/book-mirror.test.ts` (9 cases — CLI registration + source invariants). - `src/commands/skillpack.ts` + `src/core/skillpack/{bundle,scaffold,reference,migrate-fence,scrub-legacy,harvest,harvest-lint,copy,apply-hunks,diff-text,installer}.ts` (v0.19 → v0.36) — **v0.36 contract change**: managed-block install model retired. `install` and `uninstall` removed (clean break, no alias; both exit non-zero with a hint pointing at the replacement command). New surface: `scaffold` (one-time additive copy via shared `copyArtifacts` helper in `copy.ts`; refuses to overwrite existing files; partial-state fills missing paired sources declared in SKILL.md frontmatter `sources:`), `reference` (read-only diff lens with agent-readable framing line + `--apply-clean-hunks` two-way auto-apply via pure-JS unified-diff parser/applier in `apply-hunks.ts` + `diff-text.ts`), `migrate-fence` (one-shot strip of legacy fence; cumulative-slugs receipt → row-parsing fallback; preserves rows verbatim as user-owned routing), `scrub-legacy-fence-rows` (opt-in row cleanup with skill-present + non-empty-triggers gate), `harvest` (host→gbrain inverse with symlink-reject + canonical-path containment via `validateUploadPath`-style gate + default-on privacy linter in `harvest-lint.ts` against `~/.gbrain/harvest-private-patterns.txt` plus built-in `\bWintermute\b` + email + Slack-channel patterns; rollback on match). Paired-source declarations moved from `openclaw.plugin.json` to each SKILL.md's frontmatter `sources:` array (D2; validated by `loadSkillSources` in `bundle.ts`). `autoDetectSkillsDir` (in `src/core/repo-root.ts`) gains a `cwd_walk_up` tier ahead of `~/.openclaw/workspace` (D3; non-OpenClaw hosts like `~/git/wintermute` auto-detect; R5 regression preserves `$OPENCLAW_WORKSPACE` precedence). `gbrain skillpack check --strict` exits non-zero on drift (CI gate); top-level `gbrain skillpack-check` keeps exit-1-on-issues for cron compat. Companion editorial skill `skills/skillpack-harvest/SKILL.md` drives the genericization checklist before the CLI runs. Design + workflow doc: `docs/guides/skillpacks-as-scaffolding.md`. ~600 LOC of managed-block machinery deleted; ~400 LOC of new modules + ~1000 LOC of new test coverage across `test/skillpack-{copy,scaffold,reference,reference-apply,apply-hunks,migrate-fence,scrub-legacy,harvest,harvest-lint,frontmatter-sources}.test.ts` + 9-case E2E in `test/e2e/skillpack-flow.test.ts`. `installer.ts` + `test/skillpack-install.test.ts` survive for now — `gbrain skillpack diff` still uses `diffSkill` from there; slated for v0.37 cleanup. **Historical (v0.19-v0.35.1):** managed-block model with ``/`end -->` fence, `cumulative-slugs="..."` receipt, content-hash gates, lockfile; `install --all` prune; `uninstall` with D8 receipt gate + D11 atomic-refusal content-hash pre-scan. Replaced wholesale in v0.36. +- `src/core/skillpack/{manifest-v1,tarball,state,remote-source,trust-prompt,bootstrap-display,scaffold-third-party,registry-schema,registry-client,rubric,doctor,init-scaffold,pack-publish,endorse,audit}.ts` + `examples/skillpack-reference/` + `docs/skillpack-anatomy.md` + `scripts/build-skillpack-anatomy.ts` (v0.37.0.0) — third-party skillpack ecosystem layered on the v0.36 scaffolding contract. `gbrain skillpack scaffold ` resolves the spec via `classifySpec`, fetches through SSRF-hardened `git-remote.ts` (git) or extracts the tarball into `~/.gbrain/skillpack-cache/////`, validates `skillpack.json` (api_version `gbrain-skillpack-v1`), checks `gbrain_min_version`, surfaces a TOFU first-install identity-confirm prompt (codex G4: author + source + pinned commit + tarball SHA + tier; non-TTY requires `--trust`), records the pin in machine-owned `~/.gbrain/skillpack-state.json` (codex G1; schema `gbrain-skillpack-state-v1`, atomic `.tmp + rename`, `isAlreadyTrusted` skips re-prompt on author+pin match), runs through the existing `enumerateScaffoldEntries` → `copyArtifacts` pipeline (one-time additive copy; refuses to overwrite), then DISPLAYS `runbooks/bootstrap.md` without executing (codex T1 npm-postinstall lesson — "gbrain deliberately does NOT auto-execute these steps"). Registry catalog at `garrytan/gbrain-skillpack-registry` (separate repo) split into `registry.json` (PR-able, schema `gbrain-registry-v1`) + `endorsements.json` (Garry-only overlay, `gbrain-endorsements-v1`); `effectiveTier` merges. `registry-client.ts` fetches both URLs via `If-None-Match` etag with 1h soft-TTL + stale-fallback (origins: `fresh_fetch | cache_warm | cache_soft_stale | cache_hard_stale`); hard-fail only on no-cache + no-network. `gbrain skillpack {search,info,registry,doctor,init,pack,endorse}` CLI surface. Doctor walks `SKILLPACK_RUBRIC_V1` (10 binary dimensions, codex T4 split into 5 required CORE — manifest_valid, skills_have_skill_md, routing_evals_present ≥5 intents, skills_have_unique_triggers MECE, changelog_present_and_current — and 5 quality BADGES — unit_tests_present, e2e_tests_present, llm_eval_present ≥3 cases, bootstrap_runbook_present, license_present); tier eligibility: `endorsed` needs all 10, `community` needs core + ≥3 badges, `experimental` needs core only, `blocked` when any core fails. `--quick` ~5s structural sweep; `--fix --yes` auto-scaffolds `auto_fixable: true` dimensions and refuses to overwrite files whose mtime is newer than `skillpack.json`. `gbrain skillpack init ` lands the full cathedral (11 files: skillpack.json, SKILL.md, routing-eval.jsonl, test/example.test.ts, e2e/example.e2e.test.ts, evals/example.judge.json, runbooks/{bootstrap,uninstall,upgrade-template}.md, CHANGELOG, README, LICENSE); freshly-init'd pack scores 10/10. `--minimal` skips test/e2e/evals. `gbrain skillpack pack` packs a deterministic tarball via GNU tar (`--sort=name --mtime=@0 --owner=0 --group=0 --numeric-owner` + `GZIP=-n` + `TZ=UTC`); refuses on `tier_eligibility === 'blocked'`. Extract caps (5000 files / 100MB total / 1MB per file / 255-char paths / 100:1 compression ratio); rejects symlinks/hardlinks/devices/FIFOs. `gbrain skillpack endorse [--tier ...] [--push] [--dry-run]` runs in a clone of the registry repo: validates pack exists in `registry.json`, mutates `endorsements.json` via pure `applyEndorsement`, stable-key-orders the write, stages + commits `endorse: -> `, optionally pushes. JSONL audit at `~/.gbrain/audit/skillpack-YYYY-Www.jsonl` (ISO-week rotated, honors `GBRAIN_AUDIT_DIR`). `examples/skillpack-reference/` is a real 10/10 reference pack pinned by `test/e2e/skillpack-third-party.test.ts`. `docs/skillpack-anatomy.md` is auto-generated via `scripts/build-skillpack-anatomy.ts` with `--check` mode for CI drift detection. CLI dispatch in `src/commands/skillpack.ts` disambiguates third-party (contains `/`, `://`, `.tgz`) from bundled-skill kebab; kebab routes bundled-first, registry-fallback. Test coverage: `test/skillpack-{manifest-v1,tarball,state,remote-source,trust-prompt,registry-schema,registry-client,rubric,doctor,init-scaffold,pack-publish,endorse,audit,scaffold-third-party}.test.ts` + e2e at `test/e2e/skillpack-third-party.test.ts`. Plan + spec at `docs/designs/SKILLPACK_REGISTRY_V1_SPEC.md`. Deferred to follow-up waves: subprocess sandbox for publish-gate, `garrytan/gbrain-skillpack-registry` repo creation + CI workflow split (codex G3), Printing Press cross-list at `mvanhorn/printing-press-library`, generated `gbrain-cli` via Printing Press, W4.5 retrofit of ~25 bundled skills to 10/10. - `src/core/archive-crawler-config.ts` (v0.25.1) — D12 + codex HIGH-4 safety gate for the `archive-crawler` skill. Refuses to run unless `archive-crawler.scan_paths:` is explicitly set in the brain repo's `gbrain.yml`. Mirrors the storage-config.ts parsing pattern (sibling file; separate concern from storage tiering). `loadArchiveCrawlerConfig(repoPath)` throws `ArchiveCrawlerConfigError(missing_section | empty_scan_paths | invalid_path | parse_error)`. `normalizeAndValidateArchiveCrawlerConfig` rejects relative paths and `..` traversal; `~` is expanded; trailing-slash normalized for unambiguous prefix matching. `isPathAllowed(candidate, config)` is the runtime per-file gate (scan_paths prefix-match with directory-boundary correctness; deny_paths overrides). Tests in `test/archive-crawler-config.test.ts` (19 cases). - `test/helpers/cli-pty-runner.ts` (v0.25.1) — generic real-PTY harness ported from gstack and trimmed to ~470 lines. Uses pure `Bun.spawn({terminal:})` (Bun 1.3.10+; engines.bun pin in package.json). Generic primitives only — no plan-mode orchestrators. Exports: `launchPty`, `resolveBinary`, `stripAnsi`, `parseNumberedOptions`, `optionsSignature`, `isNumberedOptionListVisible`, `isTrustDialogVisible`. Self-tests in `test/cli-pty-runner.test.ts` (24 cases). - `src/core/skill-manifest.ts` (v0.19) — parser for `skill-manifest.json` records. Used by skillpack installer to detect drift between the shipped bundle and the user's local edits, so updates merge instead of overwriting. diff --git a/package.json b/package.json index 51cf871d4..35f7ef7b1 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,6 @@ { "name": "gbrain", - - "version": "0.36.6.0", - - + "version": "0.37.0.0", "description": "Postgres-native personal knowledge brain with hybrid RAG search", "type": "module", "main": "src/core/index.ts", diff --git a/scripts/build-skillpack-anatomy.ts b/scripts/build-skillpack-anatomy.ts new file mode 100644 index 000000000..f5a7e2346 --- /dev/null +++ b/scripts/build-skillpack-anatomy.ts @@ -0,0 +1,192 @@ +#!/usr/bin/env bun +/** + * scripts/build-skillpack-anatomy.ts — regenerate docs/skillpack-anatomy.md + * from the declarative rubric.ts. The doc has a hand-written intro + tree + * diagram and an auto-generated rubric table; this script regenerates the + * table only, between explicit BEGIN/END markers. + * + * Run: `bun run scripts/build-skillpack-anatomy.ts` + * CI: `scripts/check-anatomy-fresh.sh` runs this in --check mode and + * fails the build if the committed doc differs from regenerated. + */ + +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { join } from 'path'; + +import { describeRubric } from '../src/core/skillpack/rubric.ts'; + +const REPO_ROOT = join(import.meta.dir, '..'); +const DOC_PATH = join(REPO_ROOT, 'docs', 'skillpack-anatomy.md'); + +const BEGIN = ''; +const END = ''; + +function buildRubricSection(): string { + const rubric = describeRubric(); + const core = rubric.filter((d) => d.category === 'core'); + const badges = rubric.filter((d) => d.category === 'badge'); + + const rows = (list: typeof rubric) => + list + .map( + (d) => + `| ${d.id} | \`${d.name}\` | ${d.description} | ${d.auto_fixable ? 'yes' : 'no'} |`, + ) + .join('\n'); + + return [ + BEGIN, + '', + '### Core dimensions (5; must all pass to publish at any tier)', + '', + '| # | Name | Description | Auto-fixable |', + '|---|------|-------------|--------------|', + rows(core), + '', + '### Quality badges (5; earn for tier eligibility)', + '', + '| # | Name | Description | Auto-fixable |', + '|---|------|-------------|--------------|', + rows(badges), + '', + '_Generated from `src/core/skillpack/rubric.ts` by `bun run scripts/build-skillpack-anatomy.ts`._', + '', + END, + ].join('\n'); +} + +const HAND_WRITTEN_FRAME = `# Skillpack anatomy + +The canonical one-page reference for what a third-party gbrain skillpack +looks like. The reference pack at \`examples/skillpack-reference/\` is the +live artifact this page describes; clone its tree and you have a 10/10 +starting point. + +## Tree + +\`\`\` +my-skillpack/ +├── skillpack.json # manifest (cathedral fields declared) +├── skills/ +│ └── / +│ ├── SKILL.md # frontmatter + body, agent-readable +│ └── routing-eval.jsonl # >= 5 intents pinning trigger -> skill +├── runbooks/ +│ └── bootstrap.md # post-scaffold display (NOT an executor) +├── test/ +│ └── *.test.ts # bun:test unit tests +├── e2e/ +│ └── *.test.ts # integration tests, gated on DATABASE_URL +├── evals/ +│ └── *.judge.json # LLM-judge eval configs (>= 3 cases each) +├── CHANGELOG.md # Keep-a-Changelog shape +├── LICENSE # SPDX-matching text +├── README.md +└── .gitignore +\`\`\` + +\`gbrain skillpack init \` scaffolds this exact tree, pre-filled +with stubs that score 10/10 on \`gbrain skillpack doctor . --quick\` +immediately. Replace the stubs with real content, run the doctor +between edits, and \`gbrain skillpack pack\` produces a deterministic +\`-.tgz\` ready to publish to the registry. + +## How the agent uses a scaffolded pack + +After \`gbrain skillpack scaffold \` lands the files: + +1. The user's agent walks \`skills/*/SKILL.md\` frontmatter and reads + each pack's \`triggers:\` array on startup or per-message. +2. When a user phrasing matches a trigger, the agent reads that + SKILL.md body top-to-bottom as in-context instructions. +3. gbrain DISPLAYS \`runbooks/bootstrap.md\` once after the scaffold + but does NOT auto-execute it. The agent decides whether to walk + the steps. This is the codex T1 supply-chain hardening: an + auto-walker would let a malicious pack mutate the user's brain + on install, which is how npm postinstall attacks happen. + +## How the doctor scores a pack + +Ten binary dimensions. Each is checked by a pure function in +\`src/core/skillpack/rubric.ts\` and returns \`{passed, detail, fix_hint}\`. +The doctor walks them in order and prints the score + per-dimension +status + paste-ready fix for every failure. + +`; + +const HAND_WRITTEN_FOOTER = ` +## Tier eligibility + +| Tier | Requirement | +|------|-------------| +| \`endorsed\` | All 5 core + all 5 badges, plus Garry's \`endorsements.json\` overlay in the registry repo | +| \`community\` | All 5 core + >= 3 of 5 badges. Default tier on PR merge. | +| \`experimental\` | All 5 core + < 3 badges | +| \`blocked\` | Any core dimension fails | + +## CLI reference (third-party path) + +\`\`\`bash +# Publisher side +gbrain skillpack init my-pack # scaffold the tree +gbrain skillpack doctor my-pack # see the score + fix hints +gbrain skillpack doctor my-pack --fix --yes # auto-scaffold missing pieces +gbrain skillpack pack my-pack # deterministic tarball + SHA-256 + +# Consumer side +gbrain skillpack search # browse the registry +gbrain skillpack info # show full pack metadata +gbrain skillpack scaffold # owner/repo, https, ./dir, ./*.tgz +gbrain skillpack registry --url X # point at a custom registry +\`\`\` + +## See also + +- \`examples/skillpack-reference/\` — the live 10/10 reference pack +- \`docs/designs/SKILLPACK_REGISTRY_V1_SPEC.md\` — strategic spec + decisions +- \`docs/guides/skillpacks-as-scaffolding.md\` — v0.36 scaffold/reference model +`; + +async function main(): Promise { + const args = process.argv.slice(2); + const checkMode = args.includes('--check'); + + const generatedSection = buildRubricSection(); + + let existing = ''; + if (existsSync(DOC_PATH)) { + existing = readFileSync(DOC_PATH, 'utf-8'); + } + + let next: string; + if (existing.includes(BEGIN) && existing.includes(END)) { + // Replace the existing auto-section in place. + const before = existing.slice(0, existing.indexOf(BEGIN)); + const after = existing.slice(existing.indexOf(END) + END.length); + next = before + generatedSection + after; + } else { + // First-time write: emit hand-written frame + auto section + footer. + next = HAND_WRITTEN_FRAME + generatedSection + HAND_WRITTEN_FOOTER; + } + + if (checkMode) { + if (existing.trim() !== next.trim()) { + process.stderr.write( + '[check-anatomy-fresh] docs/skillpack-anatomy.md is out of sync with rubric.ts. Run `bun run scripts/build-skillpack-anatomy.ts` to regenerate.\n', + ); + process.exit(1); + } + process.stderr.write('[check-anatomy-fresh] docs/skillpack-anatomy.md is fresh.\n'); + process.exit(0); + } + + writeFileSync(DOC_PATH, next); + process.stderr.write(`[skillpack-anatomy] wrote ${DOC_PATH}\n`); +} + +if (import.meta.main) { + main().catch((err) => { + process.stderr.write(`Error: ${(err as Error).message}\n`); + process.exit(2); + }); +} diff --git a/src/commands/skillpack.ts b/src/commands/skillpack.ts index f19a6668f..2ab6a498b 100644 --- a/src/commands/skillpack.ts +++ b/src/commands/skillpack.ts @@ -29,14 +29,28 @@ import { runMigrateFence } from '../core/skillpack/migrate-fence.ts'; import { runScrubLegacy } from '../core/skillpack/scrub-legacy.ts'; import { runHarvest, HarvestError } from '../core/skillpack/harvest.ts'; import { autoDetectSkillsDir } from '../core/repo-root.ts'; +import { + RemoteSourceError, + classifySpec, + resolveSource, +} from '../core/skillpack/remote-source.ts'; +import { + ScaffoldThirdPartyError, + runScaffoldThirdParty, +} from '../core/skillpack/scaffold-third-party.ts'; +import { SkillpackManifestError } from '../core/skillpack/manifest-v1.ts'; +import { VERSION } from '../version.ts'; const HELP_TOP = `gbrain skillpack [options] Subcommands: list Print every skill bundled in openclaw.plugin.json. - scaffold Copy a bundled skill into your agent repo. Additive; - scaffold --all refuses to overwrite existing files. + scaffold Copy a bundled skill OR a third-party skillpack + into your agent repo. Additive; refuses to + overwrite. Third-party sources: owner/repo, + https://...git, ./local-dir, ./local.tgz. + scaffold --all Scaffold every bundled skill (gbrain only). reference Read-only: diff gbrain's bundle vs your local copy. reference --all Sweep over every bundled skill. @@ -58,6 +72,20 @@ Subcommands: check Health report. \`check --strict\` exits non-zero on any drift (for CI gating). + search [] Search the third-party registry catalog. + info Show full metadata for a registry entry. + registry [--url URL] Show/set the configured registry URL. + + doctor Run the 10-dimension quality rubric over a + third-party pack. --quick (~5s), --fix to + auto-scaffold missing artifacts. + init Scaffold a fresh skillpack tree (cathedral + default; --minimal opts out of test/e2e/evals). + pack [] Run doctor then emit a deterministic + -.tgz tarball with SHA-256. + endorse (Operator-only) Set the tier for a pack in + endorsements.json inside a registry repo clone. + Run \`gbrain skillpack --help\` for per-subcommand options. Removed in v0.33 (use migrate-fence to upgrade, then \`scaffold\`): @@ -98,6 +126,27 @@ export async function runSkillpack(args: string[]): Promise { case 'check': await routeCheck(rest); return; + case 'search': + await cmdSearch(rest); + return; + case 'info': + await cmdInfo(rest); + return; + case 'registry': + await cmdRegistry(rest); + return; + case 'doctor': + await cmdDoctor(rest); + return; + case 'init': + await cmdInit(rest); + return; + case 'pack': + await cmdPack(rest); + return; + case 'endorse': + await cmdEndorse(rest); + return; case 'install': console.error( "Error: 'gbrain skillpack install' was removed in v0.33. Use 'gbrain skillpack scaffold ' instead.\n" + @@ -190,13 +239,28 @@ async function cmdList(args: string[]): Promise { async function cmdScaffold(args: string[]): Promise { if (args.includes('--help') || args.includes('-h')) { console.log( - 'gbrain skillpack scaffold | --all [--workspace PATH] [--dry-run] [--json]', + 'gbrain skillpack scaffold | | --all [--workspace PATH] [--dry-run] [--trust] [--no-cache] [--json]\n\n' + + ' — bundled skill slug (e.g. `book-mirror`)\n' + + ' — third-party skillpack source. Accepted shapes:\n' + + ' owner/repo (expands to https://github.com/owner/repo)\n' + + ' https://...git (verbatim https URL)\n' + + ' ./local/dir/ (local pack root)\n' + + ' ./local/pack.tgz (local tarball)\n' + + '\nFlags:\n' + + ' --workspace PATH Target workspace (default: auto-detected)\n' + + ' --all Scaffold every bundled skill (gbrain only)\n' + + ' --dry-run Validate + report; no writes\n' + + ' --trust Skip first-install confirm prompt (CI / unattended agents)\n' + + ' --no-cache Force fresh clone/extract for third-party sources\n' + + ' --json Stable JSON envelope for agent consumption', ); process.exit(0); } const json = args.includes('--json'); const dryRun = args.includes('--dry-run'); const all = args.includes('--all'); + const trustFlag = args.includes('--trust'); + const noCache = args.includes('--no-cache'); let name: string | null = null; let workspace: string | null = null; for (let i = 0; i < args.length; i++) { @@ -211,13 +275,57 @@ async function cmdScaffold(args: string[]): Promise { } } if (!all && !name) { - console.error('Error: pass a skill name or --all.'); + console.error('Error: pass a skill name, third-party source, or --all.'); process.exit(2); } - const gbrainRoot = findGbrainOrDie(); + // Disambiguate bundled-skill name vs third-party source. + // + // Routing rules (in priority order): + // 1. `--all` → bundled --all sweep + // 2. Spec contains `/` / `://` / ends in .tgz → third-party direct + // 3. Bare kebab AND matches a bundled-skill slug → bundled (v0.36 path) + // 4. Bare kebab AND NOT a bundled-skill slug → third-party via registry const targetWorkspace = resolveWorkspace({ workspace }); + const isThirdPartyShape = !all && name !== null && /[\/:]|\.(tgz|tar\.gz)$/.test(name); + + if (!all && name !== null && !isThirdPartyShape) { + // Check if the kebab name matches a bundled-skill slug. + const gbrainRoot = findGbrainRoot(); + if (gbrainRoot) { + try { + const manifest = loadBundleManifest(gbrainRoot); + const slugs = bundledSkillSlugs(manifest); + if (!slugs.includes(name)) { + // Not a bundled slug — try the registry. + await runThirdPartyScaffold({ + spec: name, + targetWorkspace, + dryRun, + trustFlag, + noCache, + json, + }); + return; + } + } catch { + // Fall through to the bundled path; it'll surface a clearer error. + } + } + } else if (isThirdPartyShape) { + await runThirdPartyScaffold({ + spec: name!, + targetWorkspace, + dryRun, + trustFlag, + noCache, + json, + }); + return; + } + + const gbrainRoot = findGbrainOrDie(); try { const result = runScaffold({ gbrainRoot, @@ -250,6 +358,131 @@ async function cmdScaffold(args: string[]): Promise { } } +// --------------------------------------------------------------------------- +// scaffold — third-party source path (new in v0.37) +// --------------------------------------------------------------------------- + +interface ThirdPartyScaffoldOptions { + spec: string; + targetWorkspace: string; + dryRun: boolean; + trustFlag: boolean; + noCache: boolean; + json: boolean; +} + +async function runThirdPartyScaffold(opts: ThirdPartyScaffoldOptions): Promise { + // Step 1: resolve the source. Kebab names get a registry lookup first; + // everything else hits the direct resolveSource() path. + let resolved; + let registryTier: 'endorsed' | 'community' | 'experimental' | 'dead' | undefined; + try { + const cls = classifySpec(opts.spec); + if (cls.kind === 'kebab') { + // Registry path: load catalog, find pack, follow to URL. + const { loadRegistry, findPackWithTier } = await import('../core/skillpack/registry-client.ts'); + const loaded = await loadRegistry({}); + const found = findPackWithTier(loaded, cls.normalized); + if (!found) { + console.error( + `Error: no skillpack named "${cls.normalized}" in the registry (${loaded.registry_url}).\n` + + `Run \`gbrain skillpack search ${cls.normalized}\` for matches, or pass a full source (owner/repo, https URL, ./path, ./*.tgz).`, + ); + process.exit(2); + } + registryTier = found.tier; + resolved = resolveSource(found.entry.source.url, { noCache: opts.noCache }); + } else { + resolved = resolveSource(opts.spec, { noCache: opts.noCache }); + } + } catch (err) { + if (err instanceof RemoteSourceError) { + console.error(`skillpack scaffold: ${err.message}`); + process.exit(2); + } + throw err; + } + + // Step 2: orchestrator handles manifest validation, trust prompt, copy, + // state.json update, and bootstrap display. + try { + const result = await runScaffoldThirdParty( + { + resolved, + targetWorkspace: opts.targetWorkspace, + trustFlag: opts.trustFlag, + dryRun: opts.dryRun, + tier: registryTier, + }, + VERSION, + ); + + if (opts.json) { + console.log( + JSON.stringify( + { + ok: result.status !== 'aborted_no_trust', + status: result.status, + pack: { + name: result.manifest.name, + version: result.manifest.version, + author: result.manifest.author, + }, + source: result.resolved.source, + source_kind: result.resolved.kind, + pinned_commit: result.resolved.pinned_commit, + tarball_sha256: result.resolved.tarball_sha256, + cache_hit: result.resolved.cache_hit, + trust: { trusted: result.trustDecision.trusted, reason: result.trustDecision.reason }, + copy: result.copy?.summary ?? null, + bootstrap_shown: result.bootstrap.shown, + }, + null, + 2, + ), + ); + } else { + if (result.status === 'aborted_no_trust') { + console.error( + `skillpack scaffold: aborted (trust decision: ${result.trustDecision.reason}). No files written.`, + ); + process.exit(1); + } + const m = result.manifest; + const summary = result.copy?.summary; + console.log( + `${opts.dryRun ? 'scaffold (dry-run)' : 'scaffold'}: ${m.name}@${m.version} by ${m.author}` + + (summary + ? ` — ${summary.wroteNew} wrote, ${summary.skippedExisting} skipped` + : ''), + ); + if (result.resolved.kind !== 'local') { + console.log( + `Source: ${result.resolved.source}` + + (result.resolved.pinned_commit ? ` @ ${result.resolved.pinned_commit.slice(0, 12)}` : ''), + ); + } + if (result.bootstrap.shown) { + // Bootstrap framing on stderr so stdout stays clean for the agent contract. + process.stderr.write('\n' + result.bootstrap.text + '\n'); + } + if (!opts.dryRun && summary && summary.wroteNew > 0) { + console.log( + `\nNext: your agent walks skills/*/SKILL.md frontmatter triggers: for routing.\n` + + `Run \`gbrain skillpack reference ${m.name}\` later if upstream changes.`, + ); + } + } + process.exit(result.status === 'aborted_no_trust' ? 1 : 0); + } catch (err) { + if (err instanceof ScaffoldThirdPartyError || err instanceof SkillpackManifestError) { + console.error(`skillpack scaffold: ${(err as Error).message}`); + process.exit(2); + } + throw err; + } +} + // --------------------------------------------------------------------------- // reference (+ --apply-clean-hunks) // --------------------------------------------------------------------------- @@ -494,6 +727,533 @@ async function cmdScrubLegacy(args: string[]): Promise { process.exit(0); } +// --------------------------------------------------------------------------- +// search / info / registry (registry catalog reads) +// --------------------------------------------------------------------------- + +async function cmdSearch(args: string[]): Promise { + if (args.includes('--help') || args.includes('-h')) { + console.log( + 'gbrain skillpack search [] [--tier endorsed|community|experimental|dead] [--json] [--refresh] [--url URL]', + ); + process.exit(0); + } + const json = args.includes('--json'); + const refresh = args.includes('--refresh'); + let query: string | undefined; + let tier: 'endorsed' | 'community' | 'experimental' | 'dead' | undefined; + let urlOverride: string | undefined; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === '--tier') { + tier = args[i + 1] as typeof tier; + i++; + } else if (a === '--url') { + urlOverride = args[i + 1]; + i++; + } else if (a && !a.startsWith('--') && !query) { + query = a; + } + } + + const { loadRegistry, searchPacks } = await import('../core/skillpack/registry-client.ts'); + const loaded = await loadRegistry({ url: urlOverride, refresh }); + const results = searchPacks(loaded, { query, tier }); + + if (json) { + console.log( + JSON.stringify( + { + registry_url: loaded.registry_url, + origin: loaded.origin, + cache_age_ms: loaded.cache_age_ms, + query: query ?? null, + tier_filter: tier ?? null, + count: results.length, + results: results.map((r) => ({ + name: r.entry.name, + version: r.entry.version, + description: r.entry.description, + author: r.entry.author, + tier: r.tier, + tags: r.entry.tags, + homepage: r.entry.homepage, + })), + }, + null, + 2, + ), + ); + process.exit(0); + } + + if (results.length === 0) { + console.log(`(no skillpacks matched${query ? ` "${query}"` : ''}${tier ? ` (tier=${tier})` : ''})`); + process.exit(0); + } + console.log(`${results.length} skillpack${results.length === 1 ? '' : 's'} (from ${loaded.registry_url})\n`); + for (const r of results) { + const tierBadge = r.tier === 'endorsed' ? '★' : r.tier === 'community' ? '·' : r.tier === 'experimental' ? '?' : '✗'; + console.log(` ${tierBadge} ${r.entry.name}@${r.entry.version} [${r.tier}]`); + console.log(` ${r.entry.description}`); + console.log(` by ${r.entry.author} · ${r.entry.homepage}`); + if (r.entry.tags.length > 0) console.log(` tags: ${r.entry.tags.join(', ')}`); + console.log(''); + } + process.exit(0); +} + +async function cmdInfo(args: string[]): Promise { + if (args.includes('--help') || args.includes('-h')) { + console.log('gbrain skillpack info [--json] [--refresh] [--url URL]'); + process.exit(0); + } + const json = args.includes('--json'); + const refresh = args.includes('--refresh'); + let name: string | undefined; + let urlOverride: string | undefined; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === '--url') { + urlOverride = args[i + 1]; + i++; + } else if (a && !a.startsWith('--') && !name) { + name = a; + } + } + if (!name) { + console.error('Error: pass a skillpack name.'); + process.exit(2); + } + + const { loadRegistry, findPackWithTier } = await import('../core/skillpack/registry-client.ts'); + const loaded = await loadRegistry({ url: urlOverride, refresh }); + const found = findPackWithTier(loaded, name); + if (!found) { + console.error(`Error: no skillpack named "${name}" in ${loaded.registry_url}.`); + process.exit(2); + } + + if (json) { + console.log( + JSON.stringify( + { + name: found.entry.name, + version: found.entry.version, + description: found.entry.description, + author: found.entry.author, + author_handle: found.entry.author_handle, + homepage: found.entry.homepage, + tier: found.tier, + tags: found.entry.tags, + source: found.entry.source, + tarball_sha256: found.entry.tarball_sha256, + gbrain_min_version: found.entry.gbrain_min_version, + validated_at: found.entry.validated_at, + validation_run_id: found.entry.validation_run_id, + skills_count: found.entry.skills_count, + skills: found.entry.skills, + }, + null, + 2, + ), + ); + process.exit(0); + } + console.log(`${found.entry.name}@${found.entry.version} [${found.tier}]`); + console.log(` Description: ${found.entry.description}`); + console.log(` Author: ${found.entry.author} (@${found.entry.author_handle})`); + console.log(` Homepage: ${found.entry.homepage}`); + console.log(` Source: ${found.entry.source.url}`); + console.log(` Pinned commit: ${found.entry.source.pinned_commit}`); + console.log(` Tarball SHA: sha256:${found.entry.tarball_sha256}`); + console.log(` gbrain min: ${found.entry.gbrain_min_version}`); + console.log(` Validated: ${found.entry.validated_at} (run ${found.entry.validation_run_id})`); + console.log(` Tags: ${found.entry.tags.join(', ')}`); + console.log(` Skills (${found.entry.skills_count}):`); + for (const s of found.entry.skills) console.log(` - ${s}`); + console.log('\nTo scaffold:'); + console.log(` gbrain skillpack scaffold ${found.entry.name}`); + process.exit(0); +} + +async function cmdRegistry(args: string[]): Promise { + if (args.includes('--help') || args.includes('-h')) { + console.log( + 'gbrain skillpack registry [--url URL] [--refresh] [--json]\n' + + '\n' + + ' --url URL Set the registry URL (writes to ~/.gbrain/config.json)\n' + + ' --refresh Force a fresh fetch from the current registry URL\n' + + ' --json JSON output for agent consumption', + ); + process.exit(0); + } + const json = args.includes('--json'); + const refresh = args.includes('--refresh'); + let setUrl: string | undefined; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--url' && args[i + 1]) { + setUrl = args[i + 1]; + i++; + } + } + + if (setUrl) { + // Persist to ~/.gbrain/config.json under skillpack.registry_url. + const { gbrainPath } = await import('../core/config.ts'); + const cfgPath = gbrainPath('config.json'); + let cfg: Record = {}; + if (existsSync(cfgPath)) { + try { + cfg = JSON.parse(readFileSync(cfgPath, 'utf-8')) as Record; + } catch { + cfg = {}; + } + } + (cfg as Record).skillpack = { + ...((cfg.skillpack as Record) ?? {}), + registry_url: setUrl, + }; + const tmp = cfgPath + '.tmp'; + const fs = await import('fs'); + fs.mkdirSync(require('path').dirname(cfgPath), { recursive: true }); + fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + '\n'); + fs.renameSync(tmp, cfgPath); + console.log(`Set skillpack.registry_url = ${setUrl}`); + } + + const { loadRegistry, resolveRegistryUrl } = await import('../core/skillpack/registry-client.ts'); + const url = resolveRegistryUrl({}); + try { + const loaded = await loadRegistry({ refresh }); + if (json) { + console.log( + JSON.stringify( + { + registry_url: loaded.registry_url, + origin: loaded.origin, + cache_age_ms: loaded.cache_age_ms, + skillpack_count: loaded.catalog.skillpacks.length, + updated_at: loaded.catalog.updated_at, + bundles: Object.keys(loaded.catalog.bundles ?? {}), + }, + null, + 2, + ), + ); + } else { + console.log(`Registry: ${loaded.registry_url}`); + console.log(`Origin: ${loaded.origin}` + (loaded.cache_age_ms !== null ? ` (${loaded.cache_age_ms}ms old)` : '')); + console.log(`Updated: ${loaded.catalog.updated_at}`); + console.log(`Skillpacks: ${loaded.catalog.skillpacks.length}`); + const bundleNames = Object.keys(loaded.catalog.bundles ?? {}); + if (bundleNames.length > 0) console.log(`Bundles: ${bundleNames.join(', ')}`); + } + process.exit(0); + } catch (err) { + console.error(`Error: ${(err as Error).message}`); + console.error(`Currently configured registry: ${url}`); + process.exit(2); + } +} + +// --------------------------------------------------------------------------- +// doctor — quality rubric runner +// --------------------------------------------------------------------------- + +async function cmdDoctor(args: string[]): Promise { + if (args.includes('--help') || args.includes('-h')) { + console.log( + 'gbrain skillpack doctor [--quick|--full] [--fix] [--yes] [--json]\n\n' + + ' Path to the skillpack root (where skillpack.json lives)\n' + + ' --quick Structural rubric (~5s, no sandbox/LLM/DB) — default\n' + + ' --full Add publish-gate suite execution (lands in a follow-up wave)\n' + + ' --fix Auto-scaffold missing pieces flagged auto_fixable=true\n' + + ' --yes Skip confirm prompts (CI / unattended)\n' + + ' --json Stable JSON envelope for agent consumption', + ); + process.exit(0); + } + const json = args.includes('--json'); + const fix = args.includes('--fix'); + const yes = args.includes('--yes'); + const mode = args.includes('--full') ? 'full' : 'quick'; + let packDir: string | undefined; + for (const a of args) { + if (a && !a.startsWith('--') && !packDir) packDir = a; + } + if (!packDir) { + console.error('Error: pass the path to the pack root (where skillpack.json lives).'); + process.exit(2); + } + const packRoot = resolveAbs(packDir); + + const { runDoctor, formatDoctorResult } = await import('../core/skillpack/doctor.ts'); + const result = await runDoctor({ packRoot, mode, fix, yes }); + + if (json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(formatDoctorResult(result)); + } + + // Exit codes: 0 if score=10, 1 if 6-9, 2 if blocked/<5. + if (result.tier_eligibility === 'blocked' || result.score < 5) { + process.exit(2); + } + if (result.score < 10) process.exit(1); + process.exit(0); +} + +// --------------------------------------------------------------------------- +// init — publisher scaffold +// --------------------------------------------------------------------------- + +async function cmdInit(args: string[]): Promise { + if (args.includes('--help') || args.includes('-h')) { + console.log( + 'gbrain skillpack init [--target PATH] [--minimal] [--author NAME] [--license SPDX] [--homepage URL] [--dry-run] [--json]\n\n' + + ' Pack name (lowercase kebab; becomes manifest.name + dir leaf)\n' + + ' --target Target dir (default: ./)\n' + + ' --minimal Skip test/, e2e/, evals/ (advanced; doctor will score lower)\n' + + ' --dry-run Report intent, no writes\n' + + ' --json JSON envelope for agent consumption', + ); + process.exit(0); + } + const json = args.includes('--json'); + const minimal = args.includes('--minimal'); + const dryRun = args.includes('--dry-run'); + let name: string | undefined; + let target: string | undefined; + let author: string | undefined; + let license: string | undefined; + let homepage: string | undefined; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === '--target') { + target = args[i + 1]; + i++; + } else if (a === '--author') { + author = args[i + 1]; + i++; + } else if (a === '--license') { + license = args[i + 1]; + i++; + } else if (a === '--homepage') { + homepage = args[i + 1]; + i++; + } else if (a && !a.startsWith('--') && !name) { + name = a; + } + } + if (!name) { + console.error('Error: pass the new skillpack name.'); + process.exit(2); + } + + const { runInitScaffold, InitScaffoldError } = await import('../core/skillpack/init-scaffold.ts'); + const targetDir = resolveAbs(target ?? `./${name}`); + + try { + const result = runInitScaffold({ + targetDir, + name, + minimal, + author, + license, + homepage, + dryRun, + }); + if (json) { + console.log( + JSON.stringify( + { + ok: true, + dry_run: dryRun, + target: result.targetDir, + files_written: result.filesWritten, + files_skipped_existing: result.filesSkippedExisting, + manifest: result.manifest, + }, + null, + 2, + ), + ); + } else { + console.log( + `${dryRun ? 'init (dry-run)' : 'init'}: ${result.filesWritten.length} files written, ${result.filesSkippedExisting.length} skipped (already existed) at ${result.targetDir}`, + ); + if (result.filesSkippedExisting.length > 0 && !dryRun) { + console.log('\nSkipped existing files (preserved):'); + for (const p of result.filesSkippedExisting) console.log(` ${p}`); + } + if (!dryRun) { + console.log( + `\nNext:\n cd ${result.targetDir}\n gbrain skillpack doctor . --quick\n # iterate, then:\n gbrain skillpack pack`, + ); + } + } + process.exit(0); + } catch (err) { + if (err instanceof InitScaffoldError) { + console.error(`skillpack init: ${err.message}`); + process.exit(2); + } + throw err; + } +} + +// --------------------------------------------------------------------------- +// pack — publisher tarball emit + local validation +// --------------------------------------------------------------------------- + +async function cmdPack(args: string[]): Promise { + if (args.includes('--help') || args.includes('-h')) { + console.log( + 'gbrain skillpack pack [] [--out PATH] [--dry-run] [--skip-doctor] [--json]\n\n' + + ' Pack root (default: .)\n' + + ' --out PATH Output dir for the tarball (default: )\n' + + ' --dry-run Validate only, no tarball\n' + + ' --skip-doctor Skip the doctor gate (publish-gate skill uses this)\n' + + ' --json JSON envelope for agent consumption', + ); + process.exit(0); + } + const json = args.includes('--json'); + const dryRun = args.includes('--dry-run'); + const skipDoctor = args.includes('--skip-doctor'); + let packDir: string | undefined; + let outDir: string | undefined; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === '--out') { + outDir = args[i + 1]; + i++; + } else if (a && !a.startsWith('--') && !packDir) { + packDir = a; + } + } + const packRoot = resolveAbs(packDir ?? '.'); + + const { runPackPublish, PackPublishError } = await import('../core/skillpack/pack-publish.ts'); + try { + const result = await runPackPublish({ + packRoot, + outDir: outDir ? resolveAbs(outDir) : undefined, + dryRun, + skipDoctor, + }); + if (json) { + console.log(JSON.stringify(result, null, 2)); + } else if (result.refused_reason) { + console.error(`skillpack pack: refused — ${result.refused_reason}`); + if (result.doctor) { + const blocked = result.doctor.dimensions.filter((d) => !d.passed && d.category === 'core'); + for (const d of blocked) console.error(` ✗ ${d.name}: ${d.detail}`); + } + console.error('\nRun `gbrain skillpack doctor . --fix --yes` to auto-scaffold what you can, then re-run.'); + process.exit(2); + } else if (result.tarball) { + console.log(`pack: ${result.pack_name}@${result.pack_version} -> ${result.tarball.outPath}`); + console.log(` SHA-256: sha256:${result.tarball.sha256}`); + console.log(` File count: ${result.tarball.fileCount}`); + console.log(` Compressed: ${result.tarball.compressedBytes} bytes`); + console.log(` Tier eligible: ${result.tarball.tier_eligibility}`); + } else { + console.log(`pack (dry-run): ${result.pack_name}@${result.pack_version} — doctor verdict ${result.doctor?.tier_eligibility ?? '(skipped)'}`); + } + process.exit(0); + } catch (err) { + if (err instanceof PackPublishError) { + console.error(`skillpack pack: ${err.message}`); + process.exit(2); + } + throw err; + } +} + +// --------------------------------------------------------------------------- +// endorse — Garry-only registry tier override (operator workflow) +// --------------------------------------------------------------------------- + +async function cmdEndorse(args: string[]): Promise { + if (args.includes('--help') || args.includes('-h')) { + console.log( + 'gbrain skillpack endorse [--tier endorsed|community|experimental|dead] [--repo PATH] [--note TEXT] [--push] [--dry-run] [--json]\n\n' + + ' Pack name as it appears in registry.json\n' + + ' --tier Target tier (default: endorsed)\n' + + ' --repo Path to a clone of the registry repo (default: .)\n' + + ' --note Optional human note recorded in endorsements.json\n' + + ' --push git push origin HEAD after committing\n' + + ' --dry-run Report what would change without writing or committing\n' + + ' --json Stable JSON envelope for agent consumption\n\n' + + 'This is the Garry-only operator workflow. It writes endorsements.json + commits;\n' + + 'requires a clone of garrytan/gbrain-skillpack-registry (or any registry-shaped repo).', + ); + process.exit(0); + } + const json = args.includes('--json'); + const dryRun = args.includes('--dry-run'); + const push = args.includes('--push'); + let name: string | undefined; + let tier: 'endorsed' | 'community' | 'experimental' | 'dead' | undefined; + let repo: string | undefined; + let note: string | undefined; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === '--tier') { + tier = args[i + 1] as typeof tier; + i++; + } else if (a === '--repo') { + repo = args[i + 1]; + i++; + } else if (a === '--note') { + note = args[i + 1]; + i++; + } else if (a && !a.startsWith('--') && !name) { + name = a; + } + } + if (!name) { + console.error('Error: pass a skillpack name.'); + process.exit(2); + } + + const { runEndorse, EndorseError } = await import('../core/skillpack/endorse.ts'); + const registryRepoRoot = resolveAbs(repo ?? '.'); + + try { + const result = runEndorse({ + registryRepoRoot, + packName: name, + tier, + note, + push, + dryRun, + }); + if (json) { + console.log(JSON.stringify(result, null, 2)); + } else { + const verb = dryRun ? 'would endorse' : 'endorsed'; + const fromTo = result.prior_tier + ? `${result.prior_tier} -> ${result.new_tier}` + : `(unset) -> ${result.new_tier}`; + console.log(`${verb}: ${result.pack_name} ${fromTo}`); + if (result.commit_sha) console.log(`commit: ${result.commit_sha}`); + if (result.pushed) console.log(`pushed to origin`); + if (dryRun) console.log(`\n(no writes; re-run without --dry-run to commit)`); + } + process.exit(0); + } catch (err) { + if (err instanceof EndorseError) { + console.error(`skillpack endorse: ${err.message}`); + process.exit(2); + } + throw err; + } +} + // --------------------------------------------------------------------------- // harvest // --------------------------------------------------------------------------- diff --git a/src/core/skillpack/audit.ts b/src/core/skillpack/audit.ts new file mode 100644 index 000000000..a2ef1fe9b --- /dev/null +++ b/src/core/skillpack/audit.ts @@ -0,0 +1,137 @@ +/** + * skillpack/audit.ts — JSONL audit log for skillpack lifecycle events. + * + * Pattern mirrors src/core/audit-slug-fallback.ts + src/core/rerank-audit.ts: + * ISO-week-rotated JSONL at `~/.gbrain/audit/skillpack-YYYY-Www.jsonl`. + * + * One line per scaffold / scaffold-third-party / reference-applied / + * doctor-run / search event. Best-effort writes — never throws; failures + * log a stderr warning. + * + * `gbrain doctor`'s skillpack_activity check reads the last 7 days to + * surface "installed N packs in the last week" as info. + */ + +import { appendFileSync, mkdirSync, readFileSync, existsSync, readdirSync, statSync } from 'fs'; +import { dirname, join } from 'path'; + +import { gbrainPath } from '../config.ts'; + +/** Audit event kind. */ +export type SkillpackAuditEventKind = + | 'scaffold_bundled' + | 'scaffold_third_party' + | 'reference_applied' + | 'doctor_run' + | 'search' + | 'registry_refresh'; + +export interface SkillpackAuditEvent { + /** ISO 8601 timestamp. */ + ts: string; + /** What happened. */ + event: SkillpackAuditEventKind; + /** Pack name (when applicable). */ + pack?: string; + /** Pack version (when applicable). */ + version?: string; + /** Source URL / path / kebab name (when applicable). */ + source?: string; + /** Source kind (when applicable). */ + source_kind?: 'git' | 'tarball' | 'local'; + /** Pinned commit / tarball SHA (when applicable). */ + pinned_commit?: string | null; + tarball_sha256?: string | null; + /** Tier at the time of the event. */ + tier?: string; + /** Outcome: 'ok' / 'aborted' / 'error'. */ + outcome: 'ok' | 'aborted' | 'error'; + /** Error summary (when outcome is 'error' or 'aborted'). */ + error?: string; + /** Optional caller-supplied context (e.g. search query). */ + meta?: Record; +} + +/** Compute ISO-week filename (matches the other audit modules). */ +function computeIsoWeekFilename(now: Date = new Date()): string { + // ISO-week algorithm (Thursday-anchored). + const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + const dayNum = d.getUTCDay() === 0 ? 7 : d.getUTCDay(); + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const weekNo = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); + const yyyy = d.getUTCFullYear(); + const ww = String(weekNo).padStart(2, '0'); + return `skillpack-${yyyy}-W${ww}.jsonl`; +} + +/** Resolve the audit directory; honors GBRAIN_AUDIT_DIR. */ +function resolveAuditDir(): string { + const override = process.env.GBRAIN_AUDIT_DIR; + if (override && override.trim()) return override.trim(); + return gbrainPath('audit'); +} + +/** Append an event. Best-effort: stderr warn on failure, never throws. */ +export function logSkillpackEvent(event: Omit): void { + try { + const line: SkillpackAuditEvent = { ts: new Date().toISOString(), ...event }; + const auditDir = resolveAuditDir(); + mkdirSync(auditDir, { recursive: true }); + const file = join(auditDir, computeIsoWeekFilename()); + appendFileSync(file, JSON.stringify(line) + '\n', { encoding: 'utf-8' }); + } catch (err) { + process.stderr.write( + `[skillpack-audit] failed to log event (${(err as Error).message}); continuing\n`, + ); + } +} + +/** Read recent events. Used by `gbrain doctor` for the activity surface. */ +export function readRecentSkillpackEvents(days: number): SkillpackAuditEvent[] { + const auditDir = resolveAuditDir(); + if (!existsSync(auditDir)) return []; + const cutoff = Date.now() - days * 24 * 60 * 60 * 1000; + const events: SkillpackAuditEvent[] = []; + let files: string[]; + try { + files = readdirSync(auditDir).filter((f) => f.startsWith('skillpack-') && f.endsWith('.jsonl')); + } catch { + return []; + } + for (const f of files) { + const fullPath = join(auditDir, f); + try { + const st = statSync(fullPath); + // Skip ancient files outright (older than 14d = certainly fully outside the window). + if (st.mtimeMs < cutoff - 7 * 24 * 60 * 60 * 1000) continue; + } catch { + continue; + } + let content: string; + try { + content = readFileSync(fullPath, 'utf-8'); + } catch { + continue; + } + for (const rawLine of content.split('\n')) { + if (!rawLine.trim()) continue; + try { + const e = JSON.parse(rawLine) as SkillpackAuditEvent; + const t = Date.parse(e.ts); + if (Number.isFinite(t) && t >= cutoff) events.push(e); + } catch { + // Skip malformed lines; never throw out of the read path. + } + } + } + return events.sort((a, b) => Date.parse(a.ts) - Date.parse(b.ts)); +} + +/** Compute the current audit file path (for tests + doctor's hint output). */ +export function currentAuditFilePath(): string { + return join(resolveAuditDir(), computeIsoWeekFilename()); +} + +// Exported for testing. +export const _internal = { computeIsoWeekFilename, resolveAuditDir }; diff --git a/src/core/skillpack/bootstrap-display.ts b/src/core/skillpack/bootstrap-display.ts new file mode 100644 index 000000000..3aab07fc7 --- /dev/null +++ b/src/core/skillpack/bootstrap-display.ts @@ -0,0 +1,83 @@ +/** + * skillpack/bootstrap-display.ts — post-scaffold runbook display. + * + * Codex T1 fix: third-party packs don't auto-execute their install + * runbook. Instead, scaffold drops the files (additively, the v0.36 + * way) and then displays `runbooks/bootstrap.md` if present, framed + * for the calling agent to walk per-step at its own discretion. + * + * No executor. No `agent:` / `show user:` / `ask user:` dispatch. + * Just print the markdown with a header that signals to any agent + * reading the output that these are SUGGESTED steps, not a runnable + * script. The agent (Claude / OpenClaw / etc.) decides whether to + * walk them and how. + * + * Stays pure-data: returns the framed text rather than writing + * directly so tests can assert the shape and callers control the + * output stream. + */ + +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; + +import type { SkillpackManifest } from './manifest-v1.ts'; + +export interface BootstrapDisplayInput { + /** Absolute path to the scaffolded skillpack root (pack cache or local). */ + packRoot: string; + /** Parsed manifest. */ + manifest: SkillpackManifest; + /** Absolute path to the user's workspace where files landed. */ + workspace: string; +} + +export interface BootstrapDisplayResult { + /** True when a bootstrap.md was found AND non-empty. */ + shown: boolean; + /** The framed text the caller writes to stderr/stdout. Empty when shown=false. */ + text: string; + /** Resolved bootstrap.md path (informational). */ + bootstrapPath: string | null; +} + +const FRAME_HEADER = `══════════════════════════════════════════════════════════════════════ + BOOTSTRAP STEPS (read-only — agent decides what to run) +══════════════════════════════════════════════════════════════════════ +These are SUGGESTED next steps from the skillpack author. gbrain +deliberately does NOT auto-execute them — third-party packs run in +trusted-path mode and an automated walker would let a malicious pack +mutate your brain on install. + +Read each step. Run what you understand. Skip what you don't. Use +\`gbrain skillpack reference \` later if you want to see what +the author changed in a new version. +══════════════════════════════════════════════════════════════════════ +`; + +const FRAME_FOOTER = `══════════════════════════════════════════════════════════════════════ +End of bootstrap steps. The skillpack files are already on disk — +nothing above has been executed. +══════════════════════════════════════════════════════════════════════ +`; + +/** + * Build the framed bootstrap output. Pure function — does not write to any + * stream. Returns shown=false when there's no bootstrap.md or it's empty. + */ +export function buildBootstrapDisplay(input: BootstrapDisplayInput): BootstrapDisplayResult { + const relPath = input.manifest.runbooks?.bootstrap; + if (!relPath) { + return { shown: false, text: '', bootstrapPath: null }; + } + const absPath = join(input.packRoot, relPath); + if (!existsSync(absPath)) { + return { shown: false, text: '', bootstrapPath: absPath }; + } + const content = readFileSync(absPath, 'utf-8').trim(); + if (content.length === 0) { + return { shown: false, text: '', bootstrapPath: absPath }; + } + + const text = `${FRAME_HEADER}\n${content}\n\n${FRAME_FOOTER}`; + return { shown: true, text, bootstrapPath: absPath }; +} diff --git a/src/core/skillpack/doctor.ts b/src/core/skillpack/doctor.ts new file mode 100644 index 000000000..6acb94c23 --- /dev/null +++ b/src/core/skillpack/doctor.ts @@ -0,0 +1,363 @@ +/** + * skillpack/doctor.ts — `gbrain skillpack doctor` runner. + * + * Two modes (codex T1 decision: layered doctor, agent picks): + * --quick — structural-only sweep (~5s); walks the rubric, no sandbox + * / no LLM / no DB. Designed for tight iteration loops. + * --full — quick + runs the publish-gate's test + LLM-judge + routing- + * eval suites. Currently delegates the heavy lifting to the + * publish-gate's sandbox path (W5 / W3 in the original spec); + * this v1 doctor only ships --quick. --full prints a hint + * pointing at the publish-gate command once that lands. + * + * --fix — auto-scaffold missing pieces for dimensions flagged + * `auto_fixable: true`. Generates routing-eval.jsonl stubs, + * CHANGELOG entries, bootstrap.md stubs, license file. Refuses + * to overwrite files whose mtime is newer than skillpack.json's + * (heuristic for "hand-edited since last manifest update"). + * + * Returns a structured DoctorResult the CLI formats as either human + * output or stable JSON for agent consumption. + */ + +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs'; +import { join, dirname } from 'path'; + +import { loadSkillpackManifest } from './manifest-v1.ts'; +import { walkRubric, type RubricScore } from './rubric.ts'; +import { logSkillpackEvent } from './audit.ts'; + +export type DoctorMode = 'quick' | 'full'; + +export interface DoctorOptions { + packRoot: string; + mode: DoctorMode; + fix?: boolean; + /** Auto-confirm destructive fixes (CI / unattended use). */ + yes?: boolean; +} + +export interface DoctorResult { + schema_version: 'skillpack-doctor-v1'; + pack_name: string; + pack_version: string; + pack_root: string; + mode: DoctorMode; + score: number; + max_score: number; + tier_eligibility: 'endorsed' | 'community' | 'experimental' | 'blocked'; + promotion_blockers: string[]; + dimensions: Array<{ + id: number; + name: string; + category: 'core' | 'badge'; + description: string; + score: number; + passed: boolean; + detail: string; + fix_hint: string | null; + auto_fixable: boolean; + }>; + fixes_applied: string[]; + full_mode_hint: string | null; +} + +/** Run the doctor. Pure-ish — only reads + (optionally) writes the pack tree. */ +export async function runDoctor(opts: DoctorOptions): Promise { + // Load manifest first; the rubric depends on it. On failure we surface + // dimension 1's error directly without trying to walk the rest. + let manifest; + try { + manifest = loadSkillpackManifest(opts.packRoot); + } catch (err) { + return { + schema_version: 'skillpack-doctor-v1', + pack_name: 'unknown', + pack_version: 'unknown', + pack_root: opts.packRoot, + mode: opts.mode, + score: 0, + max_score: 10, + tier_eligibility: 'blocked', + promotion_blockers: ['manifest_valid'], + dimensions: [ + { + id: 1, + name: 'manifest_valid', + category: 'core', + description: 'skillpack.json passes the v1 schema validator', + score: 0, + passed: false, + detail: (err as Error).message, + fix_hint: + 'Run `gbrain skillpack init ` to regenerate a valid stub manifest, or fix the field listed above.', + auto_fixable: false, + }, + ], + fixes_applied: [], + full_mode_hint: null, + }; + } + + const score = await walkRubric({ packRoot: opts.packRoot, manifest }); + + let fixesApplied: string[] = []; + if (opts.fix) { + fixesApplied = await applyAutoFixes(opts.packRoot, manifest, score, opts.yes ?? false); + // Re-walk after fixes to update the score. + const newScore = await walkRubric({ packRoot: opts.packRoot, manifest }); + return buildResult(opts, manifest, newScore, fixesApplied); + } + + // Log the run for the gbrain doctor activity surface. + logSkillpackEvent({ + event: 'doctor_run', + pack: manifest.name, + version: manifest.version, + outcome: score.tier_eligibility === 'blocked' ? 'error' : 'ok', + meta: { mode: opts.mode, score: score.total, tier: score.tier_eligibility }, + }); + + return buildResult(opts, manifest, score, fixesApplied); +} + +function buildResult( + opts: DoctorOptions, + manifest: ReturnType, + score: RubricScore, + fixesApplied: string[], +): DoctorResult { + return { + schema_version: 'skillpack-doctor-v1', + pack_name: manifest.name, + pack_version: manifest.version, + pack_root: opts.packRoot, + mode: opts.mode, + score: score.total, + max_score: 10, + tier_eligibility: score.tier_eligibility, + promotion_blockers: score.promotion_blockers, + dimensions: score.dimensions.map((d) => ({ + id: d.id, + name: d.name, + category: d.category, + description: d.description, + score: d.passed ? 1 : 0, + passed: d.passed, + detail: d.detail, + fix_hint: d.fix_hint, + auto_fixable: d.auto_fixable, + })), + fixes_applied: fixesApplied, + full_mode_hint: + opts.mode === 'full' + ? '--full mode runs the publish-gate test + LLM-judge + routing-eval suites in a sandbox. Implementation lands in a follow-up wave; for now use `gbrain skillpack test ` once W4 ships.' + : null, + }; +} + +/** + * Apply auto-fixes for any dimension flagged `auto_fixable: true` whose + * `passed` is false. Refuses to overwrite files whose mtime is newer than + * skillpack.json's (the "user hand-edited" heuristic). + */ +async function applyAutoFixes( + packRoot: string, + manifest: ReturnType, + score: RubricScore, + autoYes: boolean, +): Promise { + const fixes: string[] = []; + const manifestPath = join(packRoot, 'skillpack.json'); + const manifestMtime = existsSync(manifestPath) ? statSync(manifestPath).mtimeMs : Date.now(); + + // Plan first; ask for confirm before any write. + const plan: Array<{ name: string; path: string; content: string }> = []; + + for (const dim of score.dimensions) { + if (dim.passed) continue; + if (!dim.auto_fixable) continue; + + switch (dim.name) { + case 'routing_evals_present': { + for (const skillPath of manifest.skills) { + const evalFile = join(packRoot, skillPath, 'routing-eval.jsonl'); + if (existsSync(evalFile)) continue; + // Build a 5-intent stub the publisher edits. + const slug = skillPath.replace(/^skills\//, ''); + const stub = Array.from({ length: 5 }).map((_, i) => ({ + intent: `example intent ${i + 1} for ${slug}`, + expected_skill: slug, + })); + plan.push({ + name: dim.name, + path: evalFile, + content: stub.map((s) => JSON.stringify(s)).join('\n') + '\n', + }); + } + break; + } + case 'changelog_present_and_current': { + const path = join(packRoot, manifest.changelog ?? 'CHANGELOG.md'); + const date = new Date().toISOString().slice(0, 10); + let content = ''; + if (existsSync(path)) { + content = readFileSync(path, 'utf-8'); + const stat = statSync(path); + if (stat.mtimeMs > manifestMtime) { + // Hand-edited; skip. + continue; + } + } else { + content = '# Changelog\n\nAll notable changes documented here.\n\n'; + } + const newEntry = `## [${manifest.version}] - ${date}\n\n- (describe changes)\n\n`; + plan.push({ name: dim.name, path, content: newEntry + content }); + break; + } + case 'unit_tests_present': { + const target = join(packRoot, 'test/example.test.ts'); + if (!existsSync(target)) { + const stub = [ + "import { describe, test, expect } from 'bun:test';", + "", + "describe('example', () => {", + " test('placeholder — replace with real assertions', () => {", + " expect(1 + 1).toBe(2);", + " });", + "});", + "", + ].join('\n'); + plan.push({ name: dim.name, path: target, content: stub }); + } + break; + } + case 'e2e_tests_present': { + const target = join(packRoot, 'e2e/example.e2e.test.ts'); + if (!existsSync(target)) { + const stub = [ + "import { describe, test, expect } from 'bun:test';", + "", + "describe.skipIf(!process.env.DATABASE_URL)('example E2E', () => {", + " test('placeholder — replace with a real integration scenario', () => {", + " expect(process.env.DATABASE_URL).toBeDefined();", + " });", + "});", + "", + ].join('\n'); + plan.push({ name: dim.name, path: target, content: stub }); + } + break; + } + case 'llm_eval_present': { + const target = join(packRoot, 'evals/example.judge.json'); + if (!existsSync(target)) { + const stub = { + task: 'Describe what good output for this skill looks like.', + output: + "{{output-from-skill}} -- the doctor stub. Replace with real example output your skill produces.", + cases: [ + { name: 'happy path', criteria: 'output satisfies the task' }, + { name: 'edge case', criteria: 'output handles a corner input gracefully' }, + { name: 'failure mode', criteria: 'output refuses gracefully when input is ambiguous' }, + ], + }; + plan.push({ name: dim.name, path: target, content: JSON.stringify(stub, null, 2) + '\n' }); + } + break; + } + case 'bootstrap_runbook_present': { + const path = join(packRoot, manifest.runbooks?.bootstrap ?? 'runbooks/bootstrap.md'); + if (!existsSync(path)) { + const stub = [ + '# Bootstrap', + '', + '1. show user: " is installed. Try one of the trigger phrases listed in skills/."', + '2. (edit me) agent: gbrain put_page wiki/_bootstrap-stub --frontmatter type=stub', + '', + '', + '', + ].join('\n'); + plan.push({ name: dim.name, path, content: stub }); + } + break; + } + case 'license_present': { + const target = join(packRoot, 'LICENSE'); + if (!existsSync(target)) { + const stub = `${manifest.license} License — replace with full license text matching the SPDX id declared in skillpack.json.\n`; + plan.push({ name: dim.name, path: target, content: stub }); + } + break; + } + } + } + + if (plan.length === 0) return []; + + if (!autoYes) { + process.stderr.write(`\n[skillpack doctor --fix] About to create the following files:\n`); + for (const p of plan) { + process.stderr.write(` ${p.path} (${p.name})\n`); + } + process.stderr.write(`\nNo TTY confirm available in this build; pass --yes to apply.\n`); + return []; + } + + for (const p of plan) { + mkdirSync(dirname(p.path), { recursive: true }); + writeFileSync(p.path, p.content); + fixes.push(`${p.name}: created ${p.path}`); + } + return fixes; +} + +/** Render the doctor result as human-readable text. */ +export function formatDoctorResult(result: DoctorResult): string { + const tierBadge = + result.tier_eligibility === 'endorsed' + ? '★' + : result.tier_eligibility === 'community' + ? '·' + : result.tier_eligibility === 'experimental' + ? '?' + : '✗'; + const lines: string[] = []; + lines.push( + `${tierBadge} ${result.pack_name}@${result.pack_version} ${result.score}/${result.max_score} [${result.tier_eligibility}]`, + ); + lines.push(`Pack root: ${result.pack_root}`); + lines.push(`Mode: ${result.mode}`); + lines.push(''); + lines.push('Core (must all pass to publish):'); + for (const d of result.dimensions.filter((dd) => dd.category === 'core')) { + lines.push( + ` ${d.passed ? '✓' : '✗'} ${d.id}. ${d.name}` + (d.passed ? '' : ` — ${d.detail}`), + ); + if (!d.passed && d.fix_hint) lines.push(` fix: ${d.fix_hint}`); + } + lines.push(''); + lines.push('Quality badges (earn for tier eligibility):'); + for (const d of result.dimensions.filter((dd) => dd.category === 'badge')) { + lines.push( + ` ${d.passed ? '✓' : '✗'} ${d.id}. ${d.name}` + + (d.passed ? '' : ` — ${d.detail}`) + + (d.auto_fixable && !d.passed ? ' [auto-fixable]' : ''), + ); + if (!d.passed && d.fix_hint) lines.push(` fix: ${d.fix_hint}`); + } + if (result.promotion_blockers.length > 0) { + lines.push(''); + lines.push(`To reach the next tier, address: ${result.promotion_blockers.join(', ')}`); + } + if (result.fixes_applied.length > 0) { + lines.push(''); + lines.push(`Auto-fixes applied (${result.fixes_applied.length}):`); + for (const f of result.fixes_applied) lines.push(` + ${f}`); + } + if (result.full_mode_hint) { + lines.push(''); + lines.push(`Note: ${result.full_mode_hint}`); + } + return lines.join('\n'); +} diff --git a/src/core/skillpack/endorse.ts b/src/core/skillpack/endorse.ts new file mode 100644 index 000000000..999e65d78 --- /dev/null +++ b/src/core/skillpack/endorse.ts @@ -0,0 +1,226 @@ +/** + * skillpack/endorse.ts — Garry-only endorsement workflow for the registry. + * + * Operator runs `gbrain skillpack endorse [--tier T]` inside a clone + * of `garrytan/gbrain-skillpack-registry`. The CLI: + * 1. Validates `` exists in registry.json + * 2. Reads + schema-validates endorsements.json + * 3. Sets/clears the tier entry (community → endorsed is the common path) + * 4. Writes back with stable key ordering so diffs are clean + * 5. Stages + creates a one-line commit `endorse: -> ` + * 6. Optionally pushes (--push) + * + * Pure-data shape lives here; the CLI wrapper in src/commands/skillpack.ts + * handles user-facing argv parsing + git invocations. + */ + +import { execFileSync } from 'child_process'; +import { existsSync, readFileSync, renameSync, writeFileSync } from 'fs'; +import { join } from 'path'; + +import { + ENDORSEMENTS_SCHEMA_VERSION, + REGISTRY_SCHEMA_VERSION, + RegistrySchemaError, + validateEndorsementsFile, + validateRegistryCatalog, + type EndorsementsFile, + type RegistryTier, +} from './registry-schema.ts'; + +export interface EndorseOptions { + /** Absolute path to a clone of the registry repo. */ + registryRepoRoot: string; + /** Pack name to endorse / change tier on. */ + packName: string; + /** Target tier. Defaults to 'endorsed' since that's the common move. */ + tier?: RegistryTier; + /** Optional human note recorded alongside the endorsement. */ + note?: string; + /** Dry-run: report what would change without writing or committing. */ + dryRun?: boolean; + /** Push the commit to origin/main after writing. */ + push?: boolean; +} + +export interface EndorseResult { + schema_version: 'skillpack-endorse-v1'; + pack_name: string; + prior_tier: RegistryTier | null; + new_tier: RegistryTier; + endorsements_path: string; + commit_sha: string | null; + pushed: boolean; + dry_run: boolean; +} + +export type EndorseErrorCode = + | 'not_a_registry_repo' + | 'pack_not_in_catalog' + | 'git_commit_failed' + | 'git_push_failed'; + +export class EndorseError extends Error { + constructor( + message: string, + public code: EndorseErrorCode, + ) { + super(message); + this.name = 'EndorseError'; + } +} + +/** Verify the directory looks like a skillpack-registry repo. */ +function assertRegistryRepo(root: string): void { + const reg = join(root, 'registry.json'); + if (!existsSync(reg)) { + throw new EndorseError( + `${root} does not look like a skillpack-registry repo (no registry.json at root)`, + 'not_a_registry_repo', + ); + } + try { + const raw = JSON.parse(readFileSync(reg, 'utf-8')); + validateRegistryCatalog(raw); + } catch (err) { + if (err instanceof RegistrySchemaError) { + throw new EndorseError( + `${root}/registry.json is malformed: ${err.message}`, + 'not_a_registry_repo', + ); + } + throw err; + } +} + +/** Stable JSON.stringify with sorted keys at every depth. */ +function stableStringify(value: unknown, indent = 2): string { + return JSON.stringify(value, sortReplacer, indent) + '\n'; +} + +function sortReplacer(_key: string, val: unknown): unknown { + if (val === null || typeof val !== 'object' || Array.isArray(val)) return val; + const sorted: Record = {}; + for (const k of Object.keys(val as Record).sort()) { + sorted[k] = (val as Record)[k]; + } + return sorted; +} + +/** Pure-fn that mutates the parsed endorsements object. */ +export function applyEndorsement( + current: EndorsementsFile, + packName: string, + tier: RegistryTier, + note?: string, +): { next: EndorsementsFile; prior_tier: RegistryTier | null } { + const prior = current.endorsements[packName]?.tier ?? null; + const next: EndorsementsFile = { + schema_version: ENDORSEMENTS_SCHEMA_VERSION, + endorsements: { + ...current.endorsements, + [packName]: { + tier, + endorsed_at: new Date().toISOString(), + ...(note ? { note } : {}), + }, + }, + }; + return { next, prior_tier: prior }; +} + +/** + * Run the full endorse flow: validate -> mutate -> write atomically -> + * git stage + commit -> optionally push. Returns a structured result the + * CLI formats. + */ +export function runEndorse(opts: EndorseOptions): EndorseResult { + assertRegistryRepo(opts.registryRepoRoot); + + // Catalog membership check. + const catalogRaw = JSON.parse(readFileSync(join(opts.registryRepoRoot, 'registry.json'), 'utf-8')); + const catalog = validateRegistryCatalog(catalogRaw); + if (!catalog.skillpacks.some((e) => e.name === opts.packName)) { + throw new EndorseError( + `pack "${opts.packName}" is not in registry.json — endorse requires a catalog entry first`, + 'pack_not_in_catalog', + ); + } + + // Endorsements file (may be missing on a fresh registry). + const endPath = join(opts.registryRepoRoot, 'endorsements.json'); + let current: EndorsementsFile; + if (existsSync(endPath)) { + current = validateEndorsementsFile(JSON.parse(readFileSync(endPath, 'utf-8'))); + } else { + current = { schema_version: ENDORSEMENTS_SCHEMA_VERSION, endorsements: {} }; + } + + const tier = opts.tier ?? 'endorsed'; + const { next, prior_tier } = applyEndorsement(current, opts.packName, tier, opts.note); + + if (opts.dryRun) { + return { + schema_version: 'skillpack-endorse-v1', + pack_name: opts.packName, + prior_tier, + new_tier: tier, + endorsements_path: endPath, + commit_sha: null, + pushed: false, + dry_run: true, + }; + } + + // Atomic write via .tmp + rename. + const tmp = endPath + '.tmp'; + writeFileSync(tmp, stableStringify(next)); + renameSync(tmp, endPath); + + // git stage + commit. + let commitSha: string | null = null; + try { + execFileSync('git', ['-C', opts.registryRepoRoot, 'add', 'endorsements.json'], { + encoding: 'utf-8', + }); + execFileSync( + 'git', + ['-C', opts.registryRepoRoot, 'commit', '-m', `endorse: ${opts.packName} -> ${tier}`], + { encoding: 'utf-8' }, + ); + commitSha = execFileSync('git', ['-C', opts.registryRepoRoot, 'rev-parse', '--short', 'HEAD'], { + encoding: 'utf-8', + }).trim(); + } catch (err) { + throw new EndorseError( + `git commit failed: ${(err as Error).message}`, + 'git_commit_failed', + ); + } + + let pushed = false; + if (opts.push) { + try { + execFileSync('git', ['-C', opts.registryRepoRoot, 'push', 'origin', 'HEAD'], { + encoding: 'utf-8', + }); + pushed = true; + } catch (err) { + throw new EndorseError( + `git push failed: ${(err as Error).message}`, + 'git_push_failed', + ); + } + } + + return { + schema_version: 'skillpack-endorse-v1', + pack_name: opts.packName, + prior_tier, + new_tier: tier, + endorsements_path: endPath, + commit_sha: commitSha, + pushed, + dry_run: false, + }; +} diff --git a/src/core/skillpack/init-scaffold.ts b/src/core/skillpack/init-scaffold.ts new file mode 100644 index 000000000..bb5135ee5 --- /dev/null +++ b/src/core/skillpack/init-scaffold.ts @@ -0,0 +1,271 @@ +/** + * skillpack/init-scaffold.ts — `gbrain skillpack init ` scaffold. + * + * Cathedral default per codex T4 + DX-Round-2: lands a complete 10/10 + * pack tree out of the box. Publisher edits or deletes what they don't + * need; `gbrain skillpack doctor --quick` on a freshly-init'd pack + * passes 10/10 immediately. + * + * `--minimal` flag drops test/, e2e/, evals/ for power users who + * explicitly opt out. + * + * Refuses to overwrite any existing file — same contract as v0.36's + * scaffold command. + */ + +import { existsSync, mkdirSync, writeFileSync } from 'fs'; +import { join } from 'path'; + +import { SKILLPACK_API_VERSION, type SkillpackManifest } from './manifest-v1.ts'; + +export interface InitScaffoldOptions { + /** Target directory (created if missing). Becomes the pack root. */ + targetDir: string; + /** Pack name (lowercase kebab; becomes manifest.name). */ + name: string; + /** Skip test/, e2e/, evals/ for power users. */ + minimal?: boolean; + /** Optional initial skill slug (default: ). */ + firstSkillSlug?: string; + /** Pre-fill author + license + homepage. */ + author?: string; + license?: string; + homepage?: string; + /** Dry-run: report intent without writing. */ + dryRun?: boolean; +} + +export interface InitScaffoldResult { + targetDir: string; + filesWritten: string[]; + filesSkippedExisting: string[]; + manifest: SkillpackManifest; +} + +export class InitScaffoldError extends Error { + constructor( + message: string, + public code: 'invalid_name' | 'target_exists_not_empty', + ) { + super(message); + this.name = 'InitScaffoldError'; + } +} + +const NAME_RE = /^[a-z][a-z0-9-]{1,63}$/; + +/** Build the cathedral scaffold tree. */ +export function runInitScaffold(opts: InitScaffoldOptions): InitScaffoldResult { + if (!NAME_RE.test(opts.name)) { + throw new InitScaffoldError( + `name "${opts.name}" is not lowercase kebab-case (must match ${NAME_RE.source})`, + 'invalid_name', + ); + } + + const firstSlug = opts.firstSkillSlug ?? opts.name; + if (!NAME_RE.test(firstSlug)) { + throw new InitScaffoldError( + `first-skill slug "${firstSlug}" is not lowercase kebab-case`, + 'invalid_name', + ); + } + + const manifest: SkillpackManifest = { + api_version: SKILLPACK_API_VERSION, + name: opts.name, + version: '0.1.0', + description: `(edit me) one-line description of the ${opts.name} skillpack`, + author: opts.author ?? 'Your Name ', + license: opts.license ?? 'MIT', + homepage: opts.homepage ?? `https://github.com/your-user/skillpack-${opts.name}`, + gbrain_min_version: '0.36.0', + skills: [`skills/${firstSlug}`], + runbooks: { bootstrap: 'runbooks/bootstrap.md' }, + changelog: 'CHANGELOG.md', + }; + + if (!opts.minimal) { + manifest.unit_tests = ['test/**/*.test.ts']; + manifest.e2e_tests = ['e2e/**/*.test.ts']; + manifest.llm_evals = ['evals/*.judge.json']; + manifest.routing_evals = [`skills/${firstSlug}/routing-eval.jsonl`]; + } else { + manifest.routing_evals = [`skills/${firstSlug}/routing-eval.jsonl`]; + } + + // Plan the writes. + const plan: Array<{ path: string; content: string }> = []; + const dateIso = new Date().toISOString().slice(0, 10); + + plan.push({ + path: join(opts.targetDir, 'skillpack.json'), + content: JSON.stringify(manifest, null, 2) + '\n', + }); + + plan.push({ + path: join(opts.targetDir, `skills/${firstSlug}/SKILL.md`), + content: [ + '---', + `name: ${firstSlug}`, + `description: (edit me) one-line description of what ${firstSlug} does`, + 'mutating: false', + 'triggers:', + ` - example trigger phrase 1 for ${firstSlug}`, + ` - example trigger phrase 2 for ${firstSlug}`, + '---', + '', + `# ${firstSlug}`, + '', + '(edit me) Markdown body describing what the skill does, what tools it uses,', + 'and the user-facing contract. Agents read this top-to-bottom when the user', + 'phrasing matches one of the `triggers:` above.', + '', + ].join('\n'), + }); + + // 5 routing-eval intents to clear dimension 3. + const intents = [ + { intent: `example phrase 1 for ${firstSlug}`, expected_skill: firstSlug }, + { intent: `example phrase 2 for ${firstSlug}`, expected_skill: firstSlug }, + { intent: `example phrase 3 for ${firstSlug}`, expected_skill: firstSlug }, + { intent: `example phrase 4 for ${firstSlug}`, expected_skill: firstSlug }, + { intent: `example phrase 5 for ${firstSlug}`, expected_skill: firstSlug }, + ]; + plan.push({ + path: join(opts.targetDir, `skills/${firstSlug}/routing-eval.jsonl`), + content: intents.map((x) => JSON.stringify(x)).join('\n') + '\n', + }); + + plan.push({ + path: join(opts.targetDir, 'runbooks/bootstrap.md'), + content: [ + '# Bootstrap', + '', + 'Post-scaffold steps. gbrain displays this but does NOT auto-execute.', + 'The agent reads it and walks per-step at its own discretion.', + '', + `1. show user: "${opts.name} is installed. Try one of the trigger phrases from skills/${firstSlug}/SKILL.md."`, + `2. (edit me) agent: gbrain put_page wiki/_${opts.name}-config --frontmatter type=config`, + '', + ].join('\n'), + }); + + plan.push({ + path: join(opts.targetDir, 'CHANGELOG.md'), + content: [ + '# Changelog', + '', + 'All notable changes documented in Keep-a-Changelog shape.', + '', + `## [0.1.0] - ${dateIso}`, + '', + '- Initial release.', + '', + ].join('\n'), + }); + + plan.push({ + path: join(opts.targetDir, 'README.md'), + content: [ + `# ${opts.name}`, + '', + `${manifest.description}`, + '', + '## Install', + '', + '```bash', + `gbrain skillpack scaffold your-user/skillpack-${opts.name}`, + '```', + '', + '## What it does', + '', + '(edit me) Explain what the pack adds to the user\'s agent.', + '', + '## Skills', + '', + `- \`skills/${firstSlug}/\` — (edit me) one-line description`, + '', + ].join('\n'), + }); + + plan.push({ + path: join(opts.targetDir, 'LICENSE'), + content: `${manifest.license} License\n\n(edit me) Replace with the full license text matching the SPDX id above.\n`, + }); + + plan.push({ + path: join(opts.targetDir, '.gitignore'), + content: ['node_modules/', '.DS_Store', '*.tgz', ''].join('\n'), + }); + + if (!opts.minimal) { + plan.push({ + path: join(opts.targetDir, 'test/example.test.ts'), + content: [ + "import { describe, test, expect } from 'bun:test';", + '', + "describe('example unit test', () => {", + " test('placeholder — replace with real assertions', () => {", + " expect(1 + 1).toBe(2);", + " });", + '});', + '', + ].join('\n'), + }); + + plan.push({ + path: join(opts.targetDir, 'e2e/example.e2e.test.ts'), + content: [ + "import { describe, test, expect } from 'bun:test';", + '', + "describe.skipIf(!process.env.DATABASE_URL)('example E2E test', () => {", + " test('placeholder — replace with a real integration scenario', () => {", + " expect(process.env.DATABASE_URL).toBeDefined();", + " });", + '});', + '', + ].join('\n'), + }); + + plan.push({ + path: join(opts.targetDir, `evals/${opts.name}.judge.json`), + content: + JSON.stringify( + { + task: `(edit me) Describe the task this LLM-judge eval scores ${opts.name} against.`, + output: '{{output-from-skill}}', + cases: [ + { name: 'happy path', criteria: 'output satisfies the task' }, + { name: 'edge case', criteria: 'output handles a corner input gracefully' }, + { name: 'failure mode', criteria: 'output refuses gracefully on ambiguous input' }, + ], + }, + null, + 2, + ) + '\n', + }); + } + + // Apply plan. + const written: string[] = []; + const skipped: string[] = []; + for (const p of plan) { + if (existsSync(p.path)) { + skipped.push(p.path); + continue; + } + if (!opts.dryRun) { + mkdirSync(join(p.path, '..'), { recursive: true }); + writeFileSync(p.path, p.content); + } + written.push(p.path); + } + + return { + targetDir: opts.targetDir, + filesWritten: written, + filesSkippedExisting: skipped, + manifest, + }; +} diff --git a/src/core/skillpack/manifest-v1.ts b/src/core/skillpack/manifest-v1.ts new file mode 100644 index 000000000..2a1320a98 --- /dev/null +++ b/src/core/skillpack/manifest-v1.ts @@ -0,0 +1,359 @@ +/** + * skillpack/manifest-v1.ts — third-party skillpack.json validator. + * + * Third-party packs declare a `skillpack.json` at their repo root. + * Schema is `gbrain-skillpack-v1` plus forward-compat extensions for + * runbook + eval schema evolution. + * + * Shape is a SUPERSET of `BundleManifest` (bundle.ts) so the v0.36 + * scaffold + reference pipelines (which already iterate + * `enumerateScaffoldEntries`) can consume third-party packs via the + * adapter `bundleManifestFromSkillpack()` without forking the + * enumeration code. + * + * Pure validator — no I/O beyond a single readFileSync at the entry + * point. Throws `SkillpackManifestError` on every failure with a + * structured code + path so the publish-gate and doctor can both + * format actionable messages. + */ + +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; + +import type { BundleManifest } from './bundle.ts'; + +/** Current manifest API version. */ +export const SKILLPACK_API_VERSION = 'gbrain-skillpack-v1' as const; + +/** Current runbook schema version. */ +export const RUNBOOK_SCHEMA_VERSION = 1 as const; + +/** Current eval schema version. */ +export const EVAL_SCHEMA_VERSION = 1 as const; + +/** Third-party skillpack manifest. */ +export interface SkillpackManifest { + /** Forward-compat tag — installer refuses unknown values. */ + api_version: typeof SKILLPACK_API_VERSION; + /** Package name (must match repo directory; unique in registry namespace). */ + name: string; + /** Semver-ish version string (Keep-a-Changelog compatible). */ + version: string; + /** One-line description, shown by `gbrain skillpack info`. */ + description: string; + /** Author name (display name optionally with email; not parsed). */ + author: string; + /** SPDX license id (e.g. "MIT"). */ + license: string; + /** Homepage URL (canonical source repo). */ + homepage: string; + /** Minimum gbrain version this pack requires (semver). */ + gbrain_min_version: string; + /** Runbook format schema version (default 1). */ + runbook_schema_version?: number; + /** Eval format schema version (default 1). */ + eval_schema_version?: number; + + /** Skill directories relative to pack root (e.g. ["skills/judge-submission"]). */ + skills: string[]; + /** Shared deps (files + dirs every skill in the pack depends on). */ + shared_deps?: string[]; + /** Skills bundled but not installed by default (rare; matches BundleManifest). */ + excluded_from_install?: string[]; + + /** Glob(s) for unit tests run by doctor --full and publish-gate. */ + unit_tests?: string[]; + /** Glob(s) for E2E tests (skipped when no DATABASE_URL). */ + e2e_tests?: string[]; + /** Glob(s) for LLM-judge eval configs (cross-modal-eval shape). */ + llm_evals?: string[]; + /** Glob(s) for routing-eval.jsonl files. */ + routing_evals?: string[]; + + /** Runbook paths the scaffolder displays post-scaffold. */ + runbooks?: { + /** Path to bootstrap.md (per-step checklist printed after scaffold). */ + bootstrap?: string; + }; + + /** Path to CHANGELOG.md. */ + changelog?: string; +} + +/** Structured error code surface. */ +export type SkillpackManifestErrorCode = + | 'manifest_not_found' + | 'manifest_malformed_json' + | 'manifest_missing_field' + | 'manifest_invalid_field' + | 'manifest_unknown_api_version' + | 'manifest_unsupported_schema_version' + | 'manifest_skill_not_found'; + +export class SkillpackManifestError extends Error { + constructor( + message: string, + public code: SkillpackManifestErrorCode, + public detail?: { field?: string; expected?: string; actual?: unknown }, + ) { + super(message); + this.name = 'SkillpackManifestError'; + } +} + +const REQUIRED_FIELDS = [ + 'api_version', + 'name', + 'version', + 'description', + 'author', + 'license', + 'homepage', + 'gbrain_min_version', + 'skills', +] as const; + +const NAME_RE = /^[a-z][a-z0-9-]{1,63}$/; +const SEMVER_RE = /^\d+\.\d+\.\d+(?:\.\d+)?(?:-[A-Za-z0-9._-]+)?$/; + +/** + * Validate a parsed JSON object as a SkillpackManifest. Pure function; + * no I/O. Used by `loadSkillpackManifest` and directly by the publish-gate + * when validating in-memory manifests. + */ +export function validateSkillpackManifest( + raw: unknown, + opts: { maxRunbookSchemaVersion?: number; maxEvalSchemaVersion?: number } = {}, +): SkillpackManifest { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + throw new SkillpackManifestError( + 'skillpack.json must be a JSON object at the top level', + 'manifest_malformed_json', + ); + } + const obj = raw as Record; + + for (const field of REQUIRED_FIELDS) { + if (!(field in obj)) { + throw new SkillpackManifestError( + `skillpack.json is missing required field: ${field}`, + 'manifest_missing_field', + { field }, + ); + } + } + + if (obj.api_version !== SKILLPACK_API_VERSION) { + throw new SkillpackManifestError( + `skillpack.json api_version must be "${SKILLPACK_API_VERSION}"; got ${JSON.stringify(obj.api_version)}`, + 'manifest_unknown_api_version', + { field: 'api_version', expected: SKILLPACK_API_VERSION, actual: obj.api_version }, + ); + } + + if (typeof obj.name !== 'string' || !NAME_RE.test(obj.name)) { + throw new SkillpackManifestError( + `name must be a lowercase kebab-case string (2-64 chars, [a-z0-9-], leading alpha); got ${JSON.stringify(obj.name)}`, + 'manifest_invalid_field', + { field: 'name', expected: NAME_RE.source, actual: obj.name }, + ); + } + + if (typeof obj.version !== 'string' || !SEMVER_RE.test(obj.version)) { + throw new SkillpackManifestError( + `version must be semver shape (e.g. "0.1.0" or "0.1.0.1"); got ${JSON.stringify(obj.version)}`, + 'manifest_invalid_field', + { field: 'version', expected: SEMVER_RE.source, actual: obj.version }, + ); + } + + for (const field of ['description', 'author', 'license', 'homepage', 'gbrain_min_version']) { + const value = obj[field]; + if (typeof value !== 'string' || value.length === 0) { + throw new SkillpackManifestError( + `${field} must be a non-empty string`, + 'manifest_invalid_field', + { field, actual: value }, + ); + } + } + + if (typeof obj.homepage === 'string' && !/^https?:\/\//.test(obj.homepage)) { + throw new SkillpackManifestError( + `homepage must be an http(s) URL; got ${obj.homepage}`, + 'manifest_invalid_field', + { field: 'homepage', actual: obj.homepage }, + ); + } + + if (!SEMVER_RE.test(obj.gbrain_min_version as string)) { + throw new SkillpackManifestError( + `gbrain_min_version must be semver shape (e.g. "0.36.0"); got ${JSON.stringify(obj.gbrain_min_version)}`, + 'manifest_invalid_field', + { field: 'gbrain_min_version', expected: SEMVER_RE.source, actual: obj.gbrain_min_version }, + ); + } + + if (!Array.isArray(obj.skills) || obj.skills.length === 0) { + throw new SkillpackManifestError( + `skills must be a non-empty array of relative paths (e.g. ["skills/foo"])`, + 'manifest_invalid_field', + { field: 'skills', actual: obj.skills }, + ); + } + for (const skillPath of obj.skills) { + if (typeof skillPath !== 'string' || !skillPath.startsWith('skills/') || skillPath.includes('..')) { + throw new SkillpackManifestError( + `skills entries must be relative paths starting with "skills/" and free of ".." traversal; got ${JSON.stringify(skillPath)}`, + 'manifest_invalid_field', + { field: 'skills', actual: skillPath }, + ); + } + } + + // Optional fields — type check only when present. + for (const arrField of [ + 'shared_deps', + 'excluded_from_install', + 'unit_tests', + 'e2e_tests', + 'llm_evals', + 'routing_evals', + ]) { + if (arrField in obj) { + const v = obj[arrField]; + if (!Array.isArray(v) || !v.every((x) => typeof x === 'string')) { + throw new SkillpackManifestError( + `${arrField}, if present, must be an array of strings`, + 'manifest_invalid_field', + { field: arrField, actual: v }, + ); + } + } + } + + if (obj.runbooks !== undefined) { + if (typeof obj.runbooks !== 'object' || obj.runbooks === null || Array.isArray(obj.runbooks)) { + throw new SkillpackManifestError( + `runbooks, if present, must be an object`, + 'manifest_invalid_field', + { field: 'runbooks', actual: obj.runbooks }, + ); + } + const runbookObj = obj.runbooks as Record; + if (runbookObj.bootstrap !== undefined && typeof runbookObj.bootstrap !== 'string') { + throw new SkillpackManifestError( + `runbooks.bootstrap must be a string path`, + 'manifest_invalid_field', + { field: 'runbooks.bootstrap', actual: runbookObj.bootstrap }, + ); + } + } + + if (obj.changelog !== undefined && typeof obj.changelog !== 'string') { + throw new SkillpackManifestError( + `changelog must be a string path`, + 'manifest_invalid_field', + { field: 'changelog', actual: obj.changelog }, + ); + } + + // Schema-version forward-compat (codex outside-voice gap). + const maxRunbook = opts.maxRunbookSchemaVersion ?? RUNBOOK_SCHEMA_VERSION; + const maxEval = opts.maxEvalSchemaVersion ?? EVAL_SCHEMA_VERSION; + if (obj.runbook_schema_version !== undefined) { + const v = obj.runbook_schema_version; + if (typeof v !== 'number' || !Number.isInteger(v) || v < 1) { + throw new SkillpackManifestError( + `runbook_schema_version must be a positive integer`, + 'manifest_invalid_field', + { field: 'runbook_schema_version', actual: v }, + ); + } + if (v > maxRunbook) { + throw new SkillpackManifestError( + `runbook_schema_version ${v} exceeds maximum supported (${maxRunbook}). Run \`gbrain upgrade\``, + 'manifest_unsupported_schema_version', + { field: 'runbook_schema_version', expected: `<= ${maxRunbook}`, actual: v }, + ); + } + } + if (obj.eval_schema_version !== undefined) { + const v = obj.eval_schema_version; + if (typeof v !== 'number' || !Number.isInteger(v) || v < 1) { + throw new SkillpackManifestError( + `eval_schema_version must be a positive integer`, + 'manifest_invalid_field', + { field: 'eval_schema_version', actual: v }, + ); + } + if (v > maxEval) { + throw new SkillpackManifestError( + `eval_schema_version ${v} exceeds maximum supported (${maxEval}). Run \`gbrain upgrade\``, + 'manifest_unsupported_schema_version', + { field: 'eval_schema_version', expected: `<= ${maxEval}`, actual: v }, + ); + } + } + + return obj as unknown as SkillpackManifest; +} + +/** + * Load + validate `skillpack.json` from a pack root. Throws + * `SkillpackManifestError` on missing file, malformed JSON, schema violation, + * unknown api_version, or skill directory missing on disk. + */ +export function loadSkillpackManifest(packRoot: string): SkillpackManifest { + const manifestPath = join(packRoot, 'skillpack.json'); + if (!existsSync(manifestPath)) { + throw new SkillpackManifestError( + `skillpack.json not found at ${manifestPath}`, + 'manifest_not_found', + ); + } + + let raw: unknown; + try { + raw = JSON.parse(readFileSync(manifestPath, 'utf-8')); + } catch (err) { + throw new SkillpackManifestError( + `skillpack.json is not valid JSON: ${(err as Error).message}`, + 'manifest_malformed_json', + ); + } + + const manifest = validateSkillpackManifest(raw); + + // Verify every declared skill directory exists on disk. + for (const skillPath of manifest.skills) { + const abs = join(packRoot, skillPath); + if (!existsSync(abs)) { + throw new SkillpackManifestError( + `skillpack.json declares skill "${skillPath}" but ${abs} does not exist`, + 'manifest_skill_not_found', + { field: 'skills', actual: skillPath }, + ); + } + } + + return manifest; +} + +/** + * Adapter: project a SkillpackManifest onto the existing BundleManifest + * shape so v0.36's `enumerateScaffoldEntries` + `loadSkillSources` paths + * iterate third-party packs without any changes. The third-party fields + * (unit_tests, llm_evals, runbooks, etc.) live on SkillpackManifest only; + * the bundle iteration doesn't need them. + */ +export function bundleManifestFromSkillpack(pack: SkillpackManifest): BundleManifest { + return { + name: pack.name, + version: pack.version, + description: pack.description, + skills: pack.skills, + shared_deps: pack.shared_deps ?? [], + excluded_from_install: pack.excluded_from_install, + }; +} diff --git a/src/core/skillpack/pack-publish.ts b/src/core/skillpack/pack-publish.ts new file mode 100644 index 000000000..711c4cabb --- /dev/null +++ b/src/core/skillpack/pack-publish.ts @@ -0,0 +1,141 @@ +/** + * skillpack/pack-publish.ts — `gbrain skillpack pack` orchestrator. + * + * Runs the publisher's local validation + deterministic tarball emit: + * 1. runDoctor(--quick) over the pack root; refuse if tier_eligibility + * is `blocked` (any core dim failing). + * 2. packTarball into /-.tgz with deterministic + * flags. Computes + reports SHA-256. + * 3. Returns a structured result the CLI consumes. + * + * --dry-run runs the doctor only and skips the tarball step. --skip-doctor + * is the escape hatch for the publish-gate skill which already runs the + * doctor server-side. Validation results are persisted into the audit + * log so the publish-gate skill can read the local-run history. + */ + +import { mkdirSync } from 'fs'; +import { join } from 'path'; + +import { logSkillpackEvent } from './audit.ts'; +import { runDoctor, type DoctorResult } from './doctor.ts'; +import { loadSkillpackManifest } from './manifest-v1.ts'; +import { packTarball, type TarballPackResult } from './tarball.ts'; + +export interface PackPublishOptions { + /** Absolute path to the pack root. */ + packRoot: string; + /** Output directory for the tarball (default: ). */ + outDir?: string; + /** Skip the doctor gate (publish-gate skill uses this; the gate runs server-side). */ + skipDoctor?: boolean; + /** Dry-run: validate only, no tarball. */ + dryRun?: boolean; +} + +export interface PackPublishResult { + schema_version: 'skillpack-pack-v1'; + pack_name: string; + pack_version: string; + doctor: DoctorResult | null; + tarball: (TarballPackResult & { tier_eligibility: string }) | null; + refused_reason: string | null; +} + +export class PackPublishError extends Error { + constructor( + message: string, + public code: 'doctor_blocked' | 'manifest_load_failed' | 'pack_failed', + ) { + super(message); + this.name = 'PackPublishError'; + } +} + +export async function runPackPublish(opts: PackPublishOptions): Promise { + let manifest; + try { + manifest = loadSkillpackManifest(opts.packRoot); + } catch (err) { + throw new PackPublishError( + `Failed to load skillpack.json: ${(err as Error).message}`, + 'manifest_load_failed', + ); + } + + let doctor: DoctorResult | null = null; + if (!opts.skipDoctor) { + doctor = await runDoctor({ packRoot: opts.packRoot, mode: 'quick' }); + if (doctor.tier_eligibility === 'blocked') { + // Audit the refusal. + logSkillpackEvent({ + event: 'doctor_run', + pack: manifest.name, + version: manifest.version, + outcome: 'error', + error: `pack refused: ${doctor.promotion_blockers.join(', ')}`, + meta: { mode: 'pack-publish-gate', score: doctor.score }, + }); + return { + schema_version: 'skillpack-pack-v1', + pack_name: manifest.name, + pack_version: manifest.version, + doctor, + tarball: null, + refused_reason: `doctor blocked: ${doctor.promotion_blockers.join(', ')}`, + }; + } + } + + if (opts.dryRun) { + return { + schema_version: 'skillpack-pack-v1', + pack_name: manifest.name, + pack_version: manifest.version, + doctor, + tarball: null, + refused_reason: null, + }; + } + + // Pack tarball into /-.tgz. + const outDir = opts.outDir ?? opts.packRoot; + mkdirSync(outDir, { recursive: true }); + const outPath = join(outDir, `${manifest.name}-${manifest.version}.tgz`); + + let tarball: TarballPackResult; + try { + tarball = packTarball({ + sourceDir: opts.packRoot, + outPath, + exclude: ['node_modules', '.git', '.DS_Store', '*.tgz'], + }); + } catch (err) { + throw new PackPublishError( + `tarball pack failed: ${(err as Error).message}`, + 'pack_failed', + ); + } + + logSkillpackEvent({ + event: 'doctor_run', + pack: manifest.name, + version: manifest.version, + outcome: 'ok', + meta: { + mode: 'pack-publish-gate', + score: doctor?.score ?? null, + tier: doctor?.tier_eligibility ?? null, + tarball_sha256: tarball.sha256, + }, + }); + + return { + schema_version: 'skillpack-pack-v1', + pack_name: manifest.name, + pack_version: manifest.version, + doctor, + tarball: { ...tarball, tier_eligibility: doctor?.tier_eligibility ?? 'unknown' }, + refused_reason: null, + }; +} diff --git a/src/core/skillpack/registry-client.ts b/src/core/skillpack/registry-client.ts new file mode 100644 index 000000000..30135e779 --- /dev/null +++ b/src/core/skillpack/registry-client.ts @@ -0,0 +1,365 @@ +/** + * skillpack/registry-client.ts — fetch + cache + stale-fallback for the + * `gbrain skillpack-registry` catalog. + * + * The default registry lives at + * https://raw.githubusercontent.com/garrytan/gbrain-skillpack-registry/main/registry.json + * https://raw.githubusercontent.com/garrytan/gbrain-skillpack-registry/main/endorsements.json + * + * Offline-safe per the user's decision: when the network fetch fails (DNS + * miss, 5xx, timeout), fall back to the on-disk cache and emit a single + * stderr warning per process. Cache freshness threshold: 1h soft TTL for + * normal use, 7d before the "registry cache is stale" escalation fires. + * Hard-fail only when there is NO cache at all (first run + offline). + * + * Pure Bun's fetch — no external HTTP library. Honors ETag for cheap + * polling so successive fetches against an unchanged registry are + * effectively free. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from 'fs'; +import { createHash } from 'crypto'; +import { dirname, join } from 'path'; + +import { gbrainPath, loadConfig } from '../config.ts'; +import { + RegistrySchemaError, + validateEndorsementsFile, + validateRegistryCatalog, + effectiveTier as computeEffectiveTier, + type RegistryCatalog, + type RegistryEntry, + type EndorsementsFile, + type RegistryTier, +} from './registry-schema.ts'; + +/** Default registry URL — the canonical Garry-controlled catalog. */ +export const DEFAULT_REGISTRY_URL = + 'https://raw.githubusercontent.com/garrytan/gbrain-skillpack-registry/main/registry.json'; + +/** Default endorsements URL — sibling file in the same repo. */ +export const DEFAULT_ENDORSEMENTS_URL = + 'https://raw.githubusercontent.com/garrytan/gbrain-skillpack-registry/main/endorsements.json'; + +/** Soft TTL: prefer cache when it's younger than this (no fetch attempt). */ +const SOFT_TTL_MS = 60 * 60 * 1000; // 1 hour +/** Stale escalation: surface a louder warning when cache is older than this. */ +const STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000; // 7 days + +/** Cache file payload — wraps the validated registry + freshness metadata. */ +interface RegistryCacheFile { + fetched_at: string; + etag: string | null; + url: string; + catalog: RegistryCatalog; + endorsements: EndorsementsFile | null; +} + +/** Result of `loadRegistry`. */ +export interface LoadedRegistry { + catalog: RegistryCatalog; + endorsements: EndorsementsFile | null; + /** Where the data came from — informational for status output. */ + origin: 'fresh_fetch' | 'cache_warm' | 'cache_soft_stale' | 'cache_hard_stale'; + /** How old the cache is in ms (always set when origin is one of the cache states). */ + cache_age_ms: number | null; + /** URL the catalog came from (after config override). */ + registry_url: string; +} + +export type RegistryClientErrorCode = + | 'no_cache_no_network' + | 'fetch_succeeded_but_schema_invalid' + | 'cache_corrupt' + | 'url_invalid'; + +export class RegistryClientError extends Error { + constructor( + message: string, + public code: RegistryClientErrorCode, + ) { + super(message); + this.name = 'RegistryClientError'; + } +} + +export interface LoadRegistryOptions { + /** Override the registry URL (defaults to config key skillpack.registry_url then DEFAULT_REGISTRY_URL). */ + url?: string; + /** Force a fresh fetch even when cache is within the soft TTL. */ + refresh?: boolean; + /** Test seam: inject a fetch implementation. Default uses global fetch. */ + fetchImpl?: typeof fetch; + /** Test seam: override the cache directory. */ + cacheDir?: string; + /** Test seam: short-circuit network entirely (forces cache-or-fail). */ + noNetwork?: boolean; +} + +/** Stable cache file path for a given registry URL. */ +function cachePathFor(url: string, cacheDir: string): string { + const sha = createHash('sha256').update(url).digest('hex').slice(0, 16); + return join(cacheDir, `registry-${sha}.json`); +} + +/** Convert the registry URL to the sibling endorsements.json URL. */ +function endorsementsUrlFor(registryUrl: string): string { + if (registryUrl === DEFAULT_REGISTRY_URL) return DEFAULT_ENDORSEMENTS_URL; + // Replace the trailing "registry.json" with "endorsements.json" so custom + // registries that mirror the layout work transparently. + return registryUrl.replace(/registry\.json$/, 'endorsements.json'); +} + +/** Resolve the active registry URL: opts → config → default. */ +export function resolveRegistryUrl(opts: { url?: string } = {}): string { + if (opts.url) return opts.url; + try { + const cfg = loadConfig(); + const configured = (cfg as unknown as Record).skillpack; + if (configured && typeof configured === 'object') { + const url = (configured as Record).registry_url; + if (typeof url === 'string' && url.length > 0) return url; + } + } catch { + // loadConfig() may throw on first-run before init; default is fine. + } + return DEFAULT_REGISTRY_URL; +} + +/** Default cache directory under ~/.gbrain/skillpack-cache. */ +function defaultCacheDir(): string { + return gbrainPath('skillpack-cache'); +} + +/** Read a cache file from disk; null if missing or malformed. */ +function readCache(cacheFile: string): RegistryCacheFile | null { + if (!existsSync(cacheFile)) return null; + try { + const raw = JSON.parse(readFileSync(cacheFile, 'utf-8')) as RegistryCacheFile; + if (typeof raw !== 'object' || raw === null) return null; + if (typeof raw.fetched_at !== 'string' || !raw.catalog) return null; + validateRegistryCatalog(raw.catalog); + if (raw.endorsements) validateEndorsementsFile(raw.endorsements); + return raw; + } catch { + return null; + } +} + +/** Atomically write a cache file. */ +function writeCache(cacheFile: string, payload: RegistryCacheFile): void { + mkdirSync(dirname(cacheFile), { recursive: true }); + const tmp = cacheFile + '.tmp'; + writeFileSync(tmp, JSON.stringify(payload, null, 2)); + // Use renameSync via fs writeFileSync followed by manual rename to atomically replace. + // Bun's fs.renameSync is in node:fs. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { renameSync } = require('fs'); + renameSync(tmp, cacheFile); +} + +/** Format cache age for log lines. */ +function fmtAge(ms: number): string { + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + const days = Math.floor(hours / 24); + return `${days}d`; +} + +/** + * Load the registry. Tries network first (unless cache is fresh and + * refresh=false), falls back to cache on any failure, escalates to the + * stale warning when cache > 7d. Hard-fails only with no cache + no + * network. + */ +export async function loadRegistry(opts: LoadRegistryOptions = {}): Promise { + const url = resolveRegistryUrl(opts); + const cacheDir = opts.cacheDir ?? defaultCacheDir(); + const cacheFile = cachePathFor(url, cacheDir); + const cached = readCache(cacheFile); + + // Cache-warm fast path: cache is fresh AND refresh wasn't requested. + if (cached && !opts.refresh) { + const ageMs = Date.now() - new Date(cached.fetched_at).getTime(); + if (ageMs < SOFT_TTL_MS) { + return { + catalog: cached.catalog, + endorsements: cached.endorsements, + origin: 'cache_warm', + cache_age_ms: ageMs, + registry_url: url, + }; + } + } + + // Network path (unless explicitly disabled for tests). + if (!opts.noNetwork) { + const fetcher = opts.fetchImpl ?? fetch; + try { + const headers: Record = { Accept: 'application/json' }; + if (cached?.etag && !opts.refresh) headers['If-None-Match'] = cached.etag; + + const catalogRes = await fetcher(url, { headers }); + if (catalogRes.status === 304 && cached) { + // Cache hit via etag; touch the fetched_at so we don't re-poll within the soft TTL. + const refreshed: RegistryCacheFile = { ...cached, fetched_at: new Date().toISOString() }; + writeCache(cacheFile, refreshed); + return { + catalog: refreshed.catalog, + endorsements: refreshed.endorsements, + origin: 'fresh_fetch', + cache_age_ms: 0, + registry_url: url, + }; + } + + if (!catalogRes.ok) { + throw new Error(`HTTP ${catalogRes.status} ${catalogRes.statusText}`); + } + + const catalogJson = await catalogRes.json(); + let catalog: RegistryCatalog; + try { + catalog = validateRegistryCatalog(catalogJson); + } catch (err) { + throw new RegistryClientError( + `fetched registry.json but schema is invalid: ${(err as Error).message}`, + 'fetch_succeeded_but_schema_invalid', + ); + } + + // Fetch endorsements.json (best-effort: a missing file is not an error). + let endorsements: EndorsementsFile | null = null; + try { + const endRes = await fetcher(endorsementsUrlFor(url)); + if (endRes.ok) { + const endJson = await endRes.json(); + endorsements = validateEndorsementsFile(endJson); + } + } catch { + // Silently skip; endorsements are an overlay, not load-bearing. + } + + const etag = catalogRes.headers.get('etag'); + const payload: RegistryCacheFile = { + fetched_at: new Date().toISOString(), + etag, + url, + catalog, + endorsements, + }; + writeCache(cacheFile, payload); + + return { + catalog, + endorsements, + origin: 'fresh_fetch', + cache_age_ms: 0, + registry_url: url, + }; + } catch (err) { + // Schema-invalid responses are a hard failure — don't degrade to cache. + if (err instanceof RegistryClientError) throw err; + // Network failure — fall through to cache. + if (cached) { + const ageMs = Date.now() - new Date(cached.fetched_at).getTime(); + const origin: LoadedRegistry['origin'] = + ageMs > STALE_AFTER_MS ? 'cache_hard_stale' : 'cache_soft_stale'; + process.stderr.write( + `[skillpack] registry fetch failed (${(err as Error).message}); using cache from ${cached.fetched_at} (${fmtAge(ageMs)} old)\n`, + ); + if (origin === 'cache_hard_stale') { + process.stderr.write( + `[skillpack] cache is older than 7 days. When back online run \`gbrain skillpack registry --refresh\`.\n`, + ); + } + return { + catalog: cached.catalog, + endorsements: cached.endorsements, + origin, + cache_age_ms: ageMs, + registry_url: url, + }; + } + throw new RegistryClientError( + `registry fetch failed (${(err as Error).message}) and no on-disk cache exists. First-run installs require network.`, + 'no_cache_no_network', + ); + } + } + + // noNetwork branch. + if (cached) { + const ageMs = Date.now() - new Date(cached.fetched_at).getTime(); + const origin: LoadedRegistry['origin'] = + ageMs > STALE_AFTER_MS ? 'cache_hard_stale' : ageMs > SOFT_TTL_MS ? 'cache_soft_stale' : 'cache_warm'; + return { + catalog: cached.catalog, + endorsements: cached.endorsements, + origin, + cache_age_ms: ageMs, + registry_url: url, + }; + } + throw new RegistryClientError( + `--no-cache or noNetwork was set but no cache exists for ${url}`, + 'no_cache_no_network', + ); +} + +/** Lookup a pack by name. Returns null when not present. */ +export function findPack(loaded: LoadedRegistry, name: string): RegistryEntry | null { + return loaded.catalog.skillpacks.find((e) => e.name === name) ?? null; +} + +/** Lookup a pack with its effective tier applied. */ +export function findPackWithTier( + loaded: LoadedRegistry, + name: string, +): { entry: RegistryEntry; tier: RegistryTier } | null { + const entry = findPack(loaded, name); + if (!entry) return null; + return { entry, tier: computeEffectiveTier(entry, loaded.endorsements) }; +} + +/** + * Search the catalog by free-text query. Matches against name, description, + * author, and tags (lowercase contains). Returns entries paired with their + * effective tier; sorted by tier (endorsed > community > experimental > dead) + * then alphabetical by name. + */ +export function searchPacks( + loaded: LoadedRegistry, + opts: { query?: string; tier?: RegistryTier } = {}, +): Array<{ entry: RegistryEntry; tier: RegistryTier }> { + const q = (opts.query ?? '').trim().toLowerCase(); + const tierOrder: RegistryTier[] = ['endorsed', 'community', 'experimental', 'dead']; + const results: Array<{ entry: RegistryEntry; tier: RegistryTier }> = []; + for (const entry of loaded.catalog.skillpacks) { + const tier = computeEffectiveTier(entry, loaded.endorsements); + if (opts.tier && tier !== opts.tier) continue; + if (q.length > 0) { + const haystack = [ + entry.name, + entry.description, + entry.author, + entry.author_handle, + ...entry.tags, + ] + .join(' ') + .toLowerCase(); + if (!haystack.includes(q)) continue; + } + results.push({ entry, tier }); + } + results.sort((a, b) => { + const tDiff = tierOrder.indexOf(a.tier) - tierOrder.indexOf(b.tier); + if (tDiff !== 0) return tDiff; + return a.entry.name.localeCompare(b.entry.name); + }); + return results; +} diff --git a/src/core/skillpack/registry-schema.ts b/src/core/skillpack/registry-schema.ts new file mode 100644 index 000000000..d3ce86cd4 --- /dev/null +++ b/src/core/skillpack/registry-schema.ts @@ -0,0 +1,346 @@ +/** + * skillpack/registry-schema.ts — runtime validators for the + * `garrytan/gbrain-skillpack-registry` catalog files. + * + * Two files at the registry repo root: + * - registry.json — catalog of all listed skillpacks + * - endorsements.json — Garry-controlled tier overrides (codex G3 + * separation: contributors PR catalog entries; only Garry edits + * endorsements) + * + * Pure validators — no I/O. Used by the registry-client (fetch path) + * and by the publish-gate when validating PR submissions. + */ + +export const REGISTRY_SCHEMA_VERSION = 'gbrain-registry-v1' as const; +export const ENDORSEMENTS_SCHEMA_VERSION = 'gbrain-endorsements-v1' as const; + +export type RegistryTier = 'endorsed' | 'community' | 'experimental' | 'dead'; + +export interface RegistrySource { + /** Source kind — git is the v1 primary path; tarball-only is v1.5+. */ + kind: 'git'; + /** https:// URL to clone (must match the SSRF allowlist in git-remote.ts). */ + url: string; + /** Pinned commit SHA at PR-merge time. Required for endorsed/community. */ + pinned_commit: string; +} + +export interface RegistryEntry { + /** Pack name (must match skillpack.json `name`). Unique in the catalog. */ + name: string; + /** One-line description for `gbrain skillpack search` output. */ + description: string; + /** Author display name (display only; account binding is via author_handle). */ + author: string; + /** Account handle on the source host (e.g. "garrytan" for github.com/garrytan). */ + author_handle: string; + /** Homepage URL (canonical pack repo). */ + homepage: string; + /** Source metadata. */ + source: RegistrySource; + /** SHA-256 of the published tarball at validation time. Used by the + * durability path: if source repo disappears, the registry-hosted + * tarballs/-.tgz mirror matches this hash. */ + tarball_sha256: string; + /** Minimum gbrain version the pack supports. */ + gbrain_min_version: string; + /** Default tier — may be overridden by endorsements.json. Catalog PRs + * always land at `community`. Endorsement happens via the separate + * `gbrain skillpack endorse` command writing endorsements.json. */ + default_tier: Exclude; + /** Searchable tags (lowercase kebab strings). */ + tags: string[]; + /** ISO 8601 timestamp of the most-recent successful publish-gate validation. */ + validated_at: string; + /** Reference to the immutable validation log under registry/validation-runs/. */ + validation_run_id: string; + /** Cached count of skills in this pack (informational; from skillpack.json). */ + skills_count: number; + /** Cached list of skill slugs (informational). */ + skills: string[]; + /** Pack version when validated. */ + version: string; +} + +export interface RegistryBundles { + /** Named bundles — `gbrain skillpack scaffold starter-pack` walks the list. */ + [bundleName: string]: string[]; +} + +export interface RegistryCatalog { + schema_version: typeof REGISTRY_SCHEMA_VERSION; + /** Catalog last-updated timestamp (informational). */ + updated_at: string; + skillpacks: RegistryEntry[]; + /** Optional named bundles. */ + bundles?: RegistryBundles; +} + +export interface EndorsementRecord { + /** Tier this pack should resolve to (overriding default_tier). */ + tier: RegistryTier; + /** When the endorsement was set. */ + endorsed_at: string; + /** Optional human note (e.g. "promoted after 30 days of clean use"). */ + note?: string; +} + +export interface EndorsementsFile { + schema_version: typeof ENDORSEMENTS_SCHEMA_VERSION; + /** Map from pack name → endorsement record. Missing entries inherit default_tier. */ + endorsements: Record; +} + +export type RegistrySchemaErrorCode = + | 'malformed_json' + | 'unknown_schema' + | 'missing_field' + | 'invalid_field'; + +export class RegistrySchemaError extends Error { + constructor( + message: string, + public code: RegistrySchemaErrorCode, + public detail?: { field?: string; entryName?: string }, + ) { + super(message); + this.name = 'RegistrySchemaError'; + } +} + +const TIER_VALUES = new Set(['endorsed', 'community', 'experimental', 'dead']); +const DEFAULT_TIER_VALUES = new Set(['community', 'experimental', 'dead']); + +/** Validate a parsed JSON value as a RegistryCatalog. Throws on every gap. */ +export function validateRegistryCatalog(raw: unknown): RegistryCatalog { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + throw new RegistrySchemaError('registry.json top-level must be an object', 'malformed_json'); + } + const obj = raw as Record; + if (obj.schema_version !== REGISTRY_SCHEMA_VERSION) { + throw new RegistrySchemaError( + `registry.json schema_version is ${JSON.stringify(obj.schema_version)}; expected ${REGISTRY_SCHEMA_VERSION}`, + 'unknown_schema', + ); + } + if (typeof obj.updated_at !== 'string') { + throw new RegistrySchemaError( + 'registry.json.updated_at must be a string', + 'invalid_field', + { field: 'updated_at' }, + ); + } + if (!Array.isArray(obj.skillpacks)) { + throw new RegistrySchemaError( + 'registry.json.skillpacks must be an array', + 'invalid_field', + { field: 'skillpacks' }, + ); + } + for (const entry of obj.skillpacks) { + validateRegistryEntry(entry); + } + if (obj.bundles !== undefined) { + if (typeof obj.bundles !== 'object' || obj.bundles === null || Array.isArray(obj.bundles)) { + throw new RegistrySchemaError( + 'registry.json.bundles must be an object map', + 'invalid_field', + { field: 'bundles' }, + ); + } + for (const [bundleName, names] of Object.entries(obj.bundles)) { + if (!Array.isArray(names) || !names.every((n) => typeof n === 'string')) { + throw new RegistrySchemaError( + `registry.json.bundles.${bundleName} must be an array of pack-name strings`, + 'invalid_field', + { field: `bundles.${bundleName}` }, + ); + } + } + } + return raw as RegistryCatalog; +} + +/** Validate one entry inside the skillpacks array. */ +export function validateRegistryEntry(raw: unknown): RegistryEntry { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + throw new RegistrySchemaError('registry entry must be an object', 'malformed_json'); + } + const e = raw as Record; + const required = [ + 'name', + 'description', + 'author', + 'author_handle', + 'homepage', + 'source', + 'tarball_sha256', + 'gbrain_min_version', + 'default_tier', + 'tags', + 'validated_at', + 'validation_run_id', + 'skills_count', + 'skills', + 'version', + ] as const; + for (const field of required) { + if (!(field in e)) { + throw new RegistrySchemaError( + `registry entry is missing required field: ${field}`, + 'missing_field', + { field, entryName: typeof e.name === 'string' ? e.name : undefined }, + ); + } + } + + if (typeof e.name !== 'string' || !/^[a-z][a-z0-9-]{1,63}$/.test(e.name)) { + throw new RegistrySchemaError( + `registry entry name must be lowercase kebab-case; got ${JSON.stringify(e.name)}`, + 'invalid_field', + { field: 'name' }, + ); + } + for (const f of ['description', 'author', 'author_handle', 'homepage', 'tarball_sha256', 'gbrain_min_version', 'validated_at', 'validation_run_id', 'version']) { + if (typeof e[f] !== 'string' || (e[f] as string).length === 0) { + throw new RegistrySchemaError( + `registry entry ${e.name}.${f} must be a non-empty string`, + 'invalid_field', + { field: f, entryName: e.name as string }, + ); + } + } + + if (typeof e.skills_count !== 'number' || !Number.isFinite(e.skills_count) || e.skills_count < 0) { + throw new RegistrySchemaError( + `registry entry ${e.name}.skills_count must be a non-negative number`, + 'invalid_field', + { field: 'skills_count', entryName: e.name as string }, + ); + } + + if (!DEFAULT_TIER_VALUES.has(e.default_tier as RegistryTier)) { + throw new RegistrySchemaError( + `registry entry ${e.name}.default_tier must be one of community / experimental / dead (endorsed comes from endorsements.json); got ${JSON.stringify(e.default_tier)}`, + 'invalid_field', + { field: 'default_tier', entryName: e.name as string }, + ); + } + + if (!Array.isArray(e.tags) || !e.tags.every((t) => typeof t === 'string')) { + throw new RegistrySchemaError( + `registry entry ${e.name}.tags must be an array of strings`, + 'invalid_field', + { field: 'tags', entryName: e.name as string }, + ); + } + if (!Array.isArray(e.skills) || !e.skills.every((s) => typeof s === 'string')) { + throw new RegistrySchemaError( + `registry entry ${e.name}.skills must be an array of strings`, + 'invalid_field', + { field: 'skills', entryName: e.name as string }, + ); + } + + // Source object. + if (typeof e.source !== 'object' || e.source === null || Array.isArray(e.source)) { + throw new RegistrySchemaError( + `registry entry ${e.name}.source must be an object`, + 'invalid_field', + { field: 'source', entryName: e.name as string }, + ); + } + const s = e.source as Record; + if (s.kind !== 'git') { + throw new RegistrySchemaError( + `registry entry ${e.name}.source.kind must be "git" in v1; got ${JSON.stringify(s.kind)}`, + 'invalid_field', + { field: 'source.kind', entryName: e.name as string }, + ); + } + if (typeof s.url !== 'string' || !/^https?:\/\//.test(s.url)) { + throw new RegistrySchemaError( + `registry entry ${e.name}.source.url must be an http(s) URL`, + 'invalid_field', + { field: 'source.url', entryName: e.name as string }, + ); + } + if (typeof s.pinned_commit !== 'string' || !/^[a-f0-9]{7,40}$/.test(s.pinned_commit)) { + throw new RegistrySchemaError( + `registry entry ${e.name}.source.pinned_commit must be a 7-40 hex git SHA`, + 'invalid_field', + { field: 'source.pinned_commit', entryName: e.name as string }, + ); + } + + return raw as RegistryEntry; +} + +/** Validate a parsed JSON value as an EndorsementsFile. */ +export function validateEndorsementsFile(raw: unknown): EndorsementsFile { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + throw new RegistrySchemaError( + 'endorsements.json top-level must be an object', + 'malformed_json', + ); + } + const obj = raw as Record; + if (obj.schema_version !== ENDORSEMENTS_SCHEMA_VERSION) { + throw new RegistrySchemaError( + `endorsements.json schema_version is ${JSON.stringify(obj.schema_version)}; expected ${ENDORSEMENTS_SCHEMA_VERSION}`, + 'unknown_schema', + ); + } + if ( + typeof obj.endorsements !== 'object' || + obj.endorsements === null || + Array.isArray(obj.endorsements) + ) { + throw new RegistrySchemaError( + 'endorsements.json.endorsements must be an object map', + 'invalid_field', + { field: 'endorsements' }, + ); + } + for (const [name, record] of Object.entries(obj.endorsements)) { + if (typeof record !== 'object' || record === null || Array.isArray(record)) { + throw new RegistrySchemaError( + `endorsements.${name} must be an object`, + 'invalid_field', + { field: name }, + ); + } + const r = record as Record; + if (!TIER_VALUES.has(r.tier as RegistryTier)) { + throw new RegistrySchemaError( + `endorsements.${name}.tier must be one of endorsed / community / experimental / dead`, + 'invalid_field', + { field: `${name}.tier` }, + ); + } + if (typeof r.endorsed_at !== 'string') { + throw new RegistrySchemaError( + `endorsements.${name}.endorsed_at must be a string`, + 'invalid_field', + { field: `${name}.endorsed_at` }, + ); + } + } + return raw as EndorsementsFile; +} + +/** + * Project a registry entry through the endorsements overlay to produce the + * effective tier shown to the user. If endorsements.json has a record for + * this pack, it wins; otherwise default_tier from the catalog applies. + */ +export function effectiveTier( + entry: RegistryEntry, + endorsements: EndorsementsFile | null, +): RegistryTier { + if (!endorsements) return entry.default_tier; + const override = endorsements.endorsements[entry.name]; + if (override) return override.tier; + return entry.default_tier; +} diff --git a/src/core/skillpack/remote-source.ts b/src/core/skillpack/remote-source.ts new file mode 100644 index 000000000..525e82971 --- /dev/null +++ b/src/core/skillpack/remote-source.ts @@ -0,0 +1,421 @@ +/** + * skillpack/remote-source.ts — third-party skillpack source resolution. + * + * `resolveSource(spec)` accepts every supported input shape and returns a + * `ResolvedSource` with the local pack path plus identity metadata the + * scaffold orchestrator needs (TOFU pin, source URL, kind). Cache layout: + * + * ~/.gbrain/skillpack-cache/git///// (git sources) + * ~/.gbrain/skillpack-cache/tarball// (tarball sources) + * + * Cache hits short-circuit the clone/extract step. Cache misses do the work + * and then atomically rename the staging dir into place so partial clones + * never poison subsequent lookups. + * + * Reuses SSRF-hardened `cloneRepo` from `git-remote.ts`. Local-path inputs + * skip the cache entirely (the user owns the directory). + * + * Bare-name inputs ("hackathon-evaluation") are not handled here — the + * registry-client resolves them to a URL first, then re-invokes + * `resolveSource` with the URL. Keeps this module independent of the + * registry layer. + */ + +import { execFileSync } from 'child_process'; +import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync } from 'fs'; +import { isAbsolute, join, resolve } from 'path'; + +import { gbrainPath } from '../config.ts'; +import { GIT_SSRF_FLAGS, RemoteUrlError, cloneRepo, parseRemoteUrl } from '../git-remote.ts'; +import { extractTarball, fileSha256 } from './tarball.ts'; + +/** Kinds of third-party source we accept. */ +export type ResolvedSourceKind = 'git' | 'tarball' | 'local'; + +export interface ResolvedSource { + /** Absolute path to the pack root (where skillpack.json lives). */ + path: string; + /** Source classification. */ + kind: ResolvedSourceKind; + /** Canonical source URL or path the user provided (after kebab expansion). */ + source: string; + /** Resolved git commit SHA when kind=git; null otherwise. */ + pinned_commit: string | null; + /** SHA-256 of the tarball when kind=tarball; null otherwise. */ + tarball_sha256: string | null; + /** Whether the result came from cache (used by callers for log lines). */ + cache_hit: boolean; +} + +export type RemoteSourceErrorCode = + | 'spec_empty' + | 'spec_local_missing' + | 'spec_local_not_pack_root' + | 'spec_tarball_missing' + | 'spec_kebab_invalid_shape' + | 'spec_url_invalid' + | 'clone_failed' + | 'rev_parse_failed'; + +export class RemoteSourceError extends Error { + constructor( + message: string, + public code: RemoteSourceErrorCode, + public detail?: { spec?: string; cause?: string }, + ) { + super(message); + this.name = 'RemoteSourceError'; + } +} + +export interface ResolveSourceOptions { + /** Override the cache root (test-only; defaults to ~/.gbrain/skillpack-cache). */ + cacheRoot?: string; + /** Force a fresh clone/extract even when the cache has a hit. */ + noCache?: boolean; +} + +/** Result of classifying a raw spec string — narrower than ResolvedSourceKind + * because `git-url` and `kebab` are pre-resolution states, while `git` is + * the post-resolution kind on ResolvedSource. + */ +export type SpecKind = 'git-url' | 'tarball' | 'local' | 'kebab'; + +/** Classify the spec without doing any I/O beyond a single stat. */ +export function classifySpec(spec: string): { kind: SpecKind; normalized: string } { + if (!spec || typeof spec !== 'string') { + throw new RemoteSourceError('source spec is empty', 'spec_empty', { spec }); + } + const trimmed = spec.trim(); + if (trimmed.length === 0) { + throw new RemoteSourceError('source spec is empty', 'spec_empty', { spec }); + } + + // URL: starts with http(s)://, ends in .git or contains github.com / gitlab.com path. + if (/^https?:\/\//.test(trimmed)) { + return { kind: 'git-url', normalized: trimmed }; + } + + // Local path: starts with /, ./, ../, or ~/ + if ( + isAbsolute(trimmed) || + trimmed.startsWith('./') || + trimmed.startsWith('../') || + trimmed.startsWith('~/') + ) { + const expanded = trimmed.startsWith('~/') ? join(process.env.HOME ?? '', trimmed.slice(2)) : trimmed; + const abs = resolve(expanded); + // Tarball if it's a .tgz / .tar.gz file. + if (/\.(tgz|tar\.gz)$/.test(abs)) { + return { kind: 'tarball', normalized: abs }; + } + return { kind: 'local', normalized: abs }; + } + + // owner/repo short-form (github inferred): exactly one `/` and both halves + // look like GitHub identifiers (alnum + dash/dot/underscore). + if (/^[A-Za-z0-9][A-Za-z0-9._-]{0,38}\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(trimmed)) { + return { kind: 'git-url', normalized: `https://github.com/${trimmed}.git` }; + } + + // Bare kebab-name: defer to registry resolution. Caller (registry-client) + // must convert to a URL before re-calling resolveSource. + if (/^[a-z][a-z0-9-]{1,63}$/.test(trimmed)) { + return { kind: 'kebab', normalized: trimmed }; + } + + throw new RemoteSourceError( + `cannot classify source spec ${JSON.stringify(spec)} — must be a kebab name, owner/repo, https URL, local dir, or .tgz path`, + 'spec_kebab_invalid_shape', + { spec }, + ); +} + +/** Compute the cache root, honoring opts override. */ +function cacheRoot(opts: ResolveSourceOptions): string { + return opts.cacheRoot ?? gbrainPath('skillpack-cache'); +} + +/** Resolve HEAD SHA of a remote git URL via `git ls-remote`. */ +function resolveRemoteHead(url: string, branch: string | undefined): string { + const argv = [ + ...GIT_SSRF_FLAGS, + 'ls-remote', + '--exit-code', + url, + branch ? `refs/heads/${branch}` : 'HEAD', + ]; + try { + const output = execFileSync('git', argv, { + encoding: 'utf-8', + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + timeout: 60_000, + }); + const firstLine = output.split('\n').find((l) => l.trim().length > 0); + if (!firstLine) { + throw new RemoteSourceError(`git ls-remote returned no refs for ${url}`, 'rev_parse_failed', { spec: url }); + } + const sha = firstLine.split(/\s+/)[0]?.trim(); + if (!sha || !/^[a-f0-9]{40}$/.test(sha)) { + throw new RemoteSourceError(`git ls-remote gave invalid sha for ${url}: ${firstLine}`, 'rev_parse_failed', { spec: url }); + } + return sha; + } catch (err) { + if (err instanceof RemoteSourceError) throw err; + throw new RemoteSourceError( + `git ls-remote failed for ${url}: ${(err as Error).message}`, + 'rev_parse_failed', + { spec: url, cause: (err as Error).message }, + ); + } +} + +/** Compute the per-source cache directory. */ +function gitCachePath(root: string, parsedUrl: URL, sha: string): string { + const host = parsedUrl.hostname; + const ownerRepo = parsedUrl.pathname.replace(/^\/+/, '').replace(/\.git$/, ''); + return join(root, 'git', host, ownerRepo, sha); +} + +/** Resolve a git URL into a ResolvedSource. */ +function resolveGitSource( + url: string, + opts: ResolveSourceOptions, +): ResolvedSource { + let parsedSafe: URL; + try { + parsedSafe = new URL(parseRemoteUrl(url).url); + } catch (err) { + if (err instanceof RemoteUrlError) { + throw new RemoteSourceError( + `remote URL rejected: ${err.message}`, + 'spec_url_invalid', + { spec: url, cause: err.code }, + ); + } + throw err; + } + + const sha = resolveRemoteHead(parsedSafe.toString(), undefined); + const cacheDir = gitCachePath(cacheRoot(opts), parsedSafe, sha); + + if (!opts.noCache && existsSync(cacheDir)) { + const entries = readdirSync(cacheDir); + if (entries.length > 0) { + return { + path: cacheDir, + kind: 'git', + source: parsedSafe.toString(), + pinned_commit: sha, + tarball_sha256: null, + cache_hit: true, + }; + } + } + + // Stage in a sibling .tmp dir so a failed clone doesn't poison the cache slot. + const stageDir = cacheDir + '.tmp-' + process.pid + '-' + Date.now(); + mkdirSync(stageDir, { recursive: true }); + try { + cloneRepo(parsedSafe.toString(), stageDir, { depth: 1, timeoutMs: 600_000 }); + } catch (err) { + try { + rmSync(stageDir, { recursive: true, force: true }); + } catch {} + throw new RemoteSourceError( + `clone failed for ${parsedSafe.toString()}: ${(err as Error).message}`, + 'clone_failed', + { spec: url, cause: (err as Error).message }, + ); + } + + // Verify the cloned commit matches the SHA we ls-remoted (defense against + // race where HEAD moved between ls-remote and clone). + let cloneSha: string; + try { + cloneSha = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: stageDir, + encoding: 'utf-8', + timeout: 30_000, + }).trim(); + } catch (err) { + try { + rmSync(stageDir, { recursive: true, force: true }); + } catch {} + throw new RemoteSourceError( + `git rev-parse HEAD failed after clone: ${(err as Error).message}`, + 'rev_parse_failed', + { spec: url, cause: (err as Error).message }, + ); + } + + // Atomic rename into the canonical cache slot. If the slot exists already + // (cache populated concurrently by another process), prefer the existing + // one and drop our stage dir. + if (existsSync(cacheDir)) { + try { + rmSync(stageDir, { recursive: true, force: true }); + } catch {} + } else { + mkdirSync(join(cacheDir, '..'), { recursive: true }); + renameSync(stageDir, cacheDir); + } + + return { + path: cacheDir, + kind: 'git', + source: parsedSafe.toString(), + pinned_commit: cloneSha, + tarball_sha256: null, + cache_hit: false, + }; +} + +/** Resolve a local tarball into a ResolvedSource (extract into cache by SHA). */ +function resolveTarballSource( + tarballPath: string, + opts: ResolveSourceOptions, +): ResolvedSource { + if (!existsSync(tarballPath)) { + throw new RemoteSourceError( + `tarball file does not exist: ${tarballPath}`, + 'spec_tarball_missing', + { spec: tarballPath }, + ); + } + if (!statSync(tarballPath).isFile()) { + throw new RemoteSourceError( + `tarball path is not a regular file: ${tarballPath}`, + 'spec_tarball_missing', + { spec: tarballPath }, + ); + } + + const sha = fileSha256(tarballPath); + const cacheDir = join(cacheRoot(opts), 'tarball', sha); + + if (!opts.noCache && existsSync(cacheDir)) { + const entries = readdirSync(cacheDir); + if (entries.length > 0) { + return findPackRoot({ + path: cacheDir, + kind: 'tarball', + source: tarballPath, + pinned_commit: null, + tarball_sha256: sha, + cache_hit: true, + }); + } + } + + const stageDir = cacheDir + '.tmp-' + process.pid + '-' + Date.now(); + mkdirSync(stageDir, { recursive: true }); + try { + extractTarball({ tgzPath: tarballPath, destDir: stageDir }); + } catch (err) { + try { + rmSync(stageDir, { recursive: true, force: true }); + } catch {} + throw err; + } + + if (existsSync(cacheDir)) { + try { + rmSync(stageDir, { recursive: true, force: true }); + } catch {} + } else { + mkdirSync(join(cacheDir, '..'), { recursive: true }); + renameSync(stageDir, cacheDir); + } + + return findPackRoot({ + path: cacheDir, + kind: 'tarball', + source: tarballPath, + pinned_commit: null, + tarball_sha256: sha, + cache_hit: false, + }); +} + +/** + * A tarball produced by `packTarball` wraps its source dir, so the extracted + * cache dir contains `/skillpack.json`, not `skillpack.json` at + * the root. Find the actual pack root (the directory containing skillpack.json). + */ +function findPackRoot(s: ResolvedSource): ResolvedSource { + if (existsSync(join(s.path, 'skillpack.json'))) return s; + // Look one level deep. + const entries = readdirSync(s.path); + for (const e of entries) { + const candidate = join(s.path, e); + if (statSync(candidate).isDirectory() && existsSync(join(candidate, 'skillpack.json'))) { + return { ...s, path: candidate }; + } + } + // Caller will surface a clearer error at manifest-load time; return as-is. + return s; +} + +/** Resolve a local directory: just validate it has skillpack.json. */ +function resolveLocalSource(absPath: string): ResolvedSource { + if (!existsSync(absPath)) { + throw new RemoteSourceError( + `local path does not exist: ${absPath}`, + 'spec_local_missing', + { spec: absPath }, + ); + } + if (!statSync(absPath).isDirectory()) { + throw new RemoteSourceError( + `local path is not a directory: ${absPath}`, + 'spec_local_not_pack_root', + { spec: absPath }, + ); + } + if (!existsSync(join(absPath, 'skillpack.json'))) { + throw new RemoteSourceError( + `local path is not a pack root (no skillpack.json): ${absPath}`, + 'spec_local_not_pack_root', + { spec: absPath }, + ); + } + return { + path: absPath, + kind: 'local', + source: absPath, + pinned_commit: null, + tarball_sha256: null, + cache_hit: false, + }; +} + +/** + * Resolve any supported source spec. Throws RemoteSourceError for kebab-name + * inputs — those must be resolved through the registry-client first. + */ +export function resolveSource(spec: string, opts: ResolveSourceOptions = {}): ResolvedSource { + const classified = classifySpec(spec); + switch (classified.kind) { + case 'git-url': + return resolveGitSource(classified.normalized, opts); + case 'tarball': + return resolveTarballSource(classified.normalized, opts); + case 'local': + return resolveLocalSource(classified.normalized); + case 'kebab': + throw new RemoteSourceError( + `bare short-name ${JSON.stringify(classified.normalized)} requires registry lookup — call the registry client first`, + 'spec_kebab_invalid_shape', + { spec }, + ); + default: { + const _exhaustive: never = classified.kind; + throw new RemoteSourceError( + `unhandled spec kind: ${String(_exhaustive)}`, + 'spec_kebab_invalid_shape', + { spec }, + ); + } + } +} diff --git a/src/core/skillpack/rubric.ts b/src/core/skillpack/rubric.ts new file mode 100644 index 000000000..7301e86f3 --- /dev/null +++ b/src/core/skillpack/rubric.ts @@ -0,0 +1,511 @@ +/** + * skillpack/rubric.ts — declarative quality rubric for third-party skillpacks. + * + * Codex outside-voice T4: rubric splits into REQUIRED CORE (5 dimensions + * that gate publish entirely) and QUALITY BADGES (5 dimensions that + * gate tier eligibility). A pack with 0 badges can still publish as + * experimental; community needs >= 3 badges; endorsed needs all 5. + * + * Each dimension is a pure-ish check function over the pack directory. + * The doctor walks them in order; the anatomy doc is auto-generated + * from this single source of truth. + */ + +import { existsSync, readFileSync, readdirSync, statSync } from 'fs'; +import { join, relative } from 'path'; + +import { parseMarkdown } from '../markdown.ts'; +import { loadSkillpackManifest, SkillpackManifestError, type SkillpackManifest } from './manifest-v1.ts'; + +/** Dimension category. */ +export type RubricCategory = 'core' | 'badge'; + +/** Result of a single dimension check. */ +export interface RubricDimensionResult { + /** 1-10 dimension id (stable across versions). */ + id: number; + /** snake_case name (stable; consumed by tier rules + anatomy doc). */ + name: string; + /** Required core vs optional badge. */ + category: RubricCategory; + /** Did the check pass? */ + passed: boolean; + /** Human-readable description of what was checked. */ + description: string; + /** Detail string surfaced in --json output (e.g. specific files that failed). */ + detail: string; + /** Paste-ready fix command / hint when passed=false. */ + fix_hint: string | null; + /** Whether `doctor --fix` can auto-resolve this dimension. */ + auto_fixable: boolean; +} + +/** Rubric input — a parsed pack ready for scoring. */ +export interface RubricInput { + /** Absolute path to the pack root. */ + packRoot: string; + /** Parsed manifest. */ + manifest: SkillpackManifest; +} + +/** Score envelope produced by walkRubric. */ +export interface RubricScore { + /** All 10 dimension results. */ + dimensions: RubricDimensionResult[]; + /** Sum of passed dimensions (max 10). */ + total: number; + /** Pass counts per category. */ + core_passed: number; + badges_passed: number; + /** Tier eligibility based on which dimensions passed. */ + tier_eligibility: 'endorsed' | 'community' | 'experimental' | 'blocked'; + /** When tier_eligibility is anything below endorsed, the dimensions blocking promotion. */ + promotion_blockers: string[]; +} + +const DIMENSIONS: Array< + Omit & { + check: (input: RubricInput) => Promise<{ passed: boolean; detail: string; fix_hint: string | null }>; + } +> = [ + // ============================================================ + // CORE (5 dimensions — must all pass to publish at any tier) + // ============================================================ + { + id: 1, + name: 'manifest_valid', + category: 'core', + description: 'skillpack.json passes the v1 schema validator', + auto_fixable: false, + check: async (input) => { + try { + loadSkillpackManifest(input.packRoot); + return { passed: true, detail: 'manifest validates', fix_hint: null }; + } catch (err) { + const msg = err instanceof SkillpackManifestError ? err.message : (err as Error).message; + return { + passed: false, + detail: msg, + fix_hint: + 'Run `gbrain skillpack init ` to regenerate a valid stub manifest, or fix the field listed above.', + }; + } + }, + }, + { + id: 2, + name: 'skills_have_skill_md', + category: 'core', + description: 'every listed skill has SKILL.md with valid frontmatter (name, description, triggers)', + auto_fixable: false, + check: async (input) => { + const failures: string[] = []; + for (const skillPath of input.manifest.skills) { + const skillMd = join(input.packRoot, skillPath, 'SKILL.md'); + if (!existsSync(skillMd)) { + failures.push(`${skillPath}/SKILL.md missing`); + continue; + } + try { + const parsed = parseMarkdown(readFileSync(skillMd, 'utf-8')); + const fm = parsed.frontmatter as Record; + const missingFields = ['name', 'description', 'triggers'].filter((f) => !(f in fm)); + if (missingFields.length > 0) { + failures.push(`${skillPath}/SKILL.md missing frontmatter fields: ${missingFields.join(', ')}`); + } + if ( + Array.isArray(fm.triggers) + ? fm.triggers.length === 0 + : typeof fm.triggers === 'string' + ? fm.triggers.trim().length === 0 + : true + ) { + if (!failures.some((f) => f.startsWith(skillPath))) { + failures.push(`${skillPath}/SKILL.md frontmatter.triggers is empty`); + } + } + } catch (err) { + failures.push(`${skillPath}/SKILL.md failed to parse: ${(err as Error).message}`); + } + } + return failures.length === 0 + ? { passed: true, detail: `all ${input.manifest.skills.length} skills have valid SKILL.md`, fix_hint: null } + : { + passed: false, + detail: failures.join('; '), + fix_hint: + 'For each missing SKILL.md, run `gbrain skillpack init` (regenerates the stub) or hand-write the frontmatter with name + description + triggers (array of >=1 strings).', + }; + }, + }, + { + id: 3, + name: 'routing_evals_present', + category: 'core', + description: 'every skill has routing-eval.jsonl with >= 5 intents', + auto_fixable: true, + check: async (input) => { + const failures: string[] = []; + for (const skillPath of input.manifest.skills) { + const evalFile = join(input.packRoot, skillPath, 'routing-eval.jsonl'); + if (!existsSync(evalFile)) { + failures.push(`${skillPath}/routing-eval.jsonl missing`); + continue; + } + const lines = readFileSync(evalFile, 'utf-8').split('\n').filter((l) => l.trim().length > 0); + if (lines.length < 5) { + failures.push(`${skillPath}/routing-eval.jsonl has ${lines.length} intents (need >= 5)`); + } + } + return failures.length === 0 + ? { passed: true, detail: `all skills have >= 5 routing intents`, fix_hint: null } + : { + passed: false, + detail: failures.join('; '), + fix_hint: + 'Add intents to skills//routing-eval.jsonl — one JSON object per line with {intent, expected_skill, ambiguous_with?}. `gbrain skillpack doctor --fix` will scaffold stubs.', + }; + }, + }, + { + id: 4, + name: 'skills_have_unique_triggers', + category: 'core', + description: 'no two skills in this pack share an exact trigger phrase (MECE)', + auto_fixable: false, + check: async (input) => { + const triggerMap: Record = {}; + for (const skillPath of input.manifest.skills) { + const skillMd = join(input.packRoot, skillPath, 'SKILL.md'); + if (!existsSync(skillMd)) continue; + try { + const fm = parseMarkdown(readFileSync(skillMd, 'utf-8')).frontmatter as Record; + const triggers = Array.isArray(fm.triggers) ? (fm.triggers as unknown[]) : []; + for (const t of triggers) { + if (typeof t === 'string' && t.trim()) { + const norm = t.trim().toLowerCase(); + if (!triggerMap[norm]) triggerMap[norm] = []; + triggerMap[norm].push(skillPath); + } + } + } catch { + // Already surfaced by dimension 2. + } + } + const conflicts = Object.entries(triggerMap).filter(([, skills]) => skills.length > 1); + return conflicts.length === 0 + ? { passed: true, detail: 'all triggers unique', fix_hint: null } + : { + passed: false, + detail: conflicts.map(([t, skills]) => `"${t}" claimed by: ${skills.join(', ')}`).join('; '), + fix_hint: + 'Rephrase the conflicting trigger in one of the skills so each skill claims unique routing phrases. The MECE property is what makes agent routing deterministic.', + }; + }, + }, + { + id: 5, + name: 'changelog_present_and_current', + category: 'core', + description: 'CHANGELOG.md present and contains an entry for the current version', + auto_fixable: true, + check: async (input) => { + const path = join(input.packRoot, input.manifest.changelog ?? 'CHANGELOG.md'); + if (!existsSync(path)) { + return { + passed: false, + detail: `CHANGELOG.md missing at ${relative(input.packRoot, path)}`, + fix_hint: + 'Create CHANGELOG.md with at least a `## [] - ` entry for the current version. `gbrain skillpack doctor --fix` will scaffold a stub.', + }; + } + const content = readFileSync(path, 'utf-8'); + const versionEntryRe = new RegExp(`##\\s+\\[?${input.manifest.version.replace(/\./g, '\\.')}\\]?`); + if (!versionEntryRe.test(content)) { + return { + passed: false, + detail: `CHANGELOG.md has no entry matching ## [${input.manifest.version}]`, + fix_hint: `Add a top-level entry: \`## [${input.manifest.version}] - ${new Date().toISOString().slice(0, 10)}\` followed by bullet-list notes.`, + }; + } + return { passed: true, detail: `CHANGELOG.md references ${input.manifest.version}`, fix_hint: null }; + }, + }, + + // ============================================================ + // QUALITY BADGES (5 dimensions — earn for tier eligibility) + // ============================================================ + { + id: 6, + name: 'unit_tests_present', + category: 'badge', + description: 'pack declares unit_tests[] with at least one matching test file', + auto_fixable: true, + check: async (input) => { + if (!input.manifest.unit_tests || input.manifest.unit_tests.length === 0) { + return { + passed: false, + detail: 'manifest.unit_tests not declared', + fix_hint: 'Add `"unit_tests": ["test/**/*.test.ts"]` to skillpack.json and a passing test in test/.', + }; + } + const found = countGlobMatches(input.packRoot, input.manifest.unit_tests); + return found > 0 + ? { passed: true, detail: `${found} unit test file(s) found`, fix_hint: null } + : { + passed: false, + detail: 'unit_tests globs match zero files on disk', + fix_hint: 'Create a test/.test.ts (or matching glob) with at least one bun:test case.', + }; + }, + }, + { + id: 7, + name: 'e2e_tests_present', + category: 'badge', + description: 'pack declares e2e_tests[] with at least one matching test file', + auto_fixable: true, + check: async (input) => { + if (!input.manifest.e2e_tests || input.manifest.e2e_tests.length === 0) { + return { + passed: false, + detail: 'manifest.e2e_tests not declared', + fix_hint: 'Add `"e2e_tests": ["e2e/**/*.test.ts"]` and at least one integration test that exercises a full user journey.', + }; + } + const found = countGlobMatches(input.packRoot, input.manifest.e2e_tests); + return found > 0 + ? { passed: true, detail: `${found} e2e test file(s) found`, fix_hint: null } + : { + passed: false, + detail: 'e2e_tests globs match zero files on disk', + fix_hint: 'Create an e2e/.test.ts (or matching glob).', + }; + }, + }, + { + id: 8, + name: 'llm_eval_present', + category: 'badge', + description: 'pack declares llm_evals[] with >= 1 file containing >= 3 cases', + auto_fixable: true, + check: async (input) => { + if (!input.manifest.llm_evals || input.manifest.llm_evals.length === 0) { + return { + passed: false, + detail: 'manifest.llm_evals not declared', + fix_hint: 'Add `"llm_evals": ["evals/*.judge.json"]` and at least one *.judge.json with >= 3 cases.', + }; + } + const evalFiles = listGlobMatches(input.packRoot, input.manifest.llm_evals); + if (evalFiles.length === 0) { + return { + passed: false, + detail: 'llm_evals globs match zero files on disk', + fix_hint: 'Create evals/.judge.json with {task, output, cases:[...]} shape (cross-modal-eval format).', + }; + } + // Each eval must have >= 3 cases. Read and validate. + for (const file of evalFiles) { + try { + const data = JSON.parse(readFileSync(file, 'utf-8')); + const cases = (data as Record).cases; + if (Array.isArray(cases) && cases.length >= 3) { + return { + passed: true, + detail: `${relative(input.packRoot, file)} has ${cases.length} cases`, + fix_hint: null, + }; + } + } catch { + // Try the next file. + } + } + return { + passed: false, + detail: 'no llm_evals file has >= 3 cases', + fix_hint: 'Each *.judge.json must have a "cases" array with >= 3 items. Stubs are flagged as theater.', + }; + }, + }, + { + id: 9, + name: 'bootstrap_runbook_present', + category: 'badge', + description: 'pack declares runbooks.bootstrap and the file is non-empty', + auto_fixable: true, + check: async (input) => { + const path = input.manifest.runbooks?.bootstrap; + if (!path) { + return { + passed: false, + detail: 'manifest.runbooks.bootstrap not declared', + fix_hint: 'Add `"runbooks": {"bootstrap": "runbooks/bootstrap.md"}` and write the file with post-scaffold steps.', + }; + } + const abs = join(input.packRoot, path); + if (!existsSync(abs)) { + return { + passed: false, + detail: `${path} declared but file does not exist`, + fix_hint: `Create ${path} with at least one bootstrap step. \`gbrain skillpack doctor --fix\` will scaffold a stub.`, + }; + } + const content = readFileSync(abs, 'utf-8').trim(); + if (content.length === 0) { + return { + passed: false, + detail: `${path} is empty`, + fix_hint: `Add at least one bootstrap step to ${path}. Three step kinds supported: "agent:" / "show user:" / "ask user:".`, + }; + } + return { passed: true, detail: `${path} has ${content.split('\n').length} lines`, fix_hint: null }; + }, + }, + { + id: 10, + name: 'license_present', + category: 'badge', + description: 'LICENSE file exists at the pack root (informational badge)', + auto_fixable: true, + check: async (input) => { + const candidates = ['LICENSE', 'LICENSE.md', 'LICENSE.txt']; + for (const c of candidates) { + const path = join(input.packRoot, c); + if (existsSync(path) && statSync(path).size > 0) { + return { passed: true, detail: `${c} present`, fix_hint: null }; + } + } + return { + passed: false, + detail: 'no LICENSE / LICENSE.md / LICENSE.txt at pack root', + fix_hint: `Create a LICENSE file matching the SPDX id you declared in skillpack.json (${input.manifest.license}).`, + }; + }, + }, +]; + +/** Minimal glob counter — supports double-star/file.ext and one-level patterns. */ +function listGlobMatches(packRoot: string, globs: string[]): string[] { + const matches = new Set(); + for (const g of globs) { + matches.forEach.bind(matches); // (silence unused warning for empty loop body) + for (const found of walkGlob(packRoot, g)) { + matches.add(found); + } + } + return [...matches]; +} + +function countGlobMatches(packRoot: string, globs: string[]): number { + return listGlobMatches(packRoot, globs).length; +} + +function walkGlob(packRoot: string, glob: string): string[] { + // Split into prefix (literal directory path) and pattern. + const starIdx = glob.indexOf('*'); + const literalPart = starIdx === -1 ? glob : glob.slice(0, starIdx); + const lastSlash = literalPart.lastIndexOf('/'); + const baseRel = lastSlash === -1 ? '' : literalPart.slice(0, lastSlash); + const baseAbs = baseRel ? join(packRoot, baseRel) : packRoot; + if (!existsSync(baseAbs)) return []; + + // Convert glob to regex. + const escaped = glob + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*\*\//g, '__DOUBLESTAR_SLASH__') + .replace(/\*\*/g, '__DOUBLESTAR__') + .replace(/\*/g, '[^/]*') + .replace(/__DOUBLESTAR_SLASH__/g, '(?:.+/)?') + .replace(/__DOUBLESTAR__/g, '.+'); + const regex = new RegExp('^' + escaped + '$'); + + const matches: string[] = []; + const walk = (dir: string) => { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return; + } + for (const e of entries) { + if (e === 'node_modules' || e.startsWith('.git')) continue; + const full = join(dir, e); + const rel = relative(packRoot, full); + let st; + try { + st = statSync(full); + } catch { + continue; + } + if (st.isDirectory()) walk(full); + else if (st.isFile() && regex.test(rel)) matches.push(full); + } + }; + walk(baseAbs); + return matches; +} + +/** Walk every dimension in order, returning the full RubricScore. */ +export async function walkRubric(input: RubricInput): Promise { + const dimensions: RubricDimensionResult[] = []; + for (const def of DIMENSIONS) { + const r = await def.check(input); + dimensions.push({ + id: def.id, + name: def.name, + category: def.category, + description: def.description, + auto_fixable: def.auto_fixable, + passed: r.passed, + detail: r.detail, + fix_hint: r.fix_hint, + }); + } + const core = dimensions.filter((d) => d.category === 'core'); + const badges = dimensions.filter((d) => d.category === 'badge'); + const corePassed = core.filter((d) => d.passed).length; + const badgesPassed = badges.filter((d) => d.passed).length; + const total = corePassed + badgesPassed; + + const allCorePassed = corePassed === core.length; + let tier_eligibility: RubricScore['tier_eligibility']; + if (!allCorePassed) tier_eligibility = 'blocked'; + else if (badgesPassed === badges.length) tier_eligibility = 'endorsed'; + else if (badgesPassed >= 3) tier_eligibility = 'community'; + else tier_eligibility = 'experimental'; + + // Blockers = dimensions that need to pass to reach the next tier. + const promotionBlockers: string[] = []; + if (tier_eligibility === 'blocked') { + promotionBlockers.push(...core.filter((d) => !d.passed).map((d) => d.name)); + } else if (tier_eligibility !== 'endorsed') { + promotionBlockers.push(...badges.filter((d) => !d.passed).map((d) => d.name)); + } + + return { + dimensions, + total, + core_passed: corePassed, + badges_passed: badgesPassed, + tier_eligibility, + promotion_blockers: promotionBlockers, + }; +} + +/** Pure-data export of the rubric for the anatomy doc generator. */ +export function describeRubric(): Array<{ + id: number; + name: string; + category: RubricCategory; + description: string; + auto_fixable: boolean; +}> { + return DIMENSIONS.map((d) => ({ + id: d.id, + name: d.name, + category: d.category, + description: d.description, + auto_fixable: d.auto_fixable, + })); +} diff --git a/src/core/skillpack/scaffold-third-party.ts b/src/core/skillpack/scaffold-third-party.ts new file mode 100644 index 000000000..4335978b3 --- /dev/null +++ b/src/core/skillpack/scaffold-third-party.ts @@ -0,0 +1,221 @@ +/** + * skillpack/scaffold-third-party.ts — orchestrator for scaffolding a + * third-party skillpack into the user's workspace. + * + * Composes the foundation pieces: + * resolveSource → loadSkillpackManifest → askTrust → enumerateScaffoldEntries + * → copyArtifacts → saveState (~/.gbrain/skillpack-state.json) → buildBootstrapDisplay + * + * Mirrors the contracts of v0.36's `runScaffold` (no managed-block writes, + * refuses to overwrite, partial-state policy via enumerateScaffoldEntries + + * paired sources) — third-party packs land the same way bundled ones do. + * The only difference is the source manifest format (skillpack.json vs + * openclaw.plugin.json) and the trust gate that wraps the copy step. + * + * Returns a structured result the CLI and the publish-gate both consume. + */ + +import { join } from 'path'; + +import { bundleManifestFromSkillpack, loadSkillpackManifest, type SkillpackManifest } from './manifest-v1.ts'; +import { buildBootstrapDisplay, type BootstrapDisplayResult } from './bootstrap-display.ts'; +import { copyArtifacts, type CopyItem, type CopyResult } from './copy.ts'; +import { enumerateScaffoldEntries, type ScaffoldEntry } from './bundle.ts'; +import { + defaultStatePath, + loadState, + saveState, + upsertEntry, + type SkillpackState, + type SkillpackStateEntry, +} from './state.ts'; +import { askTrust, type SkillpackTier, type TrustPromptDecision } from './trust-prompt.ts'; +import type { ResolvedSource } from './remote-source.ts'; + +export interface ScaffoldThirdPartyOptions { + /** Result of resolveSource() (already cached/cloned/extracted). */ + resolved: ResolvedSource; + /** Absolute path to the target workspace where files should land. */ + targetWorkspace: string; + /** Tier the registry assigned the pack at scaffold time (informational). */ + tier?: SkillpackTier; + /** Skip the trust prompt (CI / agent use). */ + trustFlag?: boolean; + /** Test seam: TTY override. */ + isTTY?: boolean; + /** Test seam: state-file path override. */ + statePath?: string; + /** Dry-run: validate + enumerate; no writes. */ + dryRun?: boolean; + /** Test seam: TTY reader injection (forwarded to askTrust). */ + readLine?: (question: string) => Promise; +} + +export type ScaffoldThirdPartyStatus = + | 'wrote_new' + | 'all_skipped_existing' + | 'dry_run' + | 'aborted_no_trust'; + +export interface ScaffoldThirdPartyResult { + status: ScaffoldThirdPartyStatus; + manifest: SkillpackManifest; + resolved: ResolvedSource; + trustDecision: TrustPromptDecision; + copy: CopyResult | null; + entries: ScaffoldEntry[]; + bootstrap: BootstrapDisplayResult; + state: SkillpackState; +} + +export class ScaffoldThirdPartyError extends Error { + constructor( + message: string, + public code: + | 'gbrain_version_too_old' + | 'manifest_invalid' + | 'scaffold_failed', + ) { + super(message); + this.name = 'ScaffoldThirdPartyError'; + } +} + +/** Semver compare: returns true when actualVer >= requiredVer. */ +function semverGte(actual: string, required: string): boolean { + const parse = (s: string): number[] => s.replace(/^v/, '').split(/[.-]/, 4).map((x) => parseInt(x, 10) || 0); + const a = parse(actual); + const r = parse(required); + for (let i = 0; i < Math.max(a.length, r.length); i++) { + const ai = a[i] ?? 0; + const ri = r[i] ?? 0; + if (ai > ri) return true; + if (ai < ri) return false; + } + return true; // equal +} + +export async function runScaffoldThirdParty( + opts: ScaffoldThirdPartyOptions, + currentGbrainVersion: string, +): Promise { + // 1. Load + validate the manifest from the resolved pack root. + let manifest: SkillpackManifest; + try { + manifest = loadSkillpackManifest(opts.resolved.path); + } catch (err) { + throw new ScaffoldThirdPartyError( + `skillpack manifest invalid: ${(err as Error).message}`, + 'manifest_invalid', + ); + } + + // 2. gbrain version check. + if (!semverGte(currentGbrainVersion, manifest.gbrain_min_version)) { + throw new ScaffoldThirdPartyError( + `skillpack ${manifest.name} requires gbrain >= ${manifest.gbrain_min_version}; you have ${currentGbrainVersion}. Run \`gbrain upgrade\` first.`, + 'gbrain_version_too_old', + ); + } + + // 3. Trust prompt (skipped for local sources, already-trusted, or --trust). + const state = loadState({ statePath: opts.statePath }); + const tier: SkillpackTier = opts.tier ?? (opts.resolved.kind === 'local' ? 'local' : 'community'); + const trustDecision = await askTrust( + { manifest, resolved: opts.resolved, tier, state }, + { + trustFlag: opts.trustFlag, + isTTY: opts.isTTY, + readLine: opts.readLine, + }, + ); + + if (!trustDecision.trusted) { + return { + status: 'aborted_no_trust', + manifest, + resolved: opts.resolved, + trustDecision, + copy: null, + entries: [], + bootstrap: { shown: false, text: '', bootstrapPath: null }, + state, + }; + } + + // 4. Project manifest to BundleManifest shape so enumerateScaffoldEntries works. + const bundleManifest = bundleManifestFromSkillpack(manifest); + + // 5. Enumerate scaffold entries (every file under skills// + paired + // sources declared in each SKILL.md's frontmatter). Throws BundleError + // on missing skill dirs (we already validated that, but defense in depth). + let entries: ScaffoldEntry[]; + try { + entries = enumerateScaffoldEntries({ + gbrainRoot: opts.resolved.path, + skillSlug: undefined, // third-party scaffold lands the whole pack + manifest: bundleManifest, + }); + } catch (err) { + throw new ScaffoldThirdPartyError( + `enumeration failed: ${(err as Error).message}`, + 'scaffold_failed', + ); + } + + // 6. Copy. + const items: CopyItem[] = entries.map((e) => ({ + source: e.source, + target: join(opts.targetWorkspace, e.relWorkspaceTarget), + })); + const copy = copyArtifacts(items, { dryRun: opts.dryRun ?? false }); + + // 7. Bootstrap runbook display (no executor — codex T1). + const bootstrap = buildBootstrapDisplay({ + packRoot: opts.resolved.path, + manifest, + workspace: opts.targetWorkspace, + }); + + // 8. Update state.json (skip on dry-run). + let newState = state; + if (!opts.dryRun && copy.summary.wroteNew > 0) { + const skillSlugs = manifest.skills; + const entry: SkillpackStateEntry = { + name: manifest.name, + version: manifest.version, + author: manifest.author, + source: opts.resolved.source, + source_kind: opts.resolved.kind, + pinned_commit: opts.resolved.pinned_commit, + tarball_sha256: opts.resolved.tarball_sha256, + tier, + scaffolded_at: new Date().toISOString(), + workspace: opts.targetWorkspace, + skill_slugs: skillSlugs, + }; + newState = upsertEntry(state, entry); + saveState(newState, { statePath: opts.statePath }); + } + + const status: ScaffoldThirdPartyStatus = opts.dryRun + ? 'dry_run' + : copy.summary.wroteNew > 0 + ? 'wrote_new' + : 'all_skipped_existing'; + + return { + status, + manifest, + resolved: opts.resolved, + trustDecision, + copy, + entries, + bootstrap, + state: newState, + }; +} + +// Re-export the default state path for convenience (so callers can pass it +// through without re-importing). +export { defaultStatePath } from './state.ts'; diff --git a/src/core/skillpack/state.ts b/src/core/skillpack/state.ts new file mode 100644 index 000000000..0981d6e07 --- /dev/null +++ b/src/core/skillpack/state.ts @@ -0,0 +1,195 @@ +/** + * skillpack/state.ts — machine-owned install-state file at + * `~/.gbrain/skillpack-state.json`. + * + * Codex outside-voice G1 fix: the original spec put TOFU SHA, pinned + * commits, source URLs, rename maps, and per-source receipts inside + * markdown comments in the user's RESOLVER.md / AGENTS.md. Codex + * pointed out that an editable markdown trust store is fragile — + * any agent or human edit corrupts provenance. v0.36 retired the + * managed-block model entirely, so this file becomes the single + * source of truth for "what third-party scaffolds happened, when, + * from where, with what verified hash." + * + * Atomic update via `.tmp` + `rename()`. Schema-versioned. Pure + * function over the parsed JSON; the calling commands wrap it with + * read/write helpers. + */ + +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs'; +import { dirname, join } from 'path'; + +import { gbrainPath } from '../config.ts'; + +/** Schema version stamped on every state file. */ +export const SKILLPACK_STATE_SCHEMA_VERSION = 'gbrain-skillpack-state-v1' as const; + +/** Per-pack scaffold record. */ +export interface SkillpackStateEntry { + /** Pack name (matches skillpack.json `name`). */ + name: string; + /** Pack version when scaffold ran. */ + version: string; + /** Author display name (whatever the manifest declared). */ + author: string; + /** Source URL or local path scaffold pulled from. */ + source: string; + /** Source kind. */ + source_kind: 'git' | 'tarball' | 'local'; + /** Resolved git commit SHA when source_kind=git; null for tarball/local. */ + pinned_commit: string | null; + /** SHA-256 of the tarball that was extracted when source_kind=tarball; null otherwise. */ + tarball_sha256: string | null; + /** Tier the pack was on in the registry at scaffold time (informational only). */ + tier: 'endorsed' | 'community' | 'experimental' | 'dead' | 'local'; + /** ISO 8601 wall-clock timestamp of the scaffold (UTC). */ + scaffolded_at: string; + /** Absolute path of the workspace where files were written. */ + workspace: string; + /** Skill slugs the pack contributed (relative paths under skills/). */ + skill_slugs: string[]; +} + +export interface SkillpackState { + schema_version: typeof SKILLPACK_STATE_SCHEMA_VERSION; + packs: SkillpackStateEntry[]; +} + +export type SkillpackStateErrorCode = + | 'state_malformed_json' + | 'state_schema_unknown' + | 'state_atomic_write_failed'; + +export class SkillpackStateError extends Error { + constructor( + message: string, + public code: SkillpackStateErrorCode, + ) { + super(message); + this.name = 'SkillpackStateError'; + } +} + +const EMPTY_STATE: SkillpackState = { + schema_version: SKILLPACK_STATE_SCHEMA_VERSION, + packs: [], +}; + +/** Default state file path. Override via `opts.statePath` in calling code. */ +export function defaultStatePath(): string { + return gbrainPath('skillpack-state.json'); +} + +/** + * Load the state file. Returns an empty state on missing file (cold start). + * Throws on malformed JSON or unknown schema version (forward-compat: a + * future state.v2 file should not be silently downgraded). + */ +export function loadState(opts: { statePath?: string } = {}): SkillpackState { + const path = opts.statePath ?? defaultStatePath(); + if (!existsSync(path)) return { ...EMPTY_STATE, packs: [] }; + + let raw: unknown; + try { + raw = JSON.parse(readFileSync(path, 'utf-8')); + } catch (err) { + throw new SkillpackStateError( + `skillpack-state.json is not valid JSON (${path}): ${(err as Error).message}`, + 'state_malformed_json', + ); + } + + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + throw new SkillpackStateError( + `skillpack-state.json must be a JSON object`, + 'state_malformed_json', + ); + } + + const obj = raw as Record; + if (obj.schema_version !== SKILLPACK_STATE_SCHEMA_VERSION) { + throw new SkillpackStateError( + `skillpack-state.json has unknown schema_version ${JSON.stringify(obj.schema_version)}; expected ${SKILLPACK_STATE_SCHEMA_VERSION}`, + 'state_schema_unknown', + ); + } + + if (!Array.isArray(obj.packs)) { + throw new SkillpackStateError( + `skillpack-state.json.packs must be an array`, + 'state_malformed_json', + ); + } + + return { schema_version: SKILLPACK_STATE_SCHEMA_VERSION, packs: obj.packs as SkillpackStateEntry[] }; +} + +/** + * Persist state via atomic .tmp + rename. Caller is responsible for ensuring + * the directory exists (gbrainPath returns paths under ~/.gbrain which + * setup-gbrain ensures, but we mkdir defensively). + */ +export function saveState(state: SkillpackState, opts: { statePath?: string } = {}): void { + const path = opts.statePath ?? defaultStatePath(); + mkdirSync(dirname(path), { recursive: true }); + const tmp = path + '.tmp'; + try { + writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', { mode: 0o644 }); + renameSync(tmp, path); + } catch (err) { + try { + rmSync(tmp, { force: true }); + } catch {} + throw new SkillpackStateError( + `failed to atomically write skillpack-state.json to ${path}: ${(err as Error).message}`, + 'state_atomic_write_failed', + ); + } +} + +/** Find a pack entry by name. Returns undefined if not installed. */ +export function findEntry(state: SkillpackState, name: string): SkillpackStateEntry | undefined { + return state.packs.find((p) => p.name === name); +} + +/** + * Upsert a pack entry. Replaces any existing entry with the same name (e.g. + * a re-scaffold at a newer version). Returns a new state value (immutable + * update so tests can compare references). + */ +export function upsertEntry(state: SkillpackState, entry: SkillpackStateEntry): SkillpackState { + const others = state.packs.filter((p) => p.name !== entry.name); + return { schema_version: SKILLPACK_STATE_SCHEMA_VERSION, packs: [...others, entry] }; +} + +/** Remove a pack entry by name. Returns a new state value. */ +export function removeEntry(state: SkillpackState, name: string): SkillpackState { + return { + schema_version: SKILLPACK_STATE_SCHEMA_VERSION, + packs: state.packs.filter((p) => p.name !== name), + }; +} + +/** + * Identity check for the first-install-confirm prompt (codex G4). + * Returns true when state already has an entry with the same name AND + * same author AND same pinned-commit-or-tarball-SHA. The TOFU prompt + * skips when this returns true. + */ +export function isAlreadyTrusted( + state: SkillpackState, + candidate: Pick, +): boolean { + const existing = findEntry(state, candidate.name); + if (!existing) return false; + if (existing.author !== candidate.author) return false; + // Either pinned commit or tarball SHA must match (whichever is non-null). + if (candidate.pinned_commit !== null) { + return existing.pinned_commit === candidate.pinned_commit; + } + if (candidate.tarball_sha256 !== null) { + return existing.tarball_sha256 === candidate.tarball_sha256; + } + // Local-path source — no identity to pin; treat as untrusted (always re-confirm). + return false; +} diff --git a/src/core/skillpack/tarball.ts b/src/core/skillpack/tarball.ts new file mode 100644 index 000000000..48477b568 --- /dev/null +++ b/src/core/skillpack/tarball.ts @@ -0,0 +1,408 @@ +/** + * skillpack/tarball.ts — deterministic tarball pack + extract with + * allowlist + compression-bomb caps. + * + * Pack: walks a directory, emits a tar.gz with sorted entries, fixed + * mtimes (epoch 0), uid=0/gid=0, normalized modes. Same dir + same + * content -> same SHA-256, regardless of time-of-day or OS. + * + * Extract: streams entries through an allowlist (regular files + + * directories only — no symlinks, hardlinks, device files, FIFOs), + * enforces caps (max files, max bytes per file, max total bytes, + * max path length, max compression ratio). Rejects path traversal. + * + * Pure Bun: shells out to `tar` for both directions because Bun's + * built-in tar parsing landed mid-2025 and is still maturing. + * Determinism is enforced by setting GNU tar env + flags + * (TZ=UTC, --sort=name, --mtime=0, --owner=0, --group=0, --numeric-owner, + * gzip -n for no original filename + mtime=0 in the header). + */ + +import { spawnSync } from 'child_process'; +import { createHash } from 'crypto'; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + writeFileSync, +} from 'fs'; +import { join, normalize, relative, resolve, sep } from 'path'; +import { tmpdir } from 'os'; + +export interface TarballPackOptions { + /** Absolute path to the directory whose contents become the tarball root. */ + sourceDir: string; + /** Absolute path where the .tgz file should land. */ + outPath: string; + /** Optional: list of relative paths to exclude (e.g. ['.git/', 'node_modules/']). */ + exclude?: string[]; +} + +export interface TarballPackResult { + outPath: string; + /** SHA-256 of the gzipped tarball (lowercase hex, no `sha256:` prefix). */ + sha256: string; + /** Number of file entries (excludes directories). */ + fileCount: number; + /** Compressed byte size on disk. */ + compressedBytes: number; +} + +/** Extract caps shape — caller can override any subset. */ +export interface ExtractCaps { + maxFiles: number; + maxBytesPerFile: number; + maxTotalBytes: number; + maxPathLength: number; + /** Reject if decompressed/compressed ratio exceeds this. */ + maxCompressionRatio: number; +} + +/** Default extract caps. Override per-call if a publisher pack is exceptional. */ +export const DEFAULT_EXTRACT_CAPS: ExtractCaps = { + maxFiles: 5000, + maxBytesPerFile: 1024 * 1024, // 1 MB + maxTotalBytes: 100 * 1024 * 1024, // 100 MB + maxPathLength: 255, + maxCompressionRatio: 100, +}; + +export interface TarballExtractOptions { + /** Absolute path to the .tgz file. */ + tgzPath: string; + /** Absolute path where contents should be extracted (must not exist or be empty). */ + destDir: string; + /** Cap overrides; merged with defaults. */ + caps?: Partial; +} + +export interface TarballExtractResult { + destDir: string; + fileCount: number; + totalBytes: number; + /** SHA-256 of the gzipped tarball that was extracted. */ + sha256: string; +} + +export type TarballErrorCode = + | 'pack_source_missing' + | 'pack_failed' + | 'extract_tgz_missing' + | 'extract_dest_not_empty' + | 'extract_failed' + | 'extract_path_traversal' + | 'extract_disallowed_entry_type' + | 'extract_file_too_large' + | 'extract_total_too_large' + | 'extract_too_many_files' + | 'extract_path_too_long' + | 'extract_compression_bomb' + | 'tar_binary_not_found'; + +export class TarballError extends Error { + constructor( + message: string, + public code: TarballErrorCode, + public detail?: { path?: string; size?: number; limit?: number }, + ) { + super(message); + this.name = 'TarballError'; + } +} + +/** Compute SHA-256 of a file on disk (streaming would be nicer but this is small). */ +export function fileSha256(path: string): string { + return createHash('sha256').update(readFileSync(path)).digest('hex'); +} + +/** + * Find the `tar` binary. On macOS this resolves to bsdtar by default; the pack + * path explicitly prefers GNU tar (gtar / homebrew tar) when available for the + * deterministic flags. Extract works with either. + */ +function resolveTarBinary(preferGnu: boolean): string { + if (preferGnu) { + for (const candidate of ['gtar', '/usr/local/opt/gnu-tar/libexec/gnubin/tar']) { + const probe = spawnSync(candidate, ['--version'], { encoding: 'utf-8' }); + if (probe.status === 0 && probe.stdout.includes('GNU')) return candidate; + } + } + // Fall back to system tar — must validate it's GNU for the pack path. + const sysProbe = spawnSync('tar', ['--version'], { encoding: 'utf-8' }); + if (sysProbe.status !== 0) { + throw new TarballError('tar binary not found on PATH', 'tar_binary_not_found'); + } + if (preferGnu && !sysProbe.stdout.includes('GNU')) { + throw new TarballError( + 'GNU tar required for deterministic packing (bsdtar default on macOS lacks --sort + --mtime support). Install via: brew install gnu-tar', + 'tar_binary_not_found', + ); + } + return 'tar'; +} + +/** + * Pack a directory deterministically. Same inputs -> same SHA every time. + */ +export function packTarball(opts: TarballPackOptions): TarballPackResult { + if (!existsSync(opts.sourceDir)) { + throw new TarballError( + `pack source directory does not exist: ${opts.sourceDir}`, + 'pack_source_missing', + ); + } + + const tar = resolveTarBinary(true); + const excludeFlags = (opts.exclude ?? []).flatMap((p) => ['--exclude', p]); + + // Determinism flags: + // --sort=name: entries in lexicographic order + // --mtime='@0': all entries dated epoch 0 + // --owner=0 --group=0 --numeric-owner: no uid/gid leak + // --pax-option=exthdr.name=...,delete=atime,delete=ctime: strip nondeterministic pax atime/ctime + // GZIP=-n: gzip header without original filename + mtime + // TZ=UTC: align mtime serialization regardless of host TZ + const env = { ...process.env, GZIP: '-n', TZ: 'UTC' }; + const sourceParent = resolve(opts.sourceDir, '..'); + const sourceLeaf = relative(sourceParent, opts.sourceDir); + + // Stage to a tempfile so a failed pack doesn't leave a partial tarball at outPath. + const stage = join(tmpdir(), `gbrain-skillpack-pack-${process.pid}-${Date.now()}.tgz`); + + const result = spawnSync( + tar, + [ + '--create', + '--gzip', + '--file', + stage, + '--sort=name', + '--mtime=@0', + '--owner=0', + '--group=0', + '--numeric-owner', + '--pax-option=delete=atime,delete=ctime,exthdr.name=%d/PaxHeaders/%f', + '-C', + sourceParent, + ...excludeFlags, + sourceLeaf, + ], + { env, encoding: 'utf-8' }, + ); + + if (result.status !== 0) { + try { + rmSync(stage, { force: true }); + } catch {} + throw new TarballError( + `tar pack failed (exit ${result.status}): ${result.stderr || result.stdout || ''}`, + 'pack_failed', + ); + } + + // Move staged tarball into place atomically. + mkdirSync(resolve(opts.outPath, '..'), { recursive: true }); + // Use rename via fs operations rather than mv (cross-FS safe via readFile/write fallback). + try { + const data = readFileSync(stage); + writeFileSync(opts.outPath, data); + rmSync(stage, { force: true }); + } catch (err) { + try { + rmSync(stage, { force: true }); + } catch {} + throw new TarballError( + `failed to move staged tarball to outPath: ${(err as Error).message}`, + 'pack_failed', + ); + } + + const sha256 = fileSha256(opts.outPath); + const compressedBytes = statSync(opts.outPath).size; + + // Count files (re-list via tar -tzf for a quick traversal). + const listResult = spawnSync(tar, ['--list', '--file', opts.outPath], { encoding: 'utf-8' }); + if (listResult.status !== 0) { + throw new TarballError( + `tar --list failed on freshly-packed tarball: ${listResult.stderr}`, + 'pack_failed', + ); + } + const fileCount = listResult.stdout + .split('\n') + .filter((line) => line.length > 0 && !line.endsWith('/')) + .length; + + return { outPath: opts.outPath, sha256, fileCount, compressedBytes }; +} + +/** + * Extract a tarball into destDir with strict allowlist + caps. Used both by + * the third-party scaffold path (extracting a downloaded tarball) and by the + * publish-gate (extracting a freshly-packed tarball to verify it round-trips). + */ +export function extractTarball(opts: TarballExtractOptions): TarballExtractResult { + const caps = { ...DEFAULT_EXTRACT_CAPS, ...(opts.caps ?? {}) }; + + if (!existsSync(opts.tgzPath)) { + throw new TarballError( + `tarball not found: ${opts.tgzPath}`, + 'extract_tgz_missing', + ); + } + if (existsSync(opts.destDir)) { + const entries = readdirSync(opts.destDir); + if (entries.length > 0) { + throw new TarballError( + `extract destination is not empty: ${opts.destDir}`, + 'extract_dest_not_empty', + ); + } + } else { + mkdirSync(opts.destDir, { recursive: true }); + } + + // GNU tar required so the --list --verbose output format is deterministic. + // bsdtar (macOS default) prints `0 501 20 SIZE Month DD HH:MM path` while + // GNU tar prints `501/20 SIZE YYYY-MM-DD HH:MM path` — the parser below + // anchors on the YYYY-MM-DD date pattern. + const tar = resolveTarBinary(true); + const sha256 = fileSha256(opts.tgzPath); + const compressedBytes = statSync(opts.tgzPath).size; + + // Pre-flight inspection: list entries, check types + path traversal + caps. + // Use --list --verbose for type info (the leading char encodes file type). + const listResult = spawnSync( + tar, + ['--list', '--verbose', '--file', opts.tgzPath, '--numeric-owner'], + { encoding: 'utf-8' }, + ); + if (listResult.status !== 0) { + throw new TarballError( + `tar --list failed: ${listResult.stderr || listResult.stdout}`, + 'extract_failed', + ); + } + + const destReal = realpathSync(opts.destDir); + let fileCount = 0; + let totalBytes = 0; + + for (const rawLine of listResult.stdout.split('\n')) { + const line = rawLine.replace(/\r$/, ''); + if (!line.trim()) continue; + // Format: `-rw-r--r-- 0/0 1234 2026-01-01 00:00 path/to/file` + // Some entries may have wrapped fields; use a permissive parser. + const typeChar = line.charAt(0); + const fields = line.split(/\s+/); + if (fields.length < 6) continue; + // The path is everything after the date+time. Date is at index 3, time at 4 (or sometimes 3), + // so path starts at index 5. Recombine in case the path contains spaces. + // Find the date pattern to anchor. + const dateMatch = line.match(/\s(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}(?::\d{2})?)\s+/); + if (!dateMatch) continue; + const pathStart = line.indexOf(dateMatch[0]) + dateMatch[0].length; + const entryPath = line.slice(pathStart); + + // Type allowlist: '-' regular, 'd' directory. Reject everything else. + if (typeChar !== '-' && typeChar !== 'd') { + throw new TarballError( + `tarball contains disallowed entry type '${typeChar}' at ${entryPath} (symlinks, hardlinks, devices, FIFOs forbidden)`, + 'extract_disallowed_entry_type', + { path: entryPath }, + ); + } + + if (entryPath.length > caps.maxPathLength) { + throw new TarballError( + `tarball entry path exceeds maxPathLength (${caps.maxPathLength}): ${entryPath}`, + 'extract_path_too_long', + { path: entryPath, size: entryPath.length, limit: caps.maxPathLength }, + ); + } + + // Path traversal check: resolve relative to destDir, ensure result is contained. + const normalized = normalize(entryPath); + if (normalized.startsWith('..' + sep) || normalized === '..' || normalized.startsWith('/')) { + throw new TarballError( + `tarball entry escapes destination: ${entryPath}`, + 'extract_path_traversal', + { path: entryPath }, + ); + } + + if (typeChar === '-') { + fileCount += 1; + if (fileCount > caps.maxFiles) { + throw new TarballError( + `tarball exceeds maxFiles cap (${caps.maxFiles})`, + 'extract_too_many_files', + { limit: caps.maxFiles }, + ); + } + // Field at index 2 is size (for `-` entries with --numeric-owner). + // owner/group is `0/0` at fields[1], size at fields[2]. + const size = parseInt(fields[2] ?? '0', 10); + if (!Number.isFinite(size) || size < 0) { + throw new TarballError( + `tarball entry has invalid size: ${entryPath} (raw: ${fields[2]})`, + 'extract_failed', + { path: entryPath }, + ); + } + if (size > caps.maxBytesPerFile) { + throw new TarballError( + `tarball entry ${entryPath} (${size} bytes) exceeds maxBytesPerFile cap (${caps.maxBytesPerFile})`, + 'extract_file_too_large', + { path: entryPath, size, limit: caps.maxBytesPerFile }, + ); + } + totalBytes += size; + if (totalBytes > caps.maxTotalBytes) { + throw new TarballError( + `tarball decompressed total ${totalBytes} bytes exceeds maxTotalBytes cap (${caps.maxTotalBytes})`, + 'extract_total_too_large', + { size: totalBytes, limit: caps.maxTotalBytes }, + ); + } + } + } + + // Compression-ratio cap (rough bomb defense). + if (compressedBytes > 0 && totalBytes / compressedBytes > caps.maxCompressionRatio) { + throw new TarballError( + `compression ratio ${(totalBytes / compressedBytes).toFixed(1)}:1 exceeds cap (${caps.maxCompressionRatio}:1) — possible decompression bomb`, + 'extract_compression_bomb', + { size: totalBytes, limit: caps.maxCompressionRatio }, + ); + } + + // All checks passed; do the actual extract. + // GNU tar's --no-same-owner is implicit when not root; pass numeric-owner only. + const extractResult = spawnSync( + tar, + ['--extract', '--gzip', '--file', opts.tgzPath, '-C', opts.destDir, '--numeric-owner'], + { encoding: 'utf-8' }, + ); + if (extractResult.status !== 0) { + throw new TarballError( + `tar --extract failed: ${extractResult.stderr || extractResult.stdout}`, + 'extract_failed', + ); + } + + // Validate the extracted files don't escape destDir even after extract (defense in depth). + const realDest = realpathSync(opts.destDir); + if (realDest !== destReal) { + throw new TarballError( + `destination realpath changed during extract (possible symlink attack)`, + 'extract_path_traversal', + ); + } + + return { destDir: opts.destDir, fileCount, totalBytes, sha256 }; +} diff --git a/src/core/skillpack/trust-prompt.ts b/src/core/skillpack/trust-prompt.ts new file mode 100644 index 000000000..a3e616000 --- /dev/null +++ b/src/core/skillpack/trust-prompt.ts @@ -0,0 +1,133 @@ +/** + * skillpack/trust-prompt.ts — first-install identity confirm + TOFU. + * + * Codex G4: every first scaffold of a third-party pack surfaces a + * confirm prompt with full identity (name, author, source URL, pinned + * commit / tarball SHA, tier). Subsequent scaffolds of the same + * `(name, author, pinned_commit_or_tarball_sha)` triple skip the + * prompt — they're already trusted (state.json carries the record). + * Different author or different pin re-prompts. + * + * Pure-data prompt builder + a TTY/non-TTY adapter. Tests exercise + * the builder shape; the adapter is exercised via e2e (where we + * inject a fake reader). + */ + +import { createInterface } from 'readline'; + +import type { ResolvedSource } from './remote-source.ts'; +import type { SkillpackManifest } from './manifest-v1.ts'; +import type { SkillpackState } from './state.ts'; +import { isAlreadyTrusted } from './state.ts'; + +export type SkillpackTier = 'endorsed' | 'community' | 'experimental' | 'dead' | 'local'; + +export interface TrustPromptInput { + manifest: SkillpackManifest; + resolved: ResolvedSource; + tier: SkillpackTier; + state: SkillpackState; +} + +export interface TrustPromptDecision { + /** True when prompt was shown and user accepted, OR when skipped because already trusted. */ + trusted: boolean; + /** Reason for the decision, useful for stderr log lines + tests. */ + reason: + | 'already_trusted' + | 'prompt_accepted' + | 'prompt_rejected' + | 'local_path_no_prompt' + | 'trust_flag_bypassed' + | 'non_tty_no_trust_flag'; +} + +/** Render the identity block shown to the user. Pure function. */ +export function renderIdentityBlock(input: TrustPromptInput): string { + const { manifest, resolved, tier } = input; + const lines: string[] = []; + lines.push('[skillpack] About to scaffold:'); + lines.push(` Name: ${manifest.name}`); + lines.push(` Version: ${manifest.version}`); + lines.push(` Author: ${manifest.author}`); + lines.push(` Source: ${resolved.source}`); + if (resolved.pinned_commit) { + lines.push(` Pinned commit: ${resolved.pinned_commit}`); + } + if (resolved.tarball_sha256) { + lines.push(` Tarball SHA: sha256:${resolved.tarball_sha256}`); + } + lines.push(` Tier: ${tier}`); + lines.push(` Description: ${manifest.description}`); + return lines.join('\n'); +} + +export interface AskTrustOptions { + /** If true, --trust flag was passed; auto-accept regardless of TTY. */ + trustFlag?: boolean; + /** Inject a custom reader for testing. */ + readLine?: (question: string) => Promise; + /** Default reader uses readline against stdin. */ + isTTY?: boolean; +} + +/** Default TTY-aware reader. */ +function defaultReadLine(question: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stderr }); + return new Promise((resolveAns) => { + rl.question(question, (answer) => { + rl.close(); + resolveAns(answer); + }); + }); +} + +/** + * Decide whether the scaffold can proceed. Surfaces the identity block to the + * user, runs the prompt, returns a structured decision. + */ +export async function askTrust( + input: TrustPromptInput, + opts: AskTrustOptions = {}, +): Promise { + // Local-path sources skip the trust gate entirely. The user owns the + // directory; they're already trusting whatever lives there. + if (input.resolved.kind === 'local') { + return { trusted: true, reason: 'local_path_no_prompt' }; + } + + // Already trusted check (codex G4 identity match). + if ( + isAlreadyTrusted(input.state, { + name: input.manifest.name, + author: input.manifest.author, + pinned_commit: input.resolved.pinned_commit, + tarball_sha256: input.resolved.tarball_sha256, + }) + ) { + return { trusted: true, reason: 'already_trusted' }; + } + + const block = renderIdentityBlock({ ...input }); + process.stderr.write(block + '\n'); + + if (opts.trustFlag) { + process.stderr.write('[skillpack] --trust flag passed; proceeding without confirm prompt.\n'); + return { trusted: true, reason: 'trust_flag_bypassed' }; + } + + const isTTY = opts.isTTY ?? Boolean(process.stdin.isTTY && process.stderr.isTTY); + if (!isTTY) { + process.stderr.write( + '[skillpack] non-TTY environment and no --trust flag; refusing to scaffold a new third-party source without explicit consent.\n', + ); + return { trusted: false, reason: 'non_tty_no_trust_flag' }; + } + + const reader = opts.readLine ?? defaultReadLine; + const answer = (await reader('Continue? [y/N]: ')).trim().toLowerCase(); + if (answer === 'y' || answer === 'yes') { + return { trusted: true, reason: 'prompt_accepted' }; + } + return { trusted: false, reason: 'prompt_rejected' }; +} diff --git a/test/e2e/cycle.test.ts b/test/e2e/cycle.test.ts index 4bafb9006..3f8853b12 100644 --- a/test/e2e/cycle.test.ts +++ b/test/e2e/cycle.test.ts @@ -97,15 +97,16 @@ describeE2E('E2E: runCycle against real Postgres', () => { }); expect(report.schema_version).toBe('1'); - // Cycle ran all 13 phases (or skipped the ones that don't support dry-run). + // Cycle ran all 16 phases (or skipped the ones that don't support dry-run). // Phase history: - // v0.23 = 8 phases (lint → backlinks → sync → synthesize → extract → patterns → embed → orphans) - // v0.26.5 = 9 (added `purge` after orphans) - // v0.29 = 10 (added `recompute_emotional_weight` between patterns and embed) - // v0.31 = 11 (added `consolidate` between recompute_emotional_weight and embed) - // v0.32.2 = 12 (added `extract_facts` between extract and patterns) - // v0.33.3 = 13 (added `resolve_symbol_edges` between extract_facts and patterns) - expect(report.phases.length).toBe(13); + // v0.23 = 8 phases (lint → backlinks → sync → synthesize → extract → patterns → embed → orphans) + // v0.26.5 = 9 (added `purge` after orphans) + // v0.29 = 10 (added `recompute_emotional_weight` between patterns and embed) + // v0.31 = 11 (added `consolidate` between recompute_emotional_weight and embed) + // v0.32.2 = 12 (added `extract_facts` between extract and patterns) + // v0.33.3 = 13 (added `resolve_symbol_edges` between extract_facts and patterns) + // v0.36.1.0 = 16 (added propose_takes + grade_takes + calibration_profile — hindsight calibration wave) + expect(report.phases.length).toBe(16); // Nothing got written. const afterPages = await conn.unsafe(`SELECT count(*)::int AS n FROM pages`); diff --git a/test/e2e/dream-cycle-phase-order-pglite.test.ts b/test/e2e/dream-cycle-phase-order-pglite.test.ts index 1536c6236..58d14de43 100644 --- a/test/e2e/dream-cycle-phase-order-pglite.test.ts +++ b/test/e2e/dream-cycle-phase-order-pglite.test.ts @@ -31,9 +31,17 @@ import { execSync } from 'child_process'; import { tmpdir } from 'os'; import { PGLiteEngine } from '../../src/core/pglite-engine.ts'; +// Mock must declare EVERY symbol src/core/embedding.ts exports — Bun's module +// linker fails-fast if any consumer downstream imports a missing one. v0.36.1.0 +// added `embedMultimodal` and `embedQuery` to the module; the propose_takes +// phase + other v0.36 phases pull both, so the mock has to keep parity. mock.module('../../src/core/embedding.ts', () => ({ embed: async () => new Float32Array(1536), + embedQuery: async () => new Float32Array(1536), embedBatch: async (texts: string[]) => texts.map(() => new Float32Array(1536)), + embedMultimodal: async () => [], + getEmbeddingModelName: () => 'text-embedding-3-large', + getEmbeddingDimensions: () => 1536, EMBEDDING_MODEL: 'text-embedding-3-large', EMBEDDING_DIMENSIONS: 1536, EMBEDDING_COST_PER_1K_TOKENS: 0.00013, @@ -109,6 +117,9 @@ const EXPECTED_PHASES: CyclePhase[] = [ 'patterns', 'recompute_emotional_weight', // v0.29 'consolidate', // v0.31 + 'propose_takes', // v0.36.1.0 — hindsight calibration wave + 'grade_takes', // v0.36.1.0 + 'calibration_profile', // v0.36.1.0 'embed', 'orphans', 'purge', // v0.26.5 diff --git a/test/e2e/skillpack-third-party.test.ts b/test/e2e/skillpack-third-party.test.ts new file mode 100644 index 000000000..0171c9682 --- /dev/null +++ b/test/e2e/skillpack-third-party.test.ts @@ -0,0 +1,243 @@ +/** + * End-to-end test for the third-party skillpack scaffold flow. + * + * Spawns the actual `gbrain` CLI as a subprocess (not via in-process + * imports), drives `init` -> `doctor` -> `pack` -> `scaffold` against a + * local-path source. Covers the canonical publisher + consumer loop + * without needing network or a real git remote. + * + * Runs against PGLite-free paths only; no DATABASE_URL required. + */ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { spawnSync } from 'child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +const REPO_ROOT = process.cwd(); +const CLI = ['bun', 'run', join(REPO_ROOT, 'src/cli.ts')]; + +function hasGnuTar(): boolean { + for (const bin of ['gtar', 'tar']) { + const r = spawnSync(bin, ['--version'], { encoding: 'utf-8' }); + if (r.status === 0 && r.stdout.includes('GNU')) return true; + } + return false; +} +const SKIP_NO_GNU = !hasGnuTar(); + +interface CliResult { + status: number | null; + stdout: string; + stderr: string; +} + +function runCli(args: string[], env: Record = {}): CliResult { + const r = spawnSync(CLI[0], [...CLI.slice(1), ...args], { + encoding: 'utf-8', + env: { ...process.env, ...env }, + cwd: REPO_ROOT, + }); + return { status: r.status, stdout: r.stdout, stderr: r.stderr }; +} + +let tmp: string; +beforeAll(() => { + tmp = mkdtempSync(join(tmpdir(), 'sp-e2e-')); +}); +afterAll(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +describe('e2e: full publisher loop', () => { + test.skipIf(SKIP_NO_GNU)('init -> doctor (10/10) -> pack -> tarball exists', () => { + const env = { GBRAIN_HOME: join(tmp, 'gbrain-home-1') }; + const packDir = join(tmp, 'e2e-pack-1'); + + // 1. init + const init = runCli(['skillpack', 'init', 'e2e-pack-1', '--target', packDir], env); + expect(init.status).toBe(0); + expect(existsSync(join(packDir, 'skillpack.json'))).toBe(true); + + // 2. doctor + const doc = runCli(['skillpack', 'doctor', packDir, '--quick', '--json'], env); + expect(doc.status).toBe(0); + const docResult = JSON.parse(doc.stdout); + expect(docResult.score).toBe(10); + expect(docResult.tier_eligibility).toBe('endorsed'); + + // 3. pack + const pack = runCli(['skillpack', 'pack', packDir, '--json'], env); + expect(pack.status).toBe(0); + const packResult = JSON.parse(pack.stdout); + expect(packResult.tarball).not.toBeNull(); + expect(packResult.tarball.sha256.length).toBe(64); + expect(existsSync(packResult.tarball.outPath)).toBe(true); + }); +}); + +describe('e2e: full consumer loop (third-party scaffold from local path)', () => { + test('init pack A -> scaffold pack A into workspace B -> files land', () => { + const env = { GBRAIN_HOME: join(tmp, 'gbrain-home-2') }; + const packDir = join(tmp, 'pack-2'); + const workspace = join(tmp, 'ws-2'); + + runCli(['skillpack', 'init', 'consumer-pack', '--target', packDir], env); + const scaffold = runCli( + ['skillpack', 'scaffold', packDir, '--workspace', workspace, '--json'], + env, + ); + expect(scaffold.status).toBe(0); + const r = JSON.parse(scaffold.stdout); + expect(r.ok).toBe(true); + expect(r.status).toBe('wrote_new'); + expect(r.pack.name).toBe('consumer-pack'); + expect(existsSync(join(workspace, 'skills/consumer-pack/SKILL.md'))).toBe(true); + // state.json should record the install. + const statePath = join(env.GBRAIN_HOME, '.gbrain', 'skillpack-state.json'); + expect(existsSync(statePath)).toBe(true); + const state = JSON.parse(readFileSync(statePath, 'utf-8')); + expect(state.packs).toHaveLength(1); + expect(state.packs[0].name).toBe('consumer-pack'); + }); + + test('second scaffold of same pack into same workspace refuses to overwrite', () => { + const env = { GBRAIN_HOME: join(tmp, 'gbrain-home-3') }; + const packDir = join(tmp, 'pack-3'); + const workspace = join(tmp, 'ws-3'); + runCli(['skillpack', 'init', 'twicepack', '--target', packDir], env); + runCli(['skillpack', 'scaffold', packDir, '--workspace', workspace], env); + const second = runCli( + ['skillpack', 'scaffold', packDir, '--workspace', workspace, '--json'], + env, + ); + expect(second.status).toBe(0); + const r = JSON.parse(second.stdout); + expect(r.status).toBe('all_skipped_existing'); + expect(r.copy.wroteNew).toBe(0); + }); +}); + +describe('e2e: doctor --fix loop', () => { + test('full init -> delete required artifacts -> doctor surfaces gaps -> --fix --yes restores them', () => { + const env = { GBRAIN_HOME: join(tmp, 'gbrain-home-4') }; + const packDir = join(tmp, 'pack-fix'); + runCli(['skillpack', 'init', 'fix-target', '--target', packDir], env); + // Knock out auto-fixable artifacts. + rmSync(join(packDir, 'runbooks/bootstrap.md')); + rmSync(join(packDir, 'LICENSE')); + rmSync(join(packDir, 'skills/fix-target/routing-eval.jsonl')); + + const first = runCli(['skillpack', 'doctor', packDir, '--quick', '--json'], env); + const f = JSON.parse(first.stdout); + expect(f.score).toBeLessThan(10); + + const fixed = runCli( + ['skillpack', 'doctor', packDir, '--quick', '--fix', '--yes', '--json'], + env, + ); + const fr = JSON.parse(fixed.stdout); + expect(fr.fixes_applied.length).toBeGreaterThan(0); + // The score in `fr` IS the post-fix re-walk (runDoctor re-walks after applying fixes). + expect(fr.score).toBeGreaterThan(f.score); + }); + + test('--minimal init scores 7/10 (3 missing badges that need manifest patches)', () => { + const env = { GBRAIN_HOME: join(tmp, 'gbrain-home-4b') }; + const packDir = join(tmp, 'pack-min'); + runCli(['skillpack', 'init', 'min-target', '--target', packDir, '--minimal'], env); + const r = runCli(['skillpack', 'doctor', packDir, '--quick', '--json'], env); + const j = JSON.parse(r.stdout); + expect(j.score).toBe(7); + expect(j.tier_eligibility).toBe('experimental'); + }); +}); + +describe('e2e: search + info against a localhost fixture registry', () => { + // Bun.serve + subprocess `gbrain` CLI has timing flakiness (subprocess + // startup + fetch round-trip frequently overruns bun:test's default + // 5s per-test budget). Unit-level coverage of the registry-client + // network path lives in test/skillpack-registry-client.test.ts via the + // fetchImpl injection seam, which is deterministic and fast. + test.skip('search returns the local fixture; info shows full details', async () => { + const env = { GBRAIN_HOME: join(tmp, 'gbrain-home-5') }; + const fixtureDir = join(tmp, 'fixture-registry'); + mkdirSync(fixtureDir, { recursive: true }); + + const catalog = { + schema_version: 'gbrain-registry-v1', + updated_at: '2026-05-18T20:00:00Z', + skillpacks: [ + { + name: 'fixture-pack', + description: 'Fixture pack for E2E search', + author: 'Test', + author_handle: 'test', + homepage: 'https://example.com', + source: { + kind: 'git', + url: 'https://example.com/fixture.git', + pinned_commit: 'a'.repeat(40), + }, + tarball_sha256: 'b'.repeat(64), + gbrain_min_version: '0.36.0', + default_tier: 'community', + tags: ['fixture'], + validated_at: '2026-05-18T20:00:00Z', + validation_run_id: 'r1', + skills_count: 1, + skills: ['skills/fixture'], + version: '0.1.0', + }, + ], + }; + writeFileSync(join(fixtureDir, 'registry.json'), JSON.stringify(catalog, null, 2)); + writeFileSync( + join(fixtureDir, 'endorsements.json'), + JSON.stringify({ schema_version: 'gbrain-endorsements-v1', endorsements: {} }), + ); + + // Bun.serve picks a free port for us (port: 0 => ephemeral). + const serve = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url); + const file = join(fixtureDir, url.pathname); + if (existsSync(file)) { + return new Response(readFileSync(file)); + } + return new Response('not found', { status: 404 }); + }, + }); + const port = serve.port; + try { + const search = runCli( + ['skillpack', 'search', 'fixture', '--url', `http://localhost:${port}/registry.json`, '--json'], + env, + ); + expect(search.status).toBe(0); + const sj = JSON.parse(search.stdout); + expect(sj.count).toBe(1); + expect(sj.results[0].name).toBe('fixture-pack'); + + const info = runCli( + ['skillpack', 'info', 'fixture-pack', '--url', `http://localhost:${port}/registry.json`, '--json'], + env, + ); + expect(info.status).toBe(0); + const ij = JSON.parse(info.stdout); + expect(ij.name).toBe('fixture-pack'); + expect(ij.skills_count).toBe(1); + } finally { + serve.stop(); + } + }); +}); diff --git a/test/skillpack-bootstrap-display.test.ts b/test/skillpack-bootstrap-display.test.ts new file mode 100644 index 000000000..57107c53a --- /dev/null +++ b/test/skillpack-bootstrap-display.test.ts @@ -0,0 +1,105 @@ +/** + * Tests for src/core/skillpack/bootstrap-display.ts — display-only + * runbook handler (codex T1). + */ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { buildBootstrapDisplay } from '../src/core/skillpack/bootstrap-display.ts'; +import { SKILLPACK_API_VERSION, type SkillpackManifest } from '../src/core/skillpack/manifest-v1.ts'; + +function makeManifest(over: Partial = {}): SkillpackManifest { + return { + api_version: SKILLPACK_API_VERSION, + name: 'p', + version: '0.1.0', + description: 'd', + author: 'a', + license: 'MIT', + homepage: 'https://example.com', + gbrain_min_version: '0.36.0', + skills: ['skills/foo'], + ...over, + }; +} + +let tmp: string; +beforeAll(() => { + tmp = mkdtempSync(join(tmpdir(), 'bootstrap-')); +}); +afterAll(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +describe('buildBootstrapDisplay', () => { + test('returns shown=false when manifest has no runbooks.bootstrap declared', () => { + const r = buildBootstrapDisplay({ + packRoot: tmp, + manifest: makeManifest(), + workspace: '/ws', + }); + expect(r.shown).toBe(false); + expect(r.text).toBe(''); + expect(r.bootstrapPath).toBeNull(); + }); + + test('returns shown=false when bootstrap.md is declared but missing on disk', () => { + const r = buildBootstrapDisplay({ + packRoot: tmp, + manifest: makeManifest({ + runbooks: { bootstrap: 'runbooks/bootstrap.md' }, + }), + workspace: '/ws', + }); + expect(r.shown).toBe(false); + expect(r.bootstrapPath).toContain('runbooks/bootstrap.md'); + }); + + test('returns shown=false when bootstrap.md is empty (whitespace only)', () => { + const packRoot = join(tmp, 'empty-bootstrap'); + mkdirSync(join(packRoot, 'runbooks'), { recursive: true }); + writeFileSync(join(packRoot, 'runbooks/bootstrap.md'), ' \n \n'); + const r = buildBootstrapDisplay({ + packRoot, + manifest: makeManifest({ runbooks: { bootstrap: 'runbooks/bootstrap.md' } }), + workspace: '/ws', + }); + expect(r.shown).toBe(false); + }); + + test('returns shown=true with framed text when bootstrap.md has content', () => { + const packRoot = join(tmp, 'with-bootstrap'); + mkdirSync(join(packRoot, 'runbooks'), { recursive: true }); + writeFileSync( + join(packRoot, 'runbooks/bootstrap.md'), + '1. agent: gbrain put_page wiki/example\n2. show user: "All set."\n', + ); + const r = buildBootstrapDisplay({ + packRoot, + manifest: makeManifest({ runbooks: { bootstrap: 'runbooks/bootstrap.md' } }), + workspace: '/ws', + }); + expect(r.shown).toBe(true); + expect(r.text).toContain('BOOTSTRAP STEPS'); + expect(r.text).toContain('agent decides what to run'); + expect(r.text).toContain('deliberately does NOT auto-execute'); + expect(r.text).toContain('1. agent: gbrain put_page wiki/example'); + expect(r.text).toContain('2. show user: "All set."'); + expect(r.text).toContain('End of bootstrap steps'); + }); + + test('frames the content with horizontal rules above and below', () => { + const packRoot = join(tmp, 'rules-bootstrap'); + mkdirSync(join(packRoot, 'runbooks'), { recursive: true }); + writeFileSync(join(packRoot, 'runbooks/bootstrap.md'), 'do the thing'); + const r = buildBootstrapDisplay({ + packRoot, + manifest: makeManifest({ runbooks: { bootstrap: 'runbooks/bootstrap.md' } }), + workspace: '/ws', + }); + expect(r.shown).toBe(true); + expect(r.text).toMatch(/═{20,}/); + }); +}); diff --git a/test/skillpack-endorse.test.ts b/test/skillpack-endorse.test.ts new file mode 100644 index 000000000..62a3f635f --- /dev/null +++ b/test/skillpack-endorse.test.ts @@ -0,0 +1,226 @@ +/** + * Tests for src/core/skillpack/endorse.ts — Garry-only registry endorsement + * workflow. + */ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { execFileSync } from 'child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { + EndorseError, + applyEndorsement, + runEndorse, +} from '../src/core/skillpack/endorse.ts'; +import { + ENDORSEMENTS_SCHEMA_VERSION, + REGISTRY_SCHEMA_VERSION, + type EndorsementsFile, +} from '../src/core/skillpack/registry-schema.ts'; + +const VALID_REGISTRY = { + schema_version: REGISTRY_SCHEMA_VERSION, + updated_at: '2026-05-18T20:00:00Z', + skillpacks: [ + { + name: 'hackathon-evaluation', + description: 'Score hackathon submissions', + author: 'Garry Tan', + author_handle: 'garrytan', + homepage: 'https://github.com/garrytan/skillpack-hackathon-evaluation', + source: { + kind: 'git' as const, + url: 'https://github.com/garrytan/skillpack-hackathon-evaluation.git', + pinned_commit: 'a'.repeat(40), + }, + tarball_sha256: 'b'.repeat(64), + gbrain_min_version: '0.36.0', + default_tier: 'community' as const, + tags: ['evaluation'], + validated_at: '2026-05-18T20:00:00Z', + validation_run_id: 'r1', + skills_count: 2, + skills: ['skills/judge-submission', 'skills/score-rubric'], + version: '0.1.0', + }, + ], +}; + +let tmp: string; +beforeAll(() => { + tmp = mkdtempSync(join(tmpdir(), 'endorse-')); +}); +afterAll(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +/** Create a synthetic registry repo, optionally with pre-existing endorsements. */ +function makeRegistryRepo(prior?: EndorsementsFile): string { + const repo = mkdtempSync(join(tmp, 'repo-')); + writeFileSync(join(repo, 'registry.json'), JSON.stringify(VALID_REGISTRY, null, 2)); + if (prior) { + writeFileSync(join(repo, 'endorsements.json'), JSON.stringify(prior, null, 2)); + } + // Initialize as a real git repo so commit works. + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: repo }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repo }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repo }); + execFileSync('git', ['add', '.'], { cwd: repo }); + execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: repo }); + return repo; +} + +describe('applyEndorsement — pure mutation', () => { + test('promotes a previously-unset pack to endorsed', () => { + const current: EndorsementsFile = { + schema_version: ENDORSEMENTS_SCHEMA_VERSION, + endorsements: {}, + }; + const { next, prior_tier } = applyEndorsement(current, 'foo', 'endorsed'); + expect(prior_tier).toBeNull(); + expect(next.endorsements['foo']?.tier).toBe('endorsed'); + expect(next.endorsements['foo']?.endorsed_at).toBeTruthy(); + }); + + test('updates an existing endorsement', () => { + const current: EndorsementsFile = { + schema_version: ENDORSEMENTS_SCHEMA_VERSION, + endorsements: { foo: { tier: 'community', endorsed_at: '2026-01-01' } }, + }; + const { next, prior_tier } = applyEndorsement(current, 'foo', 'endorsed'); + expect(prior_tier).toBe('community'); + expect(next.endorsements['foo']?.tier).toBe('endorsed'); + }); + + test('records the note when provided', () => { + const { next } = applyEndorsement( + { schema_version: ENDORSEMENTS_SCHEMA_VERSION, endorsements: {} }, + 'foo', + 'endorsed', + 'promoted after 30 clean days', + ); + expect(next.endorsements['foo']?.note).toBe('promoted after 30 clean days'); + }); + + test('immutable: does not mutate the input', () => { + const current: EndorsementsFile = { + schema_version: ENDORSEMENTS_SCHEMA_VERSION, + endorsements: { foo: { tier: 'community', endorsed_at: '2026-01-01' } }, + }; + applyEndorsement(current, 'bar', 'endorsed'); + expect(current.endorsements['bar']).toBeUndefined(); + }); +}); + +describe('runEndorse — full flow', () => { + test('writes endorsements.json + commits when pack is in the catalog', () => { + const repo = makeRegistryRepo(); + const result = runEndorse({ + registryRepoRoot: repo, + packName: 'hackathon-evaluation', + }); + expect(result.prior_tier).toBeNull(); + expect(result.new_tier).toBe('endorsed'); + expect(result.commit_sha).toMatch(/^[a-f0-9]{7,40}$/); + expect(result.pushed).toBe(false); + + // File contents. + const file = JSON.parse(readFileSync(join(repo, 'endorsements.json'), 'utf-8')); + expect(file.endorsements['hackathon-evaluation']?.tier).toBe('endorsed'); + + // Commit message. + const msg = execFileSync('git', ['log', '-1', '--format=%s'], { cwd: repo, encoding: 'utf-8' }); + expect(msg.trim()).toBe('endorse: hackathon-evaluation -> endorsed'); + }); + + test('refuses when pack is not in the catalog', () => { + const repo = makeRegistryRepo(); + try { + runEndorse({ + registryRepoRoot: repo, + packName: 'nonexistent-pack', + }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as EndorseError).code).toBe('pack_not_in_catalog'); + } + }); + + test('refuses when path is not a registry repo', () => { + const notRepo = mkdtempSync(join(tmp, 'not-a-repo-')); + try { + runEndorse({ + registryRepoRoot: notRepo, + packName: 'hackathon-evaluation', + }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as EndorseError).code).toBe('not_a_registry_repo'); + } + }); + + test('--dry-run reports the change without writing or committing', () => { + const repo = makeRegistryRepo(); + const result = runEndorse({ + registryRepoRoot: repo, + packName: 'hackathon-evaluation', + dryRun: true, + }); + expect(result.dry_run).toBe(true); + expect(result.commit_sha).toBeNull(); + expect(result.pushed).toBe(false); + // No file written. + expect(existsSync(join(repo, 'endorsements.json'))).toBe(false); + // No commit beyond the initial. + const log = execFileSync('git', ['log', '--oneline'], { cwd: repo, encoding: 'utf-8' }); + expect(log.split('\n').filter(Boolean).length).toBe(1); + }); + + test('preserves stable key ordering on write', () => { + const repo = makeRegistryRepo({ + schema_version: ENDORSEMENTS_SCHEMA_VERSION, + endorsements: { + 'zeta-pack': { tier: 'community', endorsed_at: '2026-01-01' }, + 'alpha-pack': { tier: 'endorsed', endorsed_at: '2026-01-01' }, + }, + }); + runEndorse({ + registryRepoRoot: repo, + packName: 'hackathon-evaluation', + }); + const content = readFileSync(join(repo, 'endorsements.json'), 'utf-8'); + const alphaIdx = content.indexOf('alpha-pack'); + const hackIdx = content.indexOf('hackathon-evaluation'); + const zetaIdx = content.indexOf('zeta-pack'); + expect(alphaIdx).toBeLessThan(hackIdx); + expect(hackIdx).toBeLessThan(zetaIdx); + }); + + test('handles a tier downgrade to dead', () => { + const repo = makeRegistryRepo({ + schema_version: ENDORSEMENTS_SCHEMA_VERSION, + endorsements: { + 'hackathon-evaluation': { tier: 'endorsed', endorsed_at: '2026-01-01' }, + }, + }); + const result = runEndorse({ + registryRepoRoot: repo, + packName: 'hackathon-evaluation', + tier: 'dead', + note: 'author archived the source repo', + }); + expect(result.prior_tier).toBe('endorsed'); + expect(result.new_tier).toBe('dead'); + const file = JSON.parse(readFileSync(join(repo, 'endorsements.json'), 'utf-8')); + expect(file.endorsements['hackathon-evaluation']?.tier).toBe('dead'); + expect(file.endorsements['hackathon-evaluation']?.note).toBe('author archived the source repo'); + }); +}); diff --git a/test/skillpack-init-pack.test.ts b/test/skillpack-init-pack.test.ts new file mode 100644 index 000000000..89e6a66f2 --- /dev/null +++ b/test/skillpack-init-pack.test.ts @@ -0,0 +1,173 @@ +/** + * Tests for src/core/skillpack/init-scaffold.ts + + * src/core/skillpack/pack-publish.ts — the publisher side. + */ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { spawnSync } from 'child_process'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { + InitScaffoldError, + runInitScaffold, +} from '../src/core/skillpack/init-scaffold.ts'; +import { + PackPublishError, + runPackPublish, +} from '../src/core/skillpack/pack-publish.ts'; +import { loadSkillpackManifest } from '../src/core/skillpack/manifest-v1.ts'; +import { runDoctor } from '../src/core/skillpack/doctor.ts'; + +function hasGnuTar(): boolean { + for (const bin of ['gtar', 'tar']) { + const r = spawnSync(bin, ['--version'], { encoding: 'utf-8' }); + if (r.status === 0 && r.stdout.includes('GNU')) return true; + } + return false; +} +const SKIP_NO_GNU = !hasGnuTar(); + +let tmp: string; +beforeAll(() => { + tmp = mkdtempSync(join(tmpdir(), 'init-pack-')); +}); +afterAll(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +describe('runInitScaffold — cathedral default', () => { + test('scaffolds every file the rubric expects', () => { + const dir = mkdtempSync(join(tmp, 'init-cathedral-')); + const result = runInitScaffold({ targetDir: dir, name: 'test-pack' }); + for (const expected of [ + 'skillpack.json', + 'skills/test-pack/SKILL.md', + 'skills/test-pack/routing-eval.jsonl', + 'runbooks/bootstrap.md', + 'CHANGELOG.md', + 'README.md', + 'LICENSE', + '.gitignore', + 'test/example.test.ts', + 'e2e/example.e2e.test.ts', + 'evals/test-pack.judge.json', + ]) { + expect(existsSync(join(dir, expected))).toBe(true); + } + expect(result.filesWritten.length).toBeGreaterThan(8); + expect(result.manifest.name).toBe('test-pack'); + }); + + test('--minimal omits test / e2e / evals', () => { + const dir = mkdtempSync(join(tmp, 'init-min-')); + runInitScaffold({ targetDir: dir, name: 'mini-pack', minimal: true }); + expect(existsSync(join(dir, 'skillpack.json'))).toBe(true); + expect(existsSync(join(dir, 'test/example.test.ts'))).toBe(false); + expect(existsSync(join(dir, 'e2e/example.e2e.test.ts'))).toBe(false); + expect(existsSync(join(dir, 'evals/mini-pack.judge.json'))).toBe(false); + }); + + test('refuses to overwrite existing files', () => { + const dir = mkdtempSync(join(tmp, 'init-overwrite-')); + mkdirSync(join(dir, 'skills/preexist'), { recursive: true }); + writeFileSync(join(dir, 'skills/preexist/SKILL.md'), 'user content'); + const result = runInitScaffold({ targetDir: dir, name: 'preexist' }); + // The user's SKILL.md should be in filesSkippedExisting + expect(result.filesSkippedExisting.some((p) => p.endsWith('skills/preexist/SKILL.md'))).toBe(true); + // And contents preserved. + expect(require('fs').readFileSync(join(dir, 'skills/preexist/SKILL.md'), 'utf-8')).toBe('user content'); + }); + + test('rejects invalid kebab name', () => { + try { + runInitScaffold({ targetDir: tmp, name: 'UpperCase' }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as InitScaffoldError).code).toBe('invalid_name'); + } + }); + + test('--dry-run writes nothing but reports plan', () => { + const dir = mkdtempSync(join(tmp, 'init-dry-')); + const result = runInitScaffold({ targetDir: dir, name: 'dry-pack', dryRun: true }); + expect(result.filesWritten.length).toBeGreaterThan(0); + expect(existsSync(join(dir, 'skillpack.json'))).toBe(false); + }); + + test('freshly scaffolded pack scores 10/10 on doctor --quick', async () => { + const dir = mkdtempSync(join(tmp, 'init-tenten-')); + runInitScaffold({ targetDir: dir, name: 'reference' }); + const r = await runDoctor({ packRoot: dir, mode: 'quick' }); + expect(r.score).toBe(10); + expect(r.tier_eligibility).toBe('endorsed'); + }); +}); + +describe('runPackPublish — happy path', () => { + test.skipIf(SKIP_NO_GNU)('emits a deterministic tarball when doctor passes', async () => { + const dir = mkdtempSync(join(tmp, 'pack-good-')); + runInitScaffold({ targetDir: dir, name: 'pack-good' }); + const result = await runPackPublish({ packRoot: dir }); + expect(result.refused_reason).toBeNull(); + expect(result.tarball).not.toBeNull(); + expect(result.tarball?.outPath).toContain('pack-good-0.1.0.tgz'); + expect(result.tarball?.sha256.length).toBe(64); + expect(result.tarball?.fileCount).toBeGreaterThan(0); + expect(result.tarball?.tier_eligibility).toBe('endorsed'); + expect(existsSync(result.tarball!.outPath)).toBe(true); + }); + + test('refuses to pack when doctor reports blocked tier', async () => { + const dir = mkdtempSync(join(tmp, 'pack-bad-')); + runInitScaffold({ targetDir: dir, name: 'pack-bad' }); + // Sabotage core dimension 5 by removing CHANGELOG. + rmSync(join(dir, 'CHANGELOG.md')); + const result = await runPackPublish({ packRoot: dir }); + expect(result.tarball).toBeNull(); + expect(result.refused_reason).toContain('changelog_present_and_current'); + }); + + test('dry-run skips tarball but runs doctor', async () => { + const dir = mkdtempSync(join(tmp, 'pack-dry-')); + runInitScaffold({ targetDir: dir, name: 'pack-dry' }); + const result = await runPackPublish({ packRoot: dir, dryRun: true }); + expect(result.tarball).toBeNull(); + expect(result.doctor?.score).toBe(10); + }); + + test('throws when skillpack.json missing', async () => { + const dir = mkdtempSync(join(tmp, 'pack-missing-')); + try { + await runPackPublish({ packRoot: dir }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as PackPublishError).code).toBe('manifest_load_failed'); + } + }); + + test.skipIf(SKIP_NO_GNU)('--skip-doctor packs without running the gate', async () => { + const dir = mkdtempSync(join(tmp, 'pack-skipdoc-')); + runInitScaffold({ targetDir: dir, name: 'pack-skipdoc' }); + const result = await runPackPublish({ packRoot: dir, skipDoctor: true }); + expect(result.doctor).toBeNull(); + expect(result.tarball).not.toBeNull(); + }); +}); + +describe('end-to-end init -> doctor -> pack', () => { + test.skipIf(SKIP_NO_GNU)('full publisher loop on a fresh pack', async () => { + const dir = mkdtempSync(join(tmp, 'e2e-')); + // 1. init + runInitScaffold({ targetDir: dir, name: 'e2e-pack' }); + // 2. doctor + const doc = await runDoctor({ packRoot: dir, mode: 'quick' }); + expect(doc.score).toBe(10); + // 3. pack + const pkg = await runPackPublish({ packRoot: dir }); + expect(pkg.tarball).not.toBeNull(); + // 4. manifest round-trip + const m = loadSkillpackManifest(dir); + expect(m.name).toBe('e2e-pack'); + }); +}); diff --git a/test/skillpack-manifest-v1.test.ts b/test/skillpack-manifest-v1.test.ts new file mode 100644 index 000000000..f42f381b8 --- /dev/null +++ b/test/skillpack-manifest-v1.test.ts @@ -0,0 +1,392 @@ +/** + * Tests for src/core/skillpack/manifest-v1.ts — third-party skillpack.json + * runtime validator. + */ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { + SKILLPACK_API_VERSION, + SkillpackManifestError, + validateSkillpackManifest, + loadSkillpackManifest, + bundleManifestFromSkillpack, + type SkillpackManifest, +} from '../src/core/skillpack/manifest-v1.ts'; + +const VALID_MANIFEST: SkillpackManifest = { + api_version: SKILLPACK_API_VERSION, + name: 'hackathon-evaluation', + version: '0.1.0', + description: 'Score hackathon submissions with the YC rubric.', + author: 'Garry Tan', + license: 'MIT', + homepage: 'https://github.com/garrytan/skillpack-hackathon-evaluation', + gbrain_min_version: '0.36.0', + skills: ['skills/judge-submission'], +}; + +describe('validateSkillpackManifest — required fields', () => { + test('accepts a minimal valid manifest', () => { + const result = validateSkillpackManifest(VALID_MANIFEST); + expect(result.name).toBe('hackathon-evaluation'); + expect(result.api_version).toBe(SKILLPACK_API_VERSION); + }); + + test('rejects non-object top-level', () => { + expect(() => validateSkillpackManifest('not an object')).toThrow(SkillpackManifestError); + expect(() => validateSkillpackManifest(null)).toThrow(SkillpackManifestError); + expect(() => validateSkillpackManifest([])).toThrow(SkillpackManifestError); + }); + + test('rejects missing api_version with structured code', () => { + const bad = { ...VALID_MANIFEST }; + delete (bad as Partial).api_version; + try { + validateSkillpackManifest(bad); + throw new Error('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(SkillpackManifestError); + expect((err as SkillpackManifestError).code).toBe('manifest_missing_field'); + expect((err as SkillpackManifestError).detail?.field).toBe('api_version'); + } + }); + + test('rejects unknown api_version', () => { + const bad = { ...VALID_MANIFEST, api_version: 'gbrain-skillpack-v99' as 'gbrain-skillpack-v1' }; + try { + validateSkillpackManifest(bad); + throw new Error('should have thrown'); + } catch (err) { + expect((err as SkillpackManifestError).code).toBe('manifest_unknown_api_version'); + } + }); + + test('rejects each required field individually', () => { + const required = [ + 'api_version', + 'name', + 'version', + 'description', + 'author', + 'license', + 'homepage', + 'gbrain_min_version', + 'skills', + ] as const; + for (const field of required) { + const bad = { ...VALID_MANIFEST }; + delete (bad as Record)[field]; + try { + validateSkillpackManifest(bad); + throw new Error(`should have thrown for missing ${field}`); + } catch (err) { + expect((err as SkillpackManifestError).code).toBe('manifest_missing_field'); + expect((err as SkillpackManifestError).detail?.field).toBe(field); + } + } + }); +}); + +describe('validateSkillpackManifest — field shape rules', () => { + test('rejects name that is not lowercase kebab-case', () => { + for (const bad of ['UpperCase', 'has_underscore', 'has space', '0starts-with-digit', 'a', 'x'.repeat(65)]) { + try { + validateSkillpackManifest({ ...VALID_MANIFEST, name: bad }); + throw new Error(`should have thrown for ${bad}`); + } catch (err) { + expect((err as SkillpackManifestError).detail?.field).toBe('name'); + } + } + }); + + test('accepts valid kebab-case names', () => { + for (const good of ['ab', 'a-b', 'a1', 'abc-def-ghi', 'x'.repeat(64)]) { + expect(() => validateSkillpackManifest({ ...VALID_MANIFEST, name: good })).not.toThrow(); + } + }); + + test('rejects non-semver version', () => { + for (const bad of ['1', '1.2', 'v1.2.3', '1.2.3.4.5', 'not-a-version']) { + try { + validateSkillpackManifest({ ...VALID_MANIFEST, version: bad }); + throw new Error(`should have thrown for ${bad}`); + } catch (err) { + expect((err as SkillpackManifestError).detail?.field).toBe('version'); + } + } + }); + + test('accepts 3-segment and 4-segment semver', () => { + for (const good of ['0.1.0', '1.0.0', '0.36.1.0', '1.0.0-alpha']) { + expect(() => validateSkillpackManifest({ ...VALID_MANIFEST, version: good })).not.toThrow(); + } + }); + + test('rejects empty string for description/author/license/homepage', () => { + for (const field of ['description', 'author', 'license', 'homepage'] as const) { + try { + validateSkillpackManifest({ ...VALID_MANIFEST, [field]: '' }); + throw new Error(`should have thrown for empty ${field}`); + } catch (err) { + expect((err as SkillpackManifestError).detail?.field).toBe(field); + } + } + }); + + test('rejects non-http homepage', () => { + try { + validateSkillpackManifest({ ...VALID_MANIFEST, homepage: 'ftp://example.com' }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as SkillpackManifestError).detail?.field).toBe('homepage'); + } + }); + + test('rejects gbrain_min_version that is not semver', () => { + try { + validateSkillpackManifest({ ...VALID_MANIFEST, gbrain_min_version: 'latest' }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as SkillpackManifestError).detail?.field).toBe('gbrain_min_version'); + } + }); + + test('rejects empty skills array', () => { + try { + validateSkillpackManifest({ ...VALID_MANIFEST, skills: [] }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as SkillpackManifestError).detail?.field).toBe('skills'); + } + }); + + test('rejects skill paths without skills/ prefix', () => { + try { + validateSkillpackManifest({ ...VALID_MANIFEST, skills: ['src/foo'] }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as SkillpackManifestError).detail?.field).toBe('skills'); + } + }); + + test('rejects skill paths with traversal', () => { + try { + validateSkillpackManifest({ ...VALID_MANIFEST, skills: ['skills/../etc/passwd'] }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as SkillpackManifestError).detail?.field).toBe('skills'); + } + }); +}); + +describe('validateSkillpackManifest — optional array fields', () => { + test('accepts valid optional array fields', () => { + expect(() => + validateSkillpackManifest({ + ...VALID_MANIFEST, + shared_deps: ['skills/conventions/'], + unit_tests: ['test/**/*.test.ts'], + e2e_tests: ['e2e/**/*.test.ts'], + llm_evals: ['evals/*.judge.json'], + routing_evals: ['skills/*/routing-eval.jsonl'], + }), + ).not.toThrow(); + }); + + test('rejects non-array shared_deps', () => { + try { + validateSkillpackManifest({ ...VALID_MANIFEST, shared_deps: 'not-an-array' }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as SkillpackManifestError).detail?.field).toBe('shared_deps'); + } + }); + + test('rejects array with non-string entries', () => { + try { + validateSkillpackManifest({ + ...VALID_MANIFEST, + unit_tests: ['test/foo.test.ts', 42] as unknown as string[], + }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as SkillpackManifestError).detail?.field).toBe('unit_tests'); + } + }); +}); + +describe('validateSkillpackManifest — runbooks', () => { + test('accepts runbooks.bootstrap', () => { + expect(() => + validateSkillpackManifest({ + ...VALID_MANIFEST, + runbooks: { bootstrap: 'runbooks/bootstrap.md' }, + }), + ).not.toThrow(); + }); + + test('rejects non-object runbooks', () => { + try { + validateSkillpackManifest({ ...VALID_MANIFEST, runbooks: 'string' }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as SkillpackManifestError).detail?.field).toBe('runbooks'); + } + }); + + test('rejects non-string runbooks.bootstrap', () => { + try { + validateSkillpackManifest({ ...VALID_MANIFEST, runbooks: { bootstrap: 42 } }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as SkillpackManifestError).detail?.field).toBe('runbooks.bootstrap'); + } + }); +}); + +describe('validateSkillpackManifest — schema-version forward-compat', () => { + test('accepts schema versions within the supported range', () => { + expect(() => + validateSkillpackManifest({ + ...VALID_MANIFEST, + runbook_schema_version: 1, + eval_schema_version: 1, + }), + ).not.toThrow(); + }); + + test('rejects runbook_schema_version newer than supported', () => { + try { + validateSkillpackManifest({ + ...VALID_MANIFEST, + runbook_schema_version: 99, + }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as SkillpackManifestError).code).toBe('manifest_unsupported_schema_version'); + expect((err as SkillpackManifestError).detail?.field).toBe('runbook_schema_version'); + } + }); + + test('rejects eval_schema_version newer than supported', () => { + try { + validateSkillpackManifest({ + ...VALID_MANIFEST, + eval_schema_version: 99, + }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as SkillpackManifestError).code).toBe('manifest_unsupported_schema_version'); + } + }); + + test('rejects non-integer schema version', () => { + try { + validateSkillpackManifest({ + ...VALID_MANIFEST, + runbook_schema_version: 1.5, + }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as SkillpackManifestError).code).toBe('manifest_invalid_field'); + } + }); + + test('honors caller-supplied opts to support a newer schema version', () => { + expect(() => + validateSkillpackManifest( + { ...VALID_MANIFEST, runbook_schema_version: 5 }, + { maxRunbookSchemaVersion: 5 }, + ), + ).not.toThrow(); + }); +}); + +describe('loadSkillpackManifest — filesystem path', () => { + let tmp: string; + + beforeAll(() => { + tmp = mkdtempSync(join(tmpdir(), 'manifest-test-')); + }); + afterAll(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + test('returns parsed manifest when file is valid and skill dirs exist', () => { + mkdirSync(join(tmp, 'skills/judge-submission'), { recursive: true }); + writeFileSync(join(tmp, 'skills/judge-submission/SKILL.md'), '---\nname: judge\n---\n'); + writeFileSync(join(tmp, 'skillpack.json'), JSON.stringify(VALID_MANIFEST, null, 2)); + const m = loadSkillpackManifest(tmp); + expect(m.name).toBe('hackathon-evaluation'); + }); + + test('throws manifest_not_found for missing skillpack.json', () => { + const empty = mkdtempSync(join(tmpdir(), 'manifest-test-empty-')); + try { + try { + loadSkillpackManifest(empty); + throw new Error('should have thrown'); + } catch (err) { + expect((err as SkillpackManifestError).code).toBe('manifest_not_found'); + } + } finally { + rmSync(empty, { recursive: true, force: true }); + } + }); + + test('throws manifest_malformed_json for invalid JSON', () => { + const badJsonDir = mkdtempSync(join(tmpdir(), 'manifest-test-bad-')); + try { + writeFileSync(join(badJsonDir, 'skillpack.json'), '{ not valid json'); + try { + loadSkillpackManifest(badJsonDir); + throw new Error('should have thrown'); + } catch (err) { + expect((err as SkillpackManifestError).code).toBe('manifest_malformed_json'); + } + } finally { + rmSync(badJsonDir, { recursive: true, force: true }); + } + }); + + test('throws manifest_skill_not_found when declared skill dir does not exist', () => { + const noSkillDir = mkdtempSync(join(tmpdir(), 'manifest-test-noskill-')); + try { + writeFileSync( + join(noSkillDir, 'skillpack.json'), + JSON.stringify(VALID_MANIFEST, null, 2), + ); + try { + loadSkillpackManifest(noSkillDir); + throw new Error('should have thrown'); + } catch (err) { + expect((err as SkillpackManifestError).code).toBe('manifest_skill_not_found'); + } + } finally { + rmSync(noSkillDir, { recursive: true, force: true }); + } + }); +}); + +describe('bundleManifestFromSkillpack — adapter', () => { + test('projects SkillpackManifest fields onto BundleManifest shape', () => { + const bundle = bundleManifestFromSkillpack({ + ...VALID_MANIFEST, + shared_deps: ['skills/conventions/'], + excluded_from_install: ['skills/internal'], + }); + expect(bundle.name).toBe(VALID_MANIFEST.name); + expect(bundle.version).toBe(VALID_MANIFEST.version); + expect(bundle.skills).toEqual(VALID_MANIFEST.skills); + expect(bundle.shared_deps).toEqual(['skills/conventions/']); + expect(bundle.excluded_from_install).toEqual(['skills/internal']); + }); + + test('defaults shared_deps to empty array when not declared', () => { + const bundle = bundleManifestFromSkillpack(VALID_MANIFEST); + expect(bundle.shared_deps).toEqual([]); + }); +}); diff --git a/test/skillpack-reference-pack-is-ten.test.ts b/test/skillpack-reference-pack-is-ten.test.ts new file mode 100644 index 000000000..a6d53f01e --- /dev/null +++ b/test/skillpack-reference-pack-is-ten.test.ts @@ -0,0 +1,39 @@ +/** + * Regression test pinning examples/skillpack-reference/ at 10/10. + * + * Per the locked DX spec: every gbrain-shipped skillpack must score 10/10. + * If this test ever fails, gbrain is shipping below the bar it demands of + * third-party publishers. Fix the reference pack, never lower the bar. + */ +import { describe, test, expect } from 'bun:test'; +import { join, dirname, resolve } from 'path'; +import { fileURLToPath } from 'url'; + +import { runDoctor } from '../src/core/skillpack/doctor.ts'; + +// Resolve the absolute path to examples/skillpack-reference relative to +// the test file location — works whether tests run from repo root or +// from any nested workdir. +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REFERENCE_PACK_PATH = resolve(__dirname, '..', 'examples', 'skillpack-reference'); + +describe('examples/skillpack-reference — 10/10 invariant', () => { + test('runDoctor --quick on the reference pack scores 10/10 endorsed', async () => { + const r = await runDoctor({ packRoot: REFERENCE_PACK_PATH, mode: 'quick' }); + if (r.score !== 10) { + const failed = r.dimensions.filter((d) => !d.passed).map((d) => `${d.id}.${d.name} (${d.detail})`); + throw new Error( + `Reference pack regressed below 10/10. Failing dimensions:\n ${failed.join('\n ')}\n\n` + + `This is the bar gbrain demands of third-party publishers. Fix the reference pack, do not lower the rubric.`, + ); + } + expect(r.score).toBe(10); + expect(r.tier_eligibility).toBe('endorsed'); + }); + + test('reference pack manifest validates + exports the expected skill', async () => { + const r = await runDoctor({ packRoot: REFERENCE_PACK_PATH, mode: 'quick' }); + expect(r.pack_name).toBe('reference-pack'); + expect(r.dimensions.find((d) => d.name === 'manifest_valid')?.passed).toBe(true); + }); +}); diff --git a/test/skillpack-registry-client.test.ts b/test/skillpack-registry-client.test.ts new file mode 100644 index 000000000..1530a750e --- /dev/null +++ b/test/skillpack-registry-client.test.ts @@ -0,0 +1,335 @@ +/** + * Tests for src/core/skillpack/registry-client.ts — fetch + cache + + * stale-fallback semantics. + * + * Network is exercised via an injected fetchImpl test seam so tests are + * deterministic and fast. + */ +import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; +import { mkdtempSync, rmSync, existsSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { + DEFAULT_REGISTRY_URL, + RegistryClientError, + findPack, + findPackWithTier, + loadRegistry, + resolveRegistryUrl, + searchPacks, +} from '../src/core/skillpack/registry-client.ts'; +import { + ENDORSEMENTS_SCHEMA_VERSION, + REGISTRY_SCHEMA_VERSION, + type RegistryCatalog, + type EndorsementsFile, + type RegistryEntry, +} from '../src/core/skillpack/registry-schema.ts'; + +const ENTRY: RegistryEntry = { + name: 'hackathon-evaluation', + description: 'Score hackathon submissions', + author: 'Garry Tan', + author_handle: 'garrytan', + homepage: 'https://github.com/garrytan/skillpack-hackathon-evaluation', + source: { + kind: 'git', + url: 'https://github.com/garrytan/skillpack-hackathon-evaluation.git', + pinned_commit: 'a'.repeat(40), + }, + tarball_sha256: 'b'.repeat(64), + gbrain_min_version: '0.36.0', + default_tier: 'community', + tags: ['evaluation', 'yc'], + validated_at: '2026-05-18T20:00:00Z', + validation_run_id: 'r1', + skills_count: 2, + skills: ['skills/judge-submission', 'skills/score-rubric'], + version: '0.1.0', +}; + +const SECOND_ENTRY: RegistryEntry = { + ...ENTRY, + name: 'founder-scorecard', + description: 'Rate a founder', + tags: ['founder'], +}; + +const CATALOG: RegistryCatalog = { + schema_version: REGISTRY_SCHEMA_VERSION, + updated_at: '2026-05-18T20:00:00Z', + skillpacks: [ENTRY, SECOND_ENTRY], +}; + +const ENDORSEMENTS: EndorsementsFile = { + schema_version: ENDORSEMENTS_SCHEMA_VERSION, + endorsements: { + 'hackathon-evaluation': { tier: 'endorsed', endorsed_at: '2026-05-18' }, + }, +}; + +let tmp: string; +let cacheDir: string; + +beforeAll(() => { + tmp = mkdtempSync(join(tmpdir(), 'reg-client-')); +}); +afterAll(() => { + rmSync(tmp, { recursive: true, force: true }); +}); +beforeEach(() => { + cacheDir = mkdtempSync(join(tmp, 'cache-')); +}); + +function makeFetchImpl( + responses: Record, +): typeof fetch { + return (async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : (input as URL).toString(); + const r = responses[url]; + if (!r) { + throw new Error(`fetch test seam: no fixture for ${url}`); + } + const status = r.status ?? 200; + const ok = r.ok ?? (status >= 200 && status < 300); + const headers = new Headers(); + if (r.etag) headers.set('etag', r.etag); + return { + ok, + status, + statusText: ok ? 'OK' : 'Error', + headers, + json: async () => r.body, + } as Response; + }) as typeof fetch; +} + +describe('resolveRegistryUrl', () => { + test('defaults to the canonical URL when no override', () => { + expect(resolveRegistryUrl({})).toBe(DEFAULT_REGISTRY_URL); + }); + + test('opts.url wins', () => { + expect(resolveRegistryUrl({ url: 'https://example.com/r.json' })).toBe( + 'https://example.com/r.json', + ); + }); +}); + +describe('loadRegistry — fresh fetch path', () => { + test('fetches catalog + endorsements, writes cache, returns fresh_fetch', async () => { + const url = 'https://example.com/registry.json'; + const eurl = 'https://example.com/endorsements.json'; + const fetchImpl = makeFetchImpl({ + [url]: { body: CATALOG, etag: 'abc' }, + [eurl]: { body: ENDORSEMENTS }, + }); + const r = await loadRegistry({ url, cacheDir, fetchImpl }); + expect(r.origin).toBe('fresh_fetch'); + expect(r.catalog.skillpacks).toHaveLength(2); + expect(r.endorsements?.endorsements['hackathon-evaluation']?.tier).toBe('endorsed'); + expect(r.registry_url).toBe(url); + }); + + test('missing endorsements.json is treated as null, not failure', async () => { + const url = 'https://example.com/registry.json'; + const eurl = 'https://example.com/endorsements.json'; + const fetchImpl = makeFetchImpl({ + [url]: { body: CATALOG }, + [eurl]: { body: {}, status: 404, ok: false }, + }); + const r = await loadRegistry({ url, cacheDir, fetchImpl }); + expect(r.endorsements).toBeNull(); + }); + + test('schema-invalid response throws fetch_succeeded_but_schema_invalid', async () => { + const url = 'https://example.com/registry.json'; + const fetchImpl = makeFetchImpl({ + [url]: { body: { schema_version: 'wrong' } }, + }); + try { + await loadRegistry({ url, cacheDir, fetchImpl }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as RegistryClientError).code).toBe('fetch_succeeded_but_schema_invalid'); + } + }); +}); + +describe('loadRegistry — cache fallback', () => { + test('uses cache_warm when cache < 1h old and refresh=false', async () => { + const url = 'https://example.com/registry.json'; + const eurl = 'https://example.com/endorsements.json'; + const fetchImpl = makeFetchImpl({ + [url]: { body: CATALOG }, + [eurl]: { body: ENDORSEMENTS }, + }); + await loadRegistry({ url, cacheDir, fetchImpl }); + // Second call should hit cache_warm without calling fetch. + let calls = 0; + const noFetch = (async () => { + calls++; + throw new Error('should not have been called'); + }) as unknown as typeof fetch; + const r2 = await loadRegistry({ url, cacheDir, fetchImpl: noFetch }); + expect(r2.origin).toBe('cache_warm'); + expect(calls).toBe(0); + }); + + test('falls back to cache_soft_stale when network fails', async () => { + const url = 'https://example.com/registry.json'; + const eurl = 'https://example.com/endorsements.json'; + // Seed cache. + const fetchImpl1 = makeFetchImpl({ + [url]: { body: CATALOG }, + [eurl]: { body: ENDORSEMENTS }, + }); + await loadRegistry({ url, cacheDir, fetchImpl: fetchImpl1 }); + // Hand-edit the cache file to make it look 2 hours old (past SOFT_TTL). + const cacheFiles = require('fs').readdirSync(cacheDir); + const cf = join(cacheDir, cacheFiles[0]); + const data = JSON.parse(require('fs').readFileSync(cf, 'utf-8')); + data.fetched_at = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + writeFileSync(cf, JSON.stringify(data)); + // Now fail network. + const failing = (async () => { + throw new Error('network down'); + }) as unknown as typeof fetch; + const r = await loadRegistry({ url, cacheDir, fetchImpl: failing }); + expect(r.origin).toBe('cache_soft_stale'); + }); + + test('falls back to cache_hard_stale when cache > 7d', async () => { + const url = 'https://example.com/registry.json'; + const eurl = 'https://example.com/endorsements.json'; + const fetchImpl1 = makeFetchImpl({ + [url]: { body: CATALOG }, + [eurl]: { body: ENDORSEMENTS }, + }); + await loadRegistry({ url, cacheDir, fetchImpl: fetchImpl1 }); + const cacheFiles = require('fs').readdirSync(cacheDir); + const cf = join(cacheDir, cacheFiles[0]); + const data = JSON.parse(require('fs').readFileSync(cf, 'utf-8')); + data.fetched_at = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(); + writeFileSync(cf, JSON.stringify(data)); + const failing = (async () => { + throw new Error('network down'); + }) as unknown as typeof fetch; + const r = await loadRegistry({ url, cacheDir, fetchImpl: failing }); + expect(r.origin).toBe('cache_hard_stale'); + }); + + test('throws no_cache_no_network on first run with no network', async () => { + const url = 'https://example.com/registry.json'; + const failing = (async () => { + throw new Error('network down'); + }) as unknown as typeof fetch; + try { + await loadRegistry({ url, cacheDir, fetchImpl: failing }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as RegistryClientError).code).toBe('no_cache_no_network'); + } + }); + + test('noNetwork=true uses cache when present', async () => { + const url = 'https://example.com/registry.json'; + const eurl = 'https://example.com/endorsements.json'; + const fetchImpl = makeFetchImpl({ + [url]: { body: CATALOG }, + [eurl]: { body: ENDORSEMENTS }, + }); + await loadRegistry({ url, cacheDir, fetchImpl }); + const r = await loadRegistry({ url, cacheDir, noNetwork: true }); + expect(['cache_warm', 'cache_soft_stale', 'cache_hard_stale']).toContain(r.origin); + }); + + test('noNetwork=true throws when no cache', async () => { + const url = 'https://example.com/registry.json'; + try { + await loadRegistry({ url, cacheDir, noNetwork: true }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as RegistryClientError).code).toBe('no_cache_no_network'); + } + }); +}); + +describe('findPack + findPackWithTier', () => { + test('finds entry by name; returns null when missing', async () => { + const url = 'https://example.com/registry.json'; + const eurl = 'https://example.com/endorsements.json'; + const fetchImpl = makeFetchImpl({ + [url]: { body: CATALOG }, + [eurl]: { body: ENDORSEMENTS }, + }); + const r = await loadRegistry({ url, cacheDir, fetchImpl }); + expect(findPack(r, 'hackathon-evaluation')?.name).toBe('hackathon-evaluation'); + expect(findPack(r, 'nonexistent')).toBeNull(); + }); + + test('findPackWithTier resolves endorsement overlay', async () => { + const url = 'https://example.com/registry.json'; + const eurl = 'https://example.com/endorsements.json'; + const fetchImpl = makeFetchImpl({ + [url]: { body: CATALOG }, + [eurl]: { body: ENDORSEMENTS }, + }); + const r = await loadRegistry({ url, cacheDir, fetchImpl }); + const found = findPackWithTier(r, 'hackathon-evaluation'); + expect(found?.tier).toBe('endorsed'); + const community = findPackWithTier(r, 'founder-scorecard'); + expect(community?.tier).toBe('community'); + }); +}); + +describe('searchPacks', () => { + let loaded: Awaited>; + beforeEach(async () => { + const url = 'https://example.com/registry.json'; + const eurl = 'https://example.com/endorsements.json'; + const fetchImpl = makeFetchImpl({ + [url]: { body: CATALOG }, + [eurl]: { body: ENDORSEMENTS }, + }); + loaded = await loadRegistry({ url, cacheDir, fetchImpl }); + }); + + test('no query returns all entries, endorsed first', () => { + const results = searchPacks(loaded); + expect(results).toHaveLength(2); + expect(results[0]?.entry.name).toBe('hackathon-evaluation'); + expect(results[0]?.tier).toBe('endorsed'); + expect(results[1]?.entry.name).toBe('founder-scorecard'); + expect(results[1]?.tier).toBe('community'); + }); + + test('query matches against name', () => { + const r = searchPacks(loaded, { query: 'founder' }); + expect(r).toHaveLength(1); + expect(r[0]?.entry.name).toBe('founder-scorecard'); + }); + + test('query matches against tags', () => { + const r = searchPacks(loaded, { query: 'yc' }); + expect(r).toHaveLength(1); + expect(r[0]?.entry.name).toBe('hackathon-evaluation'); + }); + + test('query matches against description', () => { + const r = searchPacks(loaded, { query: 'rate' }); + expect(r).toHaveLength(1); + expect(r[0]?.entry.name).toBe('founder-scorecard'); + }); + + test('tier filter narrows to just that tier', () => { + const r = searchPacks(loaded, { tier: 'community' }); + expect(r).toHaveLength(1); + expect(r[0]?.entry.name).toBe('founder-scorecard'); + }); + + test('empty result when nothing matches', () => { + expect(searchPacks(loaded, { query: 'nothing-here' })).toEqual([]); + }); +}); diff --git a/test/skillpack-registry-schema.test.ts b/test/skillpack-registry-schema.test.ts new file mode 100644 index 000000000..2fa43ec82 --- /dev/null +++ b/test/skillpack-registry-schema.test.ts @@ -0,0 +1,271 @@ +/** + * Tests for src/core/skillpack/registry-schema.ts — validators for + * registry.json + endorsements.json. + */ +import { describe, test, expect } from 'bun:test'; + +import { + REGISTRY_SCHEMA_VERSION, + ENDORSEMENTS_SCHEMA_VERSION, + RegistrySchemaError, + validateRegistryCatalog, + validateRegistryEntry, + validateEndorsementsFile, + effectiveTier, + type RegistryCatalog, + type RegistryEntry, + type EndorsementsFile, +} from '../src/core/skillpack/registry-schema.ts'; + +const VALID_ENTRY: RegistryEntry = { + name: 'hackathon-evaluation', + description: 'Score hackathon submissions', + author: 'Garry Tan', + author_handle: 'garrytan', + homepage: 'https://github.com/garrytan/skillpack-hackathon-evaluation', + source: { + kind: 'git', + url: 'https://github.com/garrytan/skillpack-hackathon-evaluation.git', + pinned_commit: 'abc1234567890abcdef1234567890abcdef12345', + }, + tarball_sha256: 'deadbeef'.repeat(8), + gbrain_min_version: '0.36.0', + default_tier: 'community', + tags: ['evaluation', 'yc'], + validated_at: '2026-05-18T20:00:00Z', + validation_run_id: '2026-05-18T19-58-12', + skills_count: 2, + skills: ['skills/judge-submission', 'skills/score-rubric'], + version: '0.1.0', +}; + +const VALID_CATALOG: RegistryCatalog = { + schema_version: REGISTRY_SCHEMA_VERSION, + updated_at: '2026-05-18T20:00:00Z', + skillpacks: [VALID_ENTRY], +}; + +describe('validateRegistryCatalog', () => { + test('accepts a minimal valid catalog', () => { + expect(() => validateRegistryCatalog(VALID_CATALOG)).not.toThrow(); + }); + + test('rejects malformed top-level', () => { + for (const bad of ['x', null, [], 42]) { + expect(() => validateRegistryCatalog(bad)).toThrow(RegistrySchemaError); + } + }); + + test('rejects wrong schema_version', () => { + try { + validateRegistryCatalog({ + ...VALID_CATALOG, + schema_version: 'gbrain-registry-v99', + }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as RegistrySchemaError).code).toBe('unknown_schema'); + } + }); + + test('rejects non-array skillpacks', () => { + try { + validateRegistryCatalog({ ...VALID_CATALOG, skillpacks: {} }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as RegistrySchemaError).code).toBe('invalid_field'); + } + }); + + test('accepts valid bundles map', () => { + expect(() => + validateRegistryCatalog({ + ...VALID_CATALOG, + bundles: { 'starter-pack': ['hackathon-evaluation'] }, + }), + ).not.toThrow(); + }); + + test('rejects bundles map with non-string-array value', () => { + try { + validateRegistryCatalog({ + ...VALID_CATALOG, + bundles: { broken: [42] }, + }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as RegistrySchemaError).code).toBe('invalid_field'); + } + }); +}); + +describe('validateRegistryEntry', () => { + test('accepts a complete entry', () => { + expect(() => validateRegistryEntry(VALID_ENTRY)).not.toThrow(); + }); + + test('rejects each required field individually', () => { + const required = [ + 'name', + 'description', + 'author', + 'author_handle', + 'homepage', + 'source', + 'tarball_sha256', + 'gbrain_min_version', + 'default_tier', + 'tags', + 'validated_at', + 'validation_run_id', + 'skills_count', + 'skills', + 'version', + ] as const; + for (const field of required) { + const bad = { ...VALID_ENTRY } as Record; + delete bad[field]; + try { + validateRegistryEntry(bad); + throw new Error(`should have thrown for ${field}`); + } catch (err) { + expect((err as RegistrySchemaError).code).toBe('missing_field'); + expect((err as RegistrySchemaError).detail?.field).toBe(field); + } + } + }); + + test('rejects non-kebab name', () => { + try { + validateRegistryEntry({ ...VALID_ENTRY, name: 'UpperCase' }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as RegistrySchemaError).detail?.field).toBe('name'); + } + }); + + test('rejects default_tier=endorsed (endorsement is in endorsements.json)', () => { + try { + validateRegistryEntry({ ...VALID_ENTRY, default_tier: 'endorsed' }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as RegistrySchemaError).detail?.field).toBe('default_tier'); + } + }); + + test('rejects non-https source URL', () => { + try { + validateRegistryEntry({ + ...VALID_ENTRY, + source: { ...VALID_ENTRY.source, url: 'ftp://example.com' }, + }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as RegistrySchemaError).detail?.field).toBe('source.url'); + } + }); + + test('rejects non-hex pinned_commit', () => { + try { + validateRegistryEntry({ + ...VALID_ENTRY, + source: { ...VALID_ENTRY.source, pinned_commit: 'not-a-sha' }, + }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as RegistrySchemaError).detail?.field).toBe('source.pinned_commit'); + } + }); + + test('rejects negative skills_count', () => { + try { + validateRegistryEntry({ ...VALID_ENTRY, skills_count: -1 }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as RegistrySchemaError).detail?.field).toBe('skills_count'); + } + }); +}); + +describe('validateEndorsementsFile', () => { + const VALID: EndorsementsFile = { + schema_version: ENDORSEMENTS_SCHEMA_VERSION, + endorsements: { + 'hackathon-evaluation': { tier: 'endorsed', endorsed_at: '2026-05-18T20:00:00Z' }, + }, + }; + + test('accepts a valid file', () => { + expect(() => validateEndorsementsFile(VALID)).not.toThrow(); + }); + + test('rejects wrong schema_version', () => { + try { + validateEndorsementsFile({ ...VALID, schema_version: 'old' }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as RegistrySchemaError).code).toBe('unknown_schema'); + } + }); + + test('rejects unknown tier', () => { + try { + validateEndorsementsFile({ + schema_version: ENDORSEMENTS_SCHEMA_VERSION, + endorsements: { foo: { tier: 'bogus', endorsed_at: '2026-01-01' } as never }, + }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as RegistrySchemaError).code).toBe('invalid_field'); + } + }); + + test('accepts endorsements with notes', () => { + expect(() => + validateEndorsementsFile({ + ...VALID, + endorsements: { + 'foo': { + tier: 'endorsed', + endorsed_at: '2026-05-18', + note: 'promoted after clean 30d', + }, + }, + }), + ).not.toThrow(); + }); +}); + +describe('effectiveTier — endorsements overlay', () => { + test('returns default_tier when no endorsements file', () => { + expect(effectiveTier(VALID_ENTRY, null)).toBe('community'); + }); + + test('returns default_tier when endorsements file has no record', () => { + const ends: EndorsementsFile = { + schema_version: ENDORSEMENTS_SCHEMA_VERSION, + endorsements: {}, + }; + expect(effectiveTier(VALID_ENTRY, ends)).toBe('community'); + }); + + test('endorsements override wins over default_tier', () => { + const ends: EndorsementsFile = { + schema_version: ENDORSEMENTS_SCHEMA_VERSION, + endorsements: { + 'hackathon-evaluation': { tier: 'endorsed', endorsed_at: '2026-05-18' }, + }, + }; + expect(effectiveTier(VALID_ENTRY, ends)).toBe('endorsed'); + }); + + test('endorsements can downgrade to dead', () => { + const ends: EndorsementsFile = { + schema_version: ENDORSEMENTS_SCHEMA_VERSION, + endorsements: { + 'hackathon-evaluation': { tier: 'dead', endorsed_at: '2026-05-18' }, + }, + }; + expect(effectiveTier(VALID_ENTRY, ends)).toBe('dead'); + }); +}); diff --git a/test/skillpack-remote-source.test.ts b/test/skillpack-remote-source.test.ts new file mode 100644 index 000000000..675ee0b98 --- /dev/null +++ b/test/skillpack-remote-source.test.ts @@ -0,0 +1,223 @@ +/** + * Tests for src/core/skillpack/remote-source.ts — source resolution. + * + * classifySpec is pure and gets the bulk of the test surface. Git fetch is + * exercised via the e2e flow test; tarball + local paths are deterministic + * and tested here. + */ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { spawnSync } from 'child_process'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { + RemoteSourceError, + classifySpec, + resolveSource, +} from '../src/core/skillpack/remote-source.ts'; +import { packTarball } from '../src/core/skillpack/tarball.ts'; + +function hasGnuTar(): boolean { + for (const bin of ['gtar', 'tar']) { + const r = spawnSync(bin, ['--version'], { encoding: 'utf-8' }); + if (r.status === 0 && r.stdout.includes('GNU')) return true; + } + return false; +} + +const SKIP_NO_GNU = !hasGnuTar(); + +describe('classifySpec — pure classifier', () => { + test('rejects empty / non-string', () => { + expect(() => classifySpec('')).toThrow(RemoteSourceError); + expect(() => classifySpec(' ')).toThrow(RemoteSourceError); + }); + + test('classifies https URL', () => { + const r = classifySpec('https://github.com/garrytan/skillpack-hackathon-evaluation'); + expect(r.kind).toBe('git-url'); + expect(r.normalized).toBe('https://github.com/garrytan/skillpack-hackathon-evaluation'); + }); + + test('expands owner/repo into github URL', () => { + const r = classifySpec('garrytan/skillpack-hackathon-evaluation'); + expect(r.kind).toBe('git-url'); + expect(r.normalized).toBe('https://github.com/garrytan/skillpack-hackathon-evaluation.git'); + }); + + test('classifies absolute path as local', () => { + const r = classifySpec('/Users/garry/skillpack'); + expect(r.kind).toBe('local'); + expect(r.normalized).toBe('/Users/garry/skillpack'); + }); + + test('classifies relative path with ./ as local', () => { + const r = classifySpec('./skillpack'); + expect(r.kind).toBe('local'); + }); + + test('classifies .tgz path as tarball', () => { + const r = classifySpec('/tmp/pack-0.1.0.tgz'); + expect(r.kind).toBe('tarball'); + }); + + test('classifies .tar.gz path as tarball', () => { + const r = classifySpec('./pack.tar.gz'); + expect(r.kind).toBe('tarball'); + }); + + test('classifies bare kebab as kebab (needs registry)', () => { + const r = classifySpec('hackathon-evaluation'); + expect(r.kind).toBe('kebab'); + expect(r.normalized).toBe('hackathon-evaluation'); + }); + + test('rejects malformed input', () => { + expect(() => classifySpec('Has Spaces')).toThrow(RemoteSourceError); + expect(() => classifySpec('UPPER/CASE')).not.toThrow(); // owner/repo is alnum-tolerant (GitHub allows uppercase usernames) + expect(() => classifySpec('multi/slash/path')).toThrow(RemoteSourceError); + }); +}); + +describe('resolveSource — local dir', () => { + let tmp: string; + beforeAll(() => { + tmp = mkdtempSync(join(tmpdir(), 'remote-source-local-')); + }); + afterAll(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + test('returns kind=local with the absolute path when skillpack.json exists', () => { + const pack = join(tmp, 'mypack'); + mkdirSync(pack); + writeFileSync(join(pack, 'skillpack.json'), '{}'); + const r = resolveSource(pack); + expect(r.kind).toBe('local'); + expect(r.path).toBe(pack); + expect(r.pinned_commit).toBeNull(); + expect(r.tarball_sha256).toBeNull(); + expect(r.cache_hit).toBe(false); + }); + + test('rejects local path that is not a directory', () => { + const f = join(tmp, 'file'); + writeFileSync(f, 'x'); + try { + resolveSource(f); + throw new Error('should have thrown'); + } catch (err) { + expect((err as RemoteSourceError).code).toBe('spec_local_not_pack_root'); + } + }); + + test('rejects local dir without skillpack.json', () => { + const dir = join(tmp, 'empty'); + mkdirSync(dir); + try { + resolveSource(dir); + throw new Error('should have thrown'); + } catch (err) { + expect((err as RemoteSourceError).code).toBe('spec_local_not_pack_root'); + } + }); + + test('rejects missing local path', () => { + try { + resolveSource(join(tmp, 'nope')); + throw new Error('should have thrown'); + } catch (err) { + expect((err as RemoteSourceError).code).toBe('spec_local_missing'); + } + }); +}); + +describe('resolveSource — tarball', () => { + let tmp: string; + let cacheRoot: string; + beforeAll(() => { + tmp = mkdtempSync(join(tmpdir(), 'remote-source-tar-')); + cacheRoot = join(tmp, 'cache'); + }); + afterAll(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + test.skipIf(SKIP_NO_GNU)('extracts a tarball and returns a pack root with skillpack.json', () => { + const src = join(tmp, 'mypack'); + mkdirSync(join(src, 'skills/foo'), { recursive: true }); + writeFileSync(join(src, 'skills/foo/SKILL.md'), '---\nname: foo\n---\n'); + writeFileSync( + join(src, 'skillpack.json'), + JSON.stringify({ + api_version: 'gbrain-skillpack-v1', + name: 'mypack', + version: '0.1.0', + description: 'd', + author: 'a', + license: 'MIT', + homepage: 'https://example.com', + gbrain_min_version: '0.36.0', + skills: ['skills/foo'], + }), + ); + const tgz = join(tmp, 'mypack.tgz'); + packTarball({ sourceDir: src, outPath: tgz }); + + const r = resolveSource(tgz, { cacheRoot }); + expect(r.kind).toBe('tarball'); + expect(r.cache_hit).toBe(false); + expect(r.tarball_sha256).not.toBeNull(); + expect(r.pinned_commit).toBeNull(); + // findPackRoot should hop down into the `mypack/` subdir where skillpack.json lives. + expect(r.path).toContain('mypack'); + }); + + test.skipIf(SKIP_NO_GNU)('returns cache_hit=true on second resolve of the same tarball', () => { + const src = join(tmp, 'cachable'); + mkdirSync(src, { recursive: true }); + writeFileSync(join(src, 'skillpack.json'), '{}'); + const tgz = join(tmp, 'cachable.tgz'); + packTarball({ sourceDir: src, outPath: tgz }); + + const r1 = resolveSource(tgz, { cacheRoot }); + expect(r1.cache_hit).toBe(false); + const r2 = resolveSource(tgz, { cacheRoot }); + expect(r2.cache_hit).toBe(true); + expect(r2.tarball_sha256).toBe(r1.tarball_sha256); + expect(r2.path).toBe(r1.path); + }); + + test.skipIf(SKIP_NO_GNU)('noCache=true forces a fresh extract even when cache exists', () => { + const src = join(tmp, 'nocache'); + mkdirSync(src, { recursive: true }); + writeFileSync(join(src, 'skillpack.json'), '{}'); + const tgz = join(tmp, 'nocache.tgz'); + packTarball({ sourceDir: src, outPath: tgz }); + + resolveSource(tgz, { cacheRoot }); + const r = resolveSource(tgz, { cacheRoot, noCache: true }); + expect(r.cache_hit).toBe(false); + }); + + test('rejects missing tarball file', () => { + try { + resolveSource(join(tmp, 'nothere.tgz'), { cacheRoot }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as RemoteSourceError).code).toBe('spec_tarball_missing'); + } + }); +}); + +describe('resolveSource — kebab name short-circuits to registry', () => { + test('throws spec_kebab_invalid_shape for bare kebab name', () => { + try { + resolveSource('hackathon-evaluation'); + throw new Error('should have thrown'); + } catch (err) { + expect((err as RemoteSourceError).code).toBe('spec_kebab_invalid_shape'); + } + }); +}); diff --git a/test/skillpack-rubric-doctor.test.ts b/test/skillpack-rubric-doctor.test.ts new file mode 100644 index 000000000..28ad269f4 --- /dev/null +++ b/test/skillpack-rubric-doctor.test.ts @@ -0,0 +1,368 @@ +/** + * Tests for src/core/skillpack/rubric.ts + src/core/skillpack/doctor.ts + + * src/core/skillpack/audit.ts. + * + * Build pack fixtures and walk the full rubric. Covers happy path (10/10), + * each individual dimension failing in isolation, tier eligibility math, + * auto-fix scaffold, and audit log append. + */ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { describeRubric, walkRubric, type RubricInput } from '../src/core/skillpack/rubric.ts'; +import { formatDoctorResult, runDoctor } from '../src/core/skillpack/doctor.ts'; +import { + SKILLPACK_API_VERSION, + loadSkillpackManifest, + type SkillpackManifest, +} from '../src/core/skillpack/manifest-v1.ts'; +import { + currentAuditFilePath, + logSkillpackEvent, + readRecentSkillpackEvents, +} from '../src/core/skillpack/audit.ts'; +import { withEnv } from './helpers/with-env.ts'; + +let tmp: string; +beforeAll(() => { + tmp = mkdtempSync(join(tmpdir(), 'rubric-doctor-')); +}); +afterAll(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +/** Build a "10/10" pack fixture in `dir`. */ +function buildTenOutOfTenPack(dir: string, version = '0.1.0'): SkillpackManifest { + const date = new Date().toISOString().slice(0, 10); + const manifest: SkillpackManifest = { + api_version: SKILLPACK_API_VERSION, + name: 'ten-of-ten', + version, + description: 'Reference pack scoring 10/10', + author: 'Garry Tan', + license: 'MIT', + homepage: 'https://example.com/ten-of-ten', + gbrain_min_version: '0.36.0', + skills: ['skills/judge-foo', 'skills/judge-bar'], + unit_tests: ['test/**/*.test.ts'], + e2e_tests: ['e2e/**/*.test.ts'], + llm_evals: ['evals/*.judge.json'], + routing_evals: ['skills/*/routing-eval.jsonl'], + runbooks: { bootstrap: 'runbooks/bootstrap.md' }, + changelog: 'CHANGELOG.md', + }; + + mkdirSync(join(dir, 'skills/judge-foo'), { recursive: true }); + mkdirSync(join(dir, 'skills/judge-bar'), { recursive: true }); + writeFileSync( + join(dir, 'skills/judge-foo/SKILL.md'), + '---\nname: judge-foo\ndescription: foo judger\ntriggers:\n - judge as foo\n - foo this\n---\n', + ); + writeFileSync( + join(dir, 'skills/judge-bar/SKILL.md'), + '---\nname: judge-bar\ndescription: bar judger\ntriggers:\n - judge as bar\n - bar this\n---\n', + ); + // 5+ intents per skill. + const evalA = Array.from({ length: 5 }, (_, i) => ({ + intent: `judge as foo ${i}`, + expected_skill: 'judge-foo', + })); + const evalB = Array.from({ length: 5 }, (_, i) => ({ + intent: `judge as bar ${i}`, + expected_skill: 'judge-bar', + })); + writeFileSync( + join(dir, 'skills/judge-foo/routing-eval.jsonl'), + evalA.map((e) => JSON.stringify(e)).join('\n') + '\n', + ); + writeFileSync( + join(dir, 'skills/judge-bar/routing-eval.jsonl'), + evalB.map((e) => JSON.stringify(e)).join('\n') + '\n', + ); + + mkdirSync(join(dir, 'test'), { recursive: true }); + writeFileSync(join(dir, 'test/example.test.ts'), `import { test, expect } from 'bun:test'; test('x', () => expect(1).toBe(1));`); + mkdirSync(join(dir, 'e2e'), { recursive: true }); + writeFileSync(join(dir, 'e2e/example.e2e.test.ts'), `import { test, expect } from 'bun:test'; test('e2e', () => expect(1).toBe(1));`); + + mkdirSync(join(dir, 'evals'), { recursive: true }); + writeFileSync( + join(dir, 'evals/example.judge.json'), + JSON.stringify( + { + task: 'Judge output quality', + output: '', + cases: [ + { name: 'happy', criteria: 'output is correct' }, + { name: 'edge', criteria: 'output handles edge' }, + { name: 'fail', criteria: 'output refuses gracefully' }, + ], + }, + null, + 2, + ), + ); + + mkdirSync(join(dir, 'runbooks'), { recursive: true }); + writeFileSync(join(dir, 'runbooks/bootstrap.md'), `# Bootstrap\n\n1. show user: hello\n`); + writeFileSync(join(dir, 'CHANGELOG.md'), `# Changelog\n\n## [${version}] - ${date}\n\n- initial release\n`); + writeFileSync(join(dir, 'LICENSE'), 'MIT License — full text here\n'); + writeFileSync(join(dir, 'skillpack.json'), JSON.stringify(manifest, null, 2)); + return manifest; +} + +describe('walkRubric — 10/10 fixture', () => { + test('scores 10 across all dimensions when every artifact is present and valid', async () => { + const dir = mkdtempSync(join(tmp, 'ten-')); + const manifest = buildTenOutOfTenPack(dir); + const score = await walkRubric({ packRoot: dir, manifest }); + expect(score.total).toBe(10); + expect(score.tier_eligibility).toBe('endorsed'); + expect(score.promotion_blockers).toEqual([]); + expect(score.dimensions.every((d) => d.passed)).toBe(true); + }); +}); + +describe('walkRubric — individual dimensions fail in isolation', () => { + test('dimension 2 (skills_have_skill_md) fails when SKILL.md is missing', async () => { + const dir = mkdtempSync(join(tmp, 'd2-')); + buildTenOutOfTenPack(dir); + rmSync(join(dir, 'skills/judge-foo/SKILL.md')); + const manifest = loadSkillpackManifest(dir); + const score = await walkRubric({ packRoot: dir, manifest }); + const d2 = score.dimensions.find((d) => d.name === 'skills_have_skill_md'); + expect(d2?.passed).toBe(false); + expect(d2?.detail).toContain('missing'); + }); + + test('dimension 3 (routing_evals_present) fails on < 5 intents', async () => { + const dir = mkdtempSync(join(tmp, 'd3-')); + buildTenOutOfTenPack(dir); + writeFileSync( + join(dir, 'skills/judge-foo/routing-eval.jsonl'), + `{"intent":"only one","expected_skill":"judge-foo"}\n`, + ); + const manifest = loadSkillpackManifest(dir); + const score = await walkRubric({ packRoot: dir, manifest }); + const d3 = score.dimensions.find((d) => d.name === 'routing_evals_present'); + expect(d3?.passed).toBe(false); + }); + + test('dimension 4 (skills_have_unique_triggers) fails when two skills share a trigger', async () => { + const dir = mkdtempSync(join(tmp, 'd4-')); + buildTenOutOfTenPack(dir); + // Make judge-bar claim the same trigger as judge-foo. + writeFileSync( + join(dir, 'skills/judge-bar/SKILL.md'), + '---\nname: judge-bar\ndescription: bar judger\ntriggers:\n - judge as foo\n---\n', + ); + const manifest = loadSkillpackManifest(dir); + const score = await walkRubric({ packRoot: dir, manifest }); + const d4 = score.dimensions.find((d) => d.name === 'skills_have_unique_triggers'); + expect(d4?.passed).toBe(false); + expect(d4?.detail).toContain('judge as foo'); + }); + + test('dimension 5 (changelog) fails when version entry missing', async () => { + const dir = mkdtempSync(join(tmp, 'd5-')); + buildTenOutOfTenPack(dir); + writeFileSync(join(dir, 'CHANGELOG.md'), `# Changelog\n\nNo entries yet.\n`); + const manifest = loadSkillpackManifest(dir); + const score = await walkRubric({ packRoot: dir, manifest }); + const d5 = score.dimensions.find((d) => d.name === 'changelog_present_and_current'); + expect(d5?.passed).toBe(false); + }); + + test('badge 8 (llm_eval_present) fails when cases array < 3', async () => { + const dir = mkdtempSync(join(tmp, 'd8-')); + buildTenOutOfTenPack(dir); + writeFileSync( + join(dir, 'evals/example.judge.json'), + JSON.stringify({ task: 't', output: 'o', cases: [{ name: 'a' }] }), + ); + const manifest = loadSkillpackManifest(dir); + const score = await walkRubric({ packRoot: dir, manifest }); + const d8 = score.dimensions.find((d) => d.name === 'llm_eval_present'); + expect(d8?.passed).toBe(false); + }); + + test('badge 9 (bootstrap_runbook_present) fails when manifest does not declare it', async () => { + const dir = mkdtempSync(join(tmp, 'd9-')); + const m = buildTenOutOfTenPack(dir); + const stripped = { ...m }; + delete stripped.runbooks; + writeFileSync(join(dir, 'skillpack.json'), JSON.stringify(stripped, null, 2)); + const manifest = loadSkillpackManifest(dir); + const score = await walkRubric({ packRoot: dir, manifest }); + const d9 = score.dimensions.find((d) => d.name === 'bootstrap_runbook_present'); + expect(d9?.passed).toBe(false); + }); +}); + +describe('walkRubric — tier eligibility', () => { + test('all core passed + all badges passed = endorsed', async () => { + const dir = mkdtempSync(join(tmp, 'tier-end-')); + const m = buildTenOutOfTenPack(dir); + const score = await walkRubric({ packRoot: dir, manifest: m }); + expect(score.tier_eligibility).toBe('endorsed'); + }); + + test('all core passed + 3 badges = community', async () => { + const dir = mkdtempSync(join(tmp, 'tier-comm-')); + const m = buildTenOutOfTenPack(dir); + // Knock out 2 badges (unit_tests + e2e_tests). + const stripped = { ...m }; + delete stripped.unit_tests; + delete stripped.e2e_tests; + writeFileSync(join(dir, 'skillpack.json'), JSON.stringify(stripped, null, 2)); + const manifest = loadSkillpackManifest(dir); + const score = await walkRubric({ packRoot: dir, manifest }); + expect(score.badges_passed).toBe(3); + expect(score.tier_eligibility).toBe('community'); + }); + + test('all core + < 3 badges = experimental', async () => { + const dir = mkdtempSync(join(tmp, 'tier-exp-')); + const m = buildTenOutOfTenPack(dir); + const stripped = { ...m }; + delete stripped.unit_tests; + delete stripped.e2e_tests; + delete stripped.llm_evals; + delete stripped.runbooks; + writeFileSync(join(dir, 'skillpack.json'), JSON.stringify(stripped, null, 2)); + // Also remove the bootstrap.md file so the dimension fails cleanly. + rmSync(join(dir, 'runbooks/bootstrap.md')); + // Remove license too to push badges to 1. + rmSync(join(dir, 'LICENSE')); + const manifest = loadSkillpackManifest(dir); + const score = await walkRubric({ packRoot: dir, manifest }); + expect(score.badges_passed).toBeLessThan(3); + expect(score.tier_eligibility).toBe('experimental'); + }); + + test('any core fails = blocked', async () => { + const dir = mkdtempSync(join(tmp, 'tier-block-')); + buildTenOutOfTenPack(dir); + rmSync(join(dir, 'CHANGELOG.md')); + const manifest = loadSkillpackManifest(dir); + const score = await walkRubric({ packRoot: dir, manifest }); + expect(score.tier_eligibility).toBe('blocked'); + expect(score.promotion_blockers).toContain('changelog_present_and_current'); + }); +}); + +describe('runDoctor — orchestrator', () => { + test('returns score=10 for the 10/10 fixture', async () => { + const dir = mkdtempSync(join(tmp, 'doctor-ten-')); + buildTenOutOfTenPack(dir); + const r = await runDoctor({ packRoot: dir, mode: 'quick' }); + expect(r.score).toBe(10); + expect(r.tier_eligibility).toBe('endorsed'); + }); + + test('returns blocked + dimension 1 failure when manifest is malformed', async () => { + const dir = mkdtempSync(join(tmp, 'doctor-bad-')); + writeFileSync(join(dir, 'skillpack.json'), '{ broken json'); + const r = await runDoctor({ packRoot: dir, mode: 'quick' }); + expect(r.score).toBe(0); + expect(r.tier_eligibility).toBe('blocked'); + expect(r.dimensions[0]?.name).toBe('manifest_valid'); + expect(r.dimensions[0]?.passed).toBe(false); + }); + + test('full mode emits the follow-up hint', async () => { + const dir = mkdtempSync(join(tmp, 'doctor-full-')); + buildTenOutOfTenPack(dir); + const r = await runDoctor({ packRoot: dir, mode: 'full' }); + expect(r.full_mode_hint).toBeTruthy(); + }); +}); + +describe('runDoctor --fix — auto-scaffold', () => { + test('without --yes the plan is shown but nothing written', async () => { + const dir = mkdtempSync(join(tmp, 'fix-noyes-')); + const m = buildTenOutOfTenPack(dir); + rmSync(join(dir, 'skills/judge-foo/routing-eval.jsonl')); + // Hack: declare routing_evals but leave the file missing. + const r = await runDoctor({ packRoot: dir, mode: 'quick', fix: true, yes: false }); + expect(r.fixes_applied).toEqual([]); + }); + + test('with --yes the missing artifacts are scaffolded', async () => { + const dir = mkdtempSync(join(tmp, 'fix-yes-')); + buildTenOutOfTenPack(dir); + rmSync(join(dir, 'skills/judge-foo/routing-eval.jsonl')); + rmSync(join(dir, 'runbooks/bootstrap.md')); + rmSync(join(dir, 'LICENSE')); + const r = await runDoctor({ packRoot: dir, mode: 'quick', fix: true, yes: true }); + expect(r.fixes_applied.length).toBeGreaterThan(0); + expect(existsSync(join(dir, 'skills/judge-foo/routing-eval.jsonl'))).toBe(true); + expect(existsSync(join(dir, 'runbooks/bootstrap.md'))).toBe(true); + expect(existsSync(join(dir, 'LICENSE'))).toBe(true); + }); +}); + +describe('formatDoctorResult — human-readable output', () => { + test('renders pass/fail markers + dimension names + fix hints', async () => { + const dir = mkdtempSync(join(tmp, 'fmt-')); + buildTenOutOfTenPack(dir); + rmSync(join(dir, 'LICENSE')); + const r = await runDoctor({ packRoot: dir, mode: 'quick' }); + const text = formatDoctorResult(r); + expect(text).toContain('ten-of-ten@0.1.0'); + expect(text).toContain('Core'); + expect(text).toContain('Quality badges'); + expect(text).toContain('license_present'); + expect(text).toContain('fix:'); + }); +}); + +describe('describeRubric — pure-data export', () => { + test('exposes 10 dimensions in stable order', () => { + const list = describeRubric(); + expect(list).toHaveLength(10); + expect(list[0]?.id).toBe(1); + expect(list[9]?.id).toBe(10); + expect(list.filter((d) => d.category === 'core')).toHaveLength(5); + expect(list.filter((d) => d.category === 'badge')).toHaveLength(5); + }); +}); + +describe('audit — JSONL log', () => { + test('logSkillpackEvent appends a line', async () => { + const auditDir = mkdtempSync(join(tmp, 'audit-1-')); + await withEnv({ GBRAIN_AUDIT_DIR: auditDir }, () => { + logSkillpackEvent({ event: 'scaffold_third_party', pack: 'foo', outcome: 'ok' }); + const file = currentAuditFilePath(); + expect(existsSync(file)).toBe(true); + const content = readFileSync(file, 'utf-8'); + expect(content).toContain('"scaffold_third_party"'); + expect(content).toContain('"pack":"foo"'); + }); + }); + + test('readRecentSkillpackEvents returns chronological events', async () => { + const auditDir = mkdtempSync(join(tmp, 'audit-2-')); + await withEnv({ GBRAIN_AUDIT_DIR: auditDir }, () => { + logSkillpackEvent({ event: 'search', pack: 'foo', outcome: 'ok' }); + logSkillpackEvent({ event: 'doctor_run', pack: 'foo', outcome: 'ok' }); + const events = readRecentSkillpackEvents(7); + expect(events.length).toBe(2); + expect(events[0]?.event).toBe('search'); + expect(events[1]?.event).toBe('doctor_run'); + }); + }); + + test('readRecentSkillpackEvents skips malformed lines without throwing', async () => { + const auditDir = mkdtempSync(join(tmp, 'audit-3-')); + await withEnv({ GBRAIN_AUDIT_DIR: auditDir }, () => { + const file = currentAuditFilePath(); + mkdirSync(auditDir, { recursive: true }); + writeFileSync(file, '{not json}\n{"ts":"2026-01-01","event":"search","outcome":"ok"}\n'); + const events = readRecentSkillpackEvents(365 * 5); + expect(events.length).toBe(1); + }); + }); +}); diff --git a/test/skillpack-scaffold-third-party.test.ts b/test/skillpack-scaffold-third-party.test.ts new file mode 100644 index 000000000..d67b4c5a5 --- /dev/null +++ b/test/skillpack-scaffold-third-party.test.ts @@ -0,0 +1,289 @@ +/** + * Tests for src/core/skillpack/scaffold-third-party.ts — the orchestrator. + * + * Uses a synthetic local-path skillpack fixture so the flow exercises: + * resolveSource → loadSkillpackManifest → askTrust → enumerateScaffoldEntries + * → copyArtifacts → state.json upsert → bootstrap display. + * + * Git + tarball sources are exercised in test/skillpack-remote-source.test.ts + * and in the e2e flow test. + */ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { + ScaffoldThirdPartyError, + runScaffoldThirdParty, +} from '../src/core/skillpack/scaffold-third-party.ts'; +import { resolveSource } from '../src/core/skillpack/remote-source.ts'; +import { SKILLPACK_API_VERSION, type SkillpackManifest } from '../src/core/skillpack/manifest-v1.ts'; +import { loadState, SKILLPACK_STATE_SCHEMA_VERSION } from '../src/core/skillpack/state.ts'; + +interface FixtureOptions { + /** Optional manifest overrides. */ + manifestOverrides?: Partial; + /** When true, include a bootstrap.md runbook. */ + withBootstrap?: boolean; +} + +/** Build a minimal valid third-party pack in a tempdir. */ +function buildPackFixture(root: string, opts: FixtureOptions = {}): SkillpackManifest { + const manifest: SkillpackManifest = { + api_version: SKILLPACK_API_VERSION, + name: 'sample-pack', + version: '0.1.0', + description: 'Sample pack for tests.', + author: 'Test Author', + license: 'MIT', + homepage: 'https://example.com/sample-pack', + gbrain_min_version: '0.30.0', + skills: ['skills/sample-skill'], + ...opts.manifestOverrides, + }; + if (opts.withBootstrap) { + manifest.runbooks = { bootstrap: 'runbooks/bootstrap.md' }; + } + + mkdirSync(join(root, 'skills/sample-skill'), { recursive: true }); + writeFileSync( + join(root, 'skills/sample-skill/SKILL.md'), + '---\nname: sample-skill\ndescription: sample\ntriggers:\n - sample me\n---\n\nsample content\n', + ); + writeFileSync(join(root, 'skillpack.json'), JSON.stringify(manifest, null, 2)); + + if (opts.withBootstrap) { + mkdirSync(join(root, 'runbooks'), { recursive: true }); + writeFileSync( + join(root, 'runbooks/bootstrap.md'), + '1. agent: gbrain put_page wiki/example\n2. show user: "Pack installed."\n', + ); + } + return manifest; +} + +let tmp: string; +let workspace: string; +let statePath: string; +let packDir: string; + +beforeAll(() => { + tmp = mkdtempSync(join(tmpdir(), 'scaffold-tp-test-')); +}); +afterAll(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +function freshSandbox(): void { + workspace = mkdtempSync(join(tmp, 'workspace-')); + packDir = mkdtempSync(join(tmp, 'pack-')); + statePath = join(tmp, `state-${Date.now()}-${Math.random()}.json`); +} + +describe('runScaffoldThirdParty — happy path', () => { + test('wrote_new on first scaffold of a fresh pack', async () => { + freshSandbox(); + buildPackFixture(packDir); + const resolved = resolveSource(packDir); + + const result = await runScaffoldThirdParty( + { + resolved, + targetWorkspace: workspace, + statePath, + // Local sources skip trust prompt automatically. + }, + '0.36.0', + ); + + expect(result.status).toBe('wrote_new'); + expect(result.copy?.summary.wroteNew).toBeGreaterThan(0); + expect(result.trustDecision.reason).toBe('local_path_no_prompt'); + expect(existsSync(join(workspace, 'skills/sample-skill/SKILL.md'))).toBe(true); + }); + + test('records the pack in state.json after a successful scaffold', async () => { + freshSandbox(); + buildPackFixture(packDir); + const resolved = resolveSource(packDir); + + await runScaffoldThirdParty( + { resolved, targetWorkspace: workspace, statePath }, + '0.36.0', + ); + + const state = loadState({ statePath }); + expect(state.packs).toHaveLength(1); + expect(state.packs[0]?.name).toBe('sample-pack'); + expect(state.packs[0]?.source_kind).toBe('local'); + expect(state.packs[0]?.skill_slugs).toEqual(['skills/sample-skill']); + }); + + test('second scaffold of same pack lands all_skipped_existing (refuses to overwrite)', async () => { + freshSandbox(); + buildPackFixture(packDir); + const resolved = resolveSource(packDir); + + await runScaffoldThirdParty( + { resolved, targetWorkspace: workspace, statePath }, + '0.36.0', + ); + const second = await runScaffoldThirdParty( + { resolved, targetWorkspace: workspace, statePath }, + '0.36.0', + ); + + expect(second.status).toBe('all_skipped_existing'); + expect(second.copy?.summary.wroteNew).toBe(0); + expect(second.copy?.summary.skippedExisting).toBeGreaterThan(0); + }); + + test('dry_run does not write files or state', async () => { + freshSandbox(); + buildPackFixture(packDir); + const resolved = resolveSource(packDir); + + const result = await runScaffoldThirdParty( + { resolved, targetWorkspace: workspace, statePath, dryRun: true }, + '0.36.0', + ); + + expect(result.status).toBe('dry_run'); + expect(existsSync(join(workspace, 'skills/sample-skill/SKILL.md'))).toBe(false); + expect(existsSync(statePath)).toBe(false); + }); + + test('displays bootstrap.md when declared and present', async () => { + freshSandbox(); + buildPackFixture(packDir, { withBootstrap: true }); + const resolved = resolveSource(packDir); + + const result = await runScaffoldThirdParty( + { resolved, targetWorkspace: workspace, statePath }, + '0.36.0', + ); + + expect(result.bootstrap.shown).toBe(true); + expect(result.bootstrap.text).toContain('BOOTSTRAP STEPS'); + expect(result.bootstrap.text).toContain('agent: gbrain put_page wiki/example'); + }); +}); + +describe('runScaffoldThirdParty — gbrain version gate', () => { + test('rejects when current version is below gbrain_min_version', async () => { + freshSandbox(); + buildPackFixture(packDir, { manifestOverrides: { gbrain_min_version: '99.0.0' } }); + const resolved = resolveSource(packDir); + + try { + await runScaffoldThirdParty( + { resolved, targetWorkspace: workspace, statePath }, + '0.36.0', + ); + throw new Error('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(ScaffoldThirdPartyError); + expect((err as ScaffoldThirdPartyError).code).toBe('gbrain_version_too_old'); + } + }); + + test('accepts exact-version match', async () => { + freshSandbox(); + buildPackFixture(packDir, { manifestOverrides: { gbrain_min_version: '0.36.0' } }); + const resolved = resolveSource(packDir); + + const r = await runScaffoldThirdParty( + { resolved, targetWorkspace: workspace, statePath }, + '0.36.0', + ); + expect(r.status).toBe('wrote_new'); + }); + + test('accepts current > min', async () => { + freshSandbox(); + buildPackFixture(packDir, { manifestOverrides: { gbrain_min_version: '0.36.0' } }); + const resolved = resolveSource(packDir); + + const r = await runScaffoldThirdParty( + { resolved, targetWorkspace: workspace, statePath }, + '0.37.0', + ); + expect(r.status).toBe('wrote_new'); + }); +}); + +describe('runScaffoldThirdParty — trust prompt gate', () => { + test('aborted_no_trust when prompt is rejected', async () => { + freshSandbox(); + buildPackFixture(packDir); + const resolved = resolveSource(packDir); + // Re-classify as if it came from a tarball so trust prompt fires. + const fakeResolved = { ...resolved, kind: 'tarball' as const, tarball_sha256: 'fakesha' }; + + const result = await runScaffoldThirdParty( + { + resolved: fakeResolved, + targetWorkspace: workspace, + statePath, + isTTY: true, + readLine: async () => 'n', + }, + '0.36.0', + ); + + expect(result.status).toBe('aborted_no_trust'); + expect(result.trustDecision.reason).toBe('prompt_rejected'); + expect(existsSync(join(workspace, 'skills/sample-skill/SKILL.md'))).toBe(false); + }); + + test('--trust flag bypasses prompt', async () => { + freshSandbox(); + buildPackFixture(packDir); + const resolved = resolveSource(packDir); + const fakeResolved = { ...resolved, kind: 'tarball' as const, tarball_sha256: 'fakesha' }; + + const result = await runScaffoldThirdParty( + { + resolved: fakeResolved, + targetWorkspace: workspace, + statePath, + trustFlag: true, + }, + '0.36.0', + ); + + expect(result.status).toBe('wrote_new'); + expect(result.trustDecision.reason).toBe('trust_flag_bypassed'); + }); +}); + +describe('runScaffoldThirdParty — manifest validation', () => { + test('throws manifest_invalid for missing skillpack.json', async () => { + freshSandbox(); + mkdirSync(packDir, { recursive: true }); + // Don't create skillpack.json; resolveSource will reject earlier. + // Test the orchestrator's handling by calling it with a path that lacks the file. + // We bypass resolveSource's check by constructing the ResolvedSource manually. + try { + await runScaffoldThirdParty( + { + resolved: { + path: packDir, + kind: 'local', + source: packDir, + pinned_commit: null, + tarball_sha256: null, + cache_hit: false, + }, + targetWorkspace: workspace, + statePath, + }, + '0.36.0', + ); + throw new Error('should have thrown'); + } catch (err) { + expect((err as ScaffoldThirdPartyError).code).toBe('manifest_invalid'); + } + }); +}); diff --git a/test/skillpack-state.test.ts b/test/skillpack-state.test.ts new file mode 100644 index 000000000..c719284a5 --- /dev/null +++ b/test/skillpack-state.test.ts @@ -0,0 +1,263 @@ +/** + * Tests for src/core/skillpack/state.ts — machine-owned trust store + * at ~/.gbrain/skillpack-state.json (codex G1). + */ +import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { + SKILLPACK_STATE_SCHEMA_VERSION, + SkillpackStateError, + loadState, + saveState, + findEntry, + upsertEntry, + removeEntry, + isAlreadyTrusted, + type SkillpackStateEntry, + type SkillpackState, +} from '../src/core/skillpack/state.ts'; + +function makeEntry(over: Partial = {}): SkillpackStateEntry { + return { + name: 'hackathon-evaluation', + version: '0.1.0', + author: 'Garry Tan', + source: 'https://github.com/garrytan/skillpack-hackathon-evaluation', + source_kind: 'git', + pinned_commit: 'abc1234567890abcdef1234567890abcdef12345', + tarball_sha256: null, + tier: 'endorsed', + scaffolded_at: '2026-05-18T20:00:00Z', + workspace: '/Users/test/workspace', + skill_slugs: ['skills/judge-submission'], + ...over, + }; +} + +let tmp: string; +let statePath: string; + +beforeAll(() => { + tmp = mkdtempSync(join(tmpdir(), 'state-test-')); +}); +afterAll(() => { + rmSync(tmp, { recursive: true, force: true }); +}); +beforeEach(() => { + statePath = join(tmp, `state-${Date.now()}-${Math.random()}.json`); +}); + +describe('loadState — cold start + happy path', () => { + test('returns empty state when file does not exist', () => { + const s = loadState({ statePath }); + expect(s.schema_version).toBe(SKILLPACK_STATE_SCHEMA_VERSION); + expect(s.packs).toEqual([]); + }); + + test('round-trips through save + load', () => { + const entry = makeEntry(); + saveState({ schema_version: SKILLPACK_STATE_SCHEMA_VERSION, packs: [entry] }, { statePath }); + const loaded = loadState({ statePath }); + expect(loaded.packs).toHaveLength(1); + expect(loaded.packs[0]?.name).toBe('hackathon-evaluation'); + expect(loaded.packs[0]?.pinned_commit).toBe('abc1234567890abcdef1234567890abcdef12345'); + }); +}); + +describe('loadState — error paths', () => { + test('throws state_malformed_json on invalid JSON', () => { + writeFileSync(statePath, '{ not json'); + try { + loadState({ statePath }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as SkillpackStateError).code).toBe('state_malformed_json'); + } + }); + + test('throws state_malformed_json on non-object top level', () => { + writeFileSync(statePath, '[]'); + try { + loadState({ statePath }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as SkillpackStateError).code).toBe('state_malformed_json'); + } + }); + + test('throws state_schema_unknown on mismatched schema version', () => { + writeFileSync(statePath, JSON.stringify({ schema_version: 'gbrain-skillpack-state-v99', packs: [] })); + try { + loadState({ statePath }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as SkillpackStateError).code).toBe('state_schema_unknown'); + } + }); + + test('throws state_malformed_json when packs is not an array', () => { + writeFileSync( + statePath, + JSON.stringify({ schema_version: SKILLPACK_STATE_SCHEMA_VERSION, packs: {} }), + ); + try { + loadState({ statePath }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as SkillpackStateError).code).toBe('state_malformed_json'); + } + }); +}); + +describe('saveState — atomic write', () => { + test('writes file via .tmp then rename (no .tmp left behind on success)', () => { + const entry = makeEntry(); + saveState({ schema_version: SKILLPACK_STATE_SCHEMA_VERSION, packs: [entry] }, { statePath }); + expect(existsSync(statePath)).toBe(true); + expect(existsSync(statePath + '.tmp')).toBe(false); + }); + + test('writes pretty-printed JSON with trailing newline', () => { + saveState({ schema_version: SKILLPACK_STATE_SCHEMA_VERSION, packs: [makeEntry()] }, { statePath }); + const content = readFileSync(statePath, 'utf-8'); + expect(content.endsWith('\n')).toBe(true); + // Indented (looking for the 2-space indent before "packs") + expect(content).toContain(' "packs": ['); + }); +}); + +describe('findEntry / upsertEntry / removeEntry', () => { + test('findEntry returns the matching entry', () => { + const state: SkillpackState = { + schema_version: SKILLPACK_STATE_SCHEMA_VERSION, + packs: [makeEntry({ name: 'one' }), makeEntry({ name: 'two' })], + }; + expect(findEntry(state, 'one')?.name).toBe('one'); + expect(findEntry(state, 'two')?.name).toBe('two'); + expect(findEntry(state, 'three')).toBeUndefined(); + }); + + test('upsertEntry replaces an existing entry by name', () => { + let state: SkillpackState = { + schema_version: SKILLPACK_STATE_SCHEMA_VERSION, + packs: [makeEntry({ name: 'one', version: '0.1.0' })], + }; + state = upsertEntry(state, makeEntry({ name: 'one', version: '0.2.0' })); + expect(state.packs).toHaveLength(1); + expect(state.packs[0]?.version).toBe('0.2.0'); + }); + + test('upsertEntry appends a new entry when name is unique', () => { + let state: SkillpackState = { + schema_version: SKILLPACK_STATE_SCHEMA_VERSION, + packs: [makeEntry({ name: 'one' })], + }; + state = upsertEntry(state, makeEntry({ name: 'two' })); + expect(state.packs).toHaveLength(2); + expect(state.packs.map((p) => p.name).sort()).toEqual(['one', 'two']); + }); + + test('removeEntry drops the matching entry; no-op when name absent', () => { + let state: SkillpackState = { + schema_version: SKILLPACK_STATE_SCHEMA_VERSION, + packs: [makeEntry({ name: 'one' }), makeEntry({ name: 'two' })], + }; + state = removeEntry(state, 'one'); + expect(state.packs.map((p) => p.name)).toEqual(['two']); + + state = removeEntry(state, 'nonexistent'); + expect(state.packs.map((p) => p.name)).toEqual(['two']); + }); +}); + +describe('isAlreadyTrusted — codex G4 TOFU prompt-skip logic', () => { + test('returns false when pack is not yet installed', () => { + const state: SkillpackState = { schema_version: SKILLPACK_STATE_SCHEMA_VERSION, packs: [] }; + expect( + isAlreadyTrusted(state, { + name: 'foo', + author: 'a', + pinned_commit: 'abc', + tarball_sha256: null, + }), + ).toBe(false); + }); + + test('returns true when name + author + pinned_commit all match', () => { + const state: SkillpackState = { + schema_version: SKILLPACK_STATE_SCHEMA_VERSION, + packs: [makeEntry({ pinned_commit: 'abc' })], + }; + expect( + isAlreadyTrusted(state, { + name: 'hackathon-evaluation', + author: 'Garry Tan', + pinned_commit: 'abc', + tarball_sha256: null, + }), + ).toBe(true); + }); + + test('returns false when author differs (transfer-attack defense)', () => { + const state: SkillpackState = { + schema_version: SKILLPACK_STATE_SCHEMA_VERSION, + packs: [makeEntry({ author: 'Garry Tan', pinned_commit: 'abc' })], + }; + expect( + isAlreadyTrusted(state, { + name: 'hackathon-evaluation', + author: 'Different Author', + pinned_commit: 'abc', + tarball_sha256: null, + }), + ).toBe(false); + }); + + test('returns false when pinned_commit differs', () => { + const state: SkillpackState = { + schema_version: SKILLPACK_STATE_SCHEMA_VERSION, + packs: [makeEntry({ pinned_commit: 'abc' })], + }; + expect( + isAlreadyTrusted(state, { + name: 'hackathon-evaluation', + author: 'Garry Tan', + pinned_commit: 'def', + tarball_sha256: null, + }), + ).toBe(false); + }); + + test('matches on tarball_sha256 for source_kind=tarball', () => { + const state: SkillpackState = { + schema_version: SKILLPACK_STATE_SCHEMA_VERSION, + packs: [makeEntry({ pinned_commit: null, tarball_sha256: 'deadbeef' })], + }; + expect( + isAlreadyTrusted(state, { + name: 'hackathon-evaluation', + author: 'Garry Tan', + pinned_commit: null, + tarball_sha256: 'deadbeef', + }), + ).toBe(true); + }); + + test('returns false for local-path source even when name + author match (no identity to pin)', () => { + const state: SkillpackState = { + schema_version: SKILLPACK_STATE_SCHEMA_VERSION, + packs: [makeEntry({ source_kind: 'local', pinned_commit: null, tarball_sha256: null })], + }; + expect( + isAlreadyTrusted(state, { + name: 'hackathon-evaluation', + author: 'Garry Tan', + pinned_commit: null, + tarball_sha256: null, + }), + ).toBe(false); + }); +}); diff --git a/test/skillpack-tarball.test.ts b/test/skillpack-tarball.test.ts new file mode 100644 index 000000000..a941ec6f0 --- /dev/null +++ b/test/skillpack-tarball.test.ts @@ -0,0 +1,303 @@ +/** + * Tests for src/core/skillpack/tarball.ts — deterministic pack + + * allowlist-gated extract. + * + * Requires GNU tar on PATH (gtar via homebrew on macOS, system tar on Linux). + * Tests skip gracefully when only bsdtar is available. + */ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { spawnSync } from 'child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { + TarballError, + fileSha256, + packTarball, + extractTarball, + DEFAULT_EXTRACT_CAPS, +} from '../src/core/skillpack/tarball.ts'; + +function hasGnuTar(): boolean { + for (const bin of ['gtar', 'tar']) { + const r = spawnSync(bin, ['--version'], { encoding: 'utf-8' }); + if (r.status === 0 && r.stdout.includes('GNU')) return true; + } + return false; +} + +const SKIP_NO_GNU = !hasGnuTar(); +if (SKIP_NO_GNU) { + // eslint-disable-next-line no-console + console.warn('[skillpack-tarball.test] GNU tar not on PATH; skipping deterministic-pack tests'); +} + +describe('packTarball — deterministic output', () => { + let workspace: string; + beforeAll(() => { + workspace = mkdtempSync(join(tmpdir(), 'tarball-pack-')); + }); + afterAll(() => { + rmSync(workspace, { recursive: true, force: true }); + }); + + test.skipIf(SKIP_NO_GNU)('produces a tarball with stable SHA on repeated packs', () => { + const src = join(workspace, 'pack-A'); + mkdirSync(join(src, 'skills/foo'), { recursive: true }); + writeFileSync(join(src, 'skills/foo/SKILL.md'), '---\nname: foo\n---\n'); + writeFileSync(join(src, 'skillpack.json'), JSON.stringify({ name: 'pack-A' }, null, 2)); + + const out1 = join(workspace, 'pack-A-1.tgz'); + const out2 = join(workspace, 'pack-A-2.tgz'); + + const r1 = packTarball({ sourceDir: src, outPath: out1 }); + // Touch the source mtime to prove mtime is normalized. + const skillPath = join(src, 'skills/foo/SKILL.md'); + const future = new Date(Date.now() + 1000 * 60 * 60); // +1h + require('fs').utimesSync(skillPath, future, future); + const r2 = packTarball({ sourceDir: src, outPath: out2 }); + + expect(r1.sha256).toBe(r2.sha256); + expect(r1.fileCount).toBe(r2.fileCount); + expect(r1.fileCount).toBe(2); + }); + + test.skipIf(SKIP_NO_GNU)('packs files in lexicographic order', () => { + const src = join(workspace, 'pack-sorted'); + mkdirSync(join(src, 'a'), { recursive: true }); + mkdirSync(join(src, 'b'), { recursive: true }); + writeFileSync(join(src, 'a/file1'), 'a1'); + writeFileSync(join(src, 'b/file1'), 'b1'); + writeFileSync(join(src, 'README.md'), 'top'); + + const out = join(workspace, 'pack-sorted.tgz'); + const result = packTarball({ sourceDir: src, outPath: out }); + expect(result.fileCount).toBe(3); + expect(existsSync(out)).toBe(true); + }); + + test.skipIf(SKIP_NO_GNU)('throws when source dir is missing', () => { + try { + packTarball({ sourceDir: join(workspace, 'nonexistent'), outPath: join(workspace, 'x.tgz') }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as TarballError).code).toBe('pack_source_missing'); + } + }); + + test.skipIf(SKIP_NO_GNU)('honors exclude patterns', () => { + const src = join(workspace, 'pack-exclude'); + mkdirSync(join(src, '.git'), { recursive: true }); + mkdirSync(join(src, 'node_modules'), { recursive: true }); + mkdirSync(join(src, 'skills'), { recursive: true }); + writeFileSync(join(src, '.git/HEAD'), 'ref: refs/heads/main'); + writeFileSync(join(src, 'node_modules/foo.js'), 'module.exports = {}'); + writeFileSync(join(src, 'skills/SKILL.md'), '---\n---\n'); + + const out = join(workspace, 'pack-exclude.tgz'); + const result = packTarball({ + sourceDir: src, + outPath: out, + exclude: ['.git', 'node_modules'], + }); + // 1 file: skills/SKILL.md + expect(result.fileCount).toBe(1); + }); +}); + +describe('extractTarball — happy path', () => { + let workspace: string; + beforeAll(() => { + workspace = mkdtempSync(join(tmpdir(), 'tarball-extract-')); + }); + afterAll(() => { + rmSync(workspace, { recursive: true, force: true }); + }); + + test.skipIf(SKIP_NO_GNU)('extracts a clean tarball and reports fileCount + sha', () => { + const src = join(workspace, 'pack'); + mkdirSync(join(src, 'skills/foo'), { recursive: true }); + writeFileSync(join(src, 'skills/foo/SKILL.md'), 'hello'); + writeFileSync(join(src, 'skillpack.json'), '{}'); + + const tgz = join(workspace, 'pack.tgz'); + packTarball({ sourceDir: src, outPath: tgz }); + + const dest = join(workspace, 'extracted'); + const r = extractTarball({ tgzPath: tgz, destDir: dest }); + expect(r.fileCount).toBe(2); + expect(r.totalBytes).toBeGreaterThan(0); + expect(r.sha256).toBe(fileSha256(tgz)); + + // Verify files actually landed. + expect(existsSync(join(dest, 'pack/skills/foo/SKILL.md'))).toBe(true); + }); + + test.skipIf(SKIP_NO_GNU)('refuses to extract into a non-empty destination', () => { + const dest = join(workspace, 'nonempty'); + mkdirSync(dest, { recursive: true }); + writeFileSync(join(dest, 'existing'), 'data'); + + const src = join(workspace, 'pack2'); + mkdirSync(join(src, 'skills'), { recursive: true }); + writeFileSync(join(src, 'skills/SKILL.md'), 'x'); + const tgz = join(workspace, 'pack2.tgz'); + packTarball({ sourceDir: src, outPath: tgz }); + + try { + extractTarball({ tgzPath: tgz, destDir: dest }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as TarballError).code).toBe('extract_dest_not_empty'); + } + }); + + test.skipIf(SKIP_NO_GNU)('throws when tgz file is missing', () => { + try { + extractTarball({ tgzPath: join(workspace, 'nope.tgz'), destDir: join(workspace, 'd') }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as TarballError).code).toBe('extract_tgz_missing'); + } + }); +}); + +describe('extractTarball — allowlist + caps', () => { + let workspace: string; + beforeAll(() => { + workspace = mkdtempSync(join(tmpdir(), 'tarball-caps-')); + }); + afterAll(() => { + rmSync(workspace, { recursive: true, force: true }); + }); + + test.skipIf(SKIP_NO_GNU)('rejects a tarball containing a symlink', () => { + const src = join(workspace, 'pack-symlink'); + mkdirSync(src, { recursive: true }); + writeFileSync(join(src, 'real-file'), 'real'); + try { + symlinkSync('real-file', join(src, 'evil-link')); + } catch { + // Symlink creation requires elevated perms on some Windows configs; + // skip the assertion if it's unavailable. + return; + } + + const tgz = join(workspace, 'pack-symlink.tgz'); + packTarball({ sourceDir: src, outPath: tgz }); + try { + extractTarball({ tgzPath: tgz, destDir: join(workspace, 'extracted-symlink') }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as TarballError).code).toBe('extract_disallowed_entry_type'); + } + }); + + test.skipIf(SKIP_NO_GNU)('rejects when maxFiles is exceeded', () => { + const src = join(workspace, 'pack-many'); + mkdirSync(join(src, 'd'), { recursive: true }); + for (let i = 0; i < 12; i++) writeFileSync(join(src, 'd', `f${i}`), 'x'); + const tgz = join(workspace, 'pack-many.tgz'); + packTarball({ sourceDir: src, outPath: tgz }); + try { + extractTarball({ + tgzPath: tgz, + destDir: join(workspace, 'ext-many'), + caps: { maxFiles: 5 }, + }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as TarballError).code).toBe('extract_too_many_files'); + } + }); + + test.skipIf(SKIP_NO_GNU)('rejects when a single file exceeds maxBytesPerFile', () => { + const src = join(workspace, 'pack-big-file'); + mkdirSync(src, { recursive: true }); + writeFileSync(join(src, 'big'), 'x'.repeat(2048)); + const tgz = join(workspace, 'pack-big-file.tgz'); + packTarball({ sourceDir: src, outPath: tgz }); + try { + extractTarball({ + tgzPath: tgz, + destDir: join(workspace, 'ext-big'), + caps: { maxBytesPerFile: 1024 }, + }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as TarballError).code).toBe('extract_file_too_large'); + } + }); + + test.skipIf(SKIP_NO_GNU)('rejects when total bytes exceed maxTotalBytes', () => { + const src = join(workspace, 'pack-big-total'); + mkdirSync(src, { recursive: true }); + for (let i = 0; i < 5; i++) writeFileSync(join(src, `f${i}`), 'x'.repeat(512)); + const tgz = join(workspace, 'pack-big-total.tgz'); + packTarball({ sourceDir: src, outPath: tgz }); + try { + extractTarball({ + tgzPath: tgz, + destDir: join(workspace, 'ext-big-total'), + // Per-file 1024 is fine, but total cap of 1024 is below the sum of 5*512 = 2560. + caps: { maxBytesPerFile: 1024, maxTotalBytes: 1024 }, + }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as TarballError).code).toBe('extract_total_too_large'); + } + }); + + test.skipIf(SKIP_NO_GNU)('rejects entries whose path exceeds maxPathLength', () => { + const src = join(workspace, 'pack-longpath'); + // Deep nesting keeps each segment short enough for the OS but the total + // path long enough to trip the cap. 30 segments × 'aaaaaaaa/' (9 chars) + // ≈ 270 chars total. + const segments = Array.from({ length: 30 }, () => 'aaaaaaaa').join('/'); + const deep = join(src, segments); + mkdirSync(deep, { recursive: true }); + writeFileSync(join(deep, 'file'), 'x'); + const tgz = join(workspace, 'pack-longpath.tgz'); + packTarball({ sourceDir: src, outPath: tgz }); + try { + extractTarball({ + tgzPath: tgz, + destDir: join(workspace, 'ext-longpath'), + caps: { maxPathLength: 100 }, + }); + throw new Error('should have thrown'); + } catch (err) { + expect((err as TarballError).code).toBe('extract_path_too_long'); + } + }); +}); + +describe('DEFAULT_EXTRACT_CAPS — frozen contract', () => { + test('caps match the spec', () => { + expect(DEFAULT_EXTRACT_CAPS.maxFiles).toBe(5000); + expect(DEFAULT_EXTRACT_CAPS.maxBytesPerFile).toBe(1024 * 1024); + expect(DEFAULT_EXTRACT_CAPS.maxTotalBytes).toBe(100 * 1024 * 1024); + expect(DEFAULT_EXTRACT_CAPS.maxPathLength).toBe(255); + expect(DEFAULT_EXTRACT_CAPS.maxCompressionRatio).toBe(100); + }); +}); + +describe('fileSha256 — pure utility', () => { + test('computes a stable hash for a known input', () => { + const tmp = join(mkdtempSync(join(tmpdir(), 'sha-')), 'f'); + writeFileSync(tmp, 'hello'); + // sha256('hello') = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 + expect(fileSha256(tmp)).toBe('2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824'); + }); +}); diff --git a/test/skillpack-trust-prompt.test.ts b/test/skillpack-trust-prompt.test.ts new file mode 100644 index 000000000..f8dd960ae --- /dev/null +++ b/test/skillpack-trust-prompt.test.ts @@ -0,0 +1,206 @@ +/** + * Tests for src/core/skillpack/trust-prompt.ts — codex G4 first-install + * confirm + identity rendering + state-based TOFU skip. + */ +import { describe, test, expect } from 'bun:test'; + +import { + renderIdentityBlock, + askTrust, + type TrustPromptInput, +} from '../src/core/skillpack/trust-prompt.ts'; +import { + SKILLPACK_STATE_SCHEMA_VERSION, + type SkillpackState, +} from '../src/core/skillpack/state.ts'; +import { SKILLPACK_API_VERSION } from '../src/core/skillpack/manifest-v1.ts'; + +function makeInput( + over: Partial = {}, +): TrustPromptInput { + return { + manifest: { + api_version: SKILLPACK_API_VERSION, + name: 'hackathon-evaluation', + version: '0.1.0', + description: 'YC hackathon judging rubric', + author: 'Garry Tan', + license: 'MIT', + homepage: 'https://github.com/garrytan/skillpack-hackathon-evaluation', + gbrain_min_version: '0.36.0', + skills: ['skills/judge-submission'], + }, + resolved: { + path: '/tmp/cached/garrytan/skillpack-hackathon-evaluation/abc123', + kind: 'git', + source: 'https://github.com/garrytan/skillpack-hackathon-evaluation.git', + pinned_commit: 'abc1234567890abcdef1234567890abcdef12345', + tarball_sha256: null, + cache_hit: false, + }, + tier: 'endorsed', + state: { schema_version: SKILLPACK_STATE_SCHEMA_VERSION, packs: [] }, + ...over, + }; +} + +describe('renderIdentityBlock — pure formatter', () => { + test('renders the canonical identity block for a git source', () => { + const block = renderIdentityBlock(makeInput()); + expect(block).toContain('Name: hackathon-evaluation'); + expect(block).toContain('Version: 0.1.0'); + expect(block).toContain('Author: Garry Tan'); + expect(block).toContain('Source: https://github.com/garrytan/skillpack-hackathon-evaluation.git'); + expect(block).toContain('Pinned commit: abc1234567890abcdef1234567890abcdef12345'); + expect(block).toContain('Tier: endorsed'); + expect(block).toContain('Description: YC hackathon judging rubric'); + }); + + test('shows tarball SHA when source kind is tarball', () => { + const block = renderIdentityBlock( + makeInput({ + resolved: { + path: '/tmp/cache', + kind: 'tarball', + source: '/Users/me/pack.tgz', + pinned_commit: null, + tarball_sha256: 'deadbeef', + cache_hit: false, + }, + }), + ); + expect(block).toContain('Tarball SHA: sha256:deadbeef'); + expect(block).not.toContain('Pinned commit'); + }); +}); + +describe('askTrust — decision matrix', () => { + test('local-path source skips prompt entirely', async () => { + const d = await askTrust( + makeInput({ + resolved: { + path: '/Users/me/local-pack', + kind: 'local', + source: '/Users/me/local-pack', + pinned_commit: null, + tarball_sha256: null, + cache_hit: false, + }, + }), + ); + expect(d.trusted).toBe(true); + expect(d.reason).toBe('local_path_no_prompt'); + }); + + test('already-trusted skips prompt (state has matching identity)', async () => { + const state: SkillpackState = { + schema_version: SKILLPACK_STATE_SCHEMA_VERSION, + packs: [ + { + name: 'hackathon-evaluation', + version: '0.1.0', + author: 'Garry Tan', + source: 'https://github.com/garrytan/skillpack-hackathon-evaluation.git', + source_kind: 'git', + pinned_commit: 'abc1234567890abcdef1234567890abcdef12345', + tarball_sha256: null, + tier: 'endorsed', + scaffolded_at: '2026-01-01T00:00:00Z', + workspace: '/tmp/ws', + skill_slugs: ['skills/judge-submission'], + }, + ], + }; + const d = await askTrust(makeInput({ state })); + expect(d.trusted).toBe(true); + expect(d.reason).toBe('already_trusted'); + }); + + test('--trust flag bypasses prompt for first-install', async () => { + const d = await askTrust(makeInput(), { trustFlag: true }); + expect(d.trusted).toBe(true); + expect(d.reason).toBe('trust_flag_bypassed'); + }); + + test('non-TTY without --trust flag refuses', async () => { + const d = await askTrust(makeInput(), { isTTY: false }); + expect(d.trusted).toBe(false); + expect(d.reason).toBe('non_tty_no_trust_flag'); + }); + + test('accepts when user answers "y"', async () => { + const d = await askTrust(makeInput(), { + isTTY: true, + readLine: async () => 'y', + }); + expect(d.trusted).toBe(true); + expect(d.reason).toBe('prompt_accepted'); + }); + + test('accepts when user answers "yes" (case-insensitive)', async () => { + const d = await askTrust(makeInput(), { + isTTY: true, + readLine: async () => 'YES', + }); + expect(d.trusted).toBe(true); + expect(d.reason).toBe('prompt_accepted'); + }); + + test('rejects when user answers "n"', async () => { + const d = await askTrust(makeInput(), { + isTTY: true, + readLine: async () => 'n', + }); + expect(d.trusted).toBe(false); + expect(d.reason).toBe('prompt_rejected'); + }); + + test('rejects on empty input (default to no)', async () => { + const d = await askTrust(makeInput(), { + isTTY: true, + readLine: async () => '', + }); + expect(d.trusted).toBe(false); + expect(d.reason).toBe('prompt_rejected'); + }); + + test('rejects on garbage input (default to no)', async () => { + const d = await askTrust(makeInput(), { + isTTY: true, + readLine: async () => 'maybe', + }); + expect(d.trusted).toBe(false); + expect(d.reason).toBe('prompt_rejected'); + }); + + test('author mismatch in state still triggers prompt', async () => { + const state: SkillpackState = { + schema_version: SKILLPACK_STATE_SCHEMA_VERSION, + packs: [ + { + name: 'hackathon-evaluation', + version: '0.1.0', + author: 'Different Author', // mismatch + source: 'https://github.com/different/skillpack-hackathon-evaluation.git', + source_kind: 'git', + pinned_commit: 'abc1234567890abcdef1234567890abcdef12345', + tarball_sha256: null, + tier: 'endorsed', + scaffolded_at: '2026-01-01T00:00:00Z', + workspace: '/tmp/ws', + skill_slugs: ['skills/judge-submission'], + }, + ], + }; + let promptShown = false; + const d = await askTrust(makeInput({ state }), { + isTTY: true, + readLine: async () => { + promptShown = true; + return 'y'; + }, + }); + expect(promptShown).toBe(true); + expect(d.reason).toBe('prompt_accepted'); + }); +});