From ecbb67fc54ca27ce231defbf61fba3922e38dce2 Mon Sep 17 00:00:00 2001 From: namastex888 Date: Fri, 10 Jul 2026 01:53:54 -0300 Subject: [PATCH 1/4] feat(skills): wish template ships in-skill via CLAUDE_SKILL_DIR (G1) --- skills/README.md | 2 +- skills/brainstorm/SKILL.md | 2 +- skills/wish/SKILL.md | 10 +++++----- {templates => skills/wish/templates}/wish-template.md | 0 tests/e2e/v5-lifecycle.sh | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) rename {templates => skills/wish/templates}/wish-template.md (100%) diff --git a/skills/README.md b/skills/README.md index 1b8ca185e..0d597777e 100644 --- a/skills/README.md +++ b/skills/README.md @@ -16,7 +16,7 @@ Decision legend: | Skill | Decision | Rationale | |-------|----------|-----------| | `brainstorm` | Keep — core (rewritten here) | Ideation → DESIGN.md. WRS scoring and crystallize are pure methodology; only the tracking-task call moved to `genie task`, artifacts stay in `.genie/`. | -| `wish` | Keep — core (rewritten here) | DESIGN.md → WISH.md with groups + DAG. Scaffold is now a `cp` of `templates/wish-template.md`; per-group tasks via `genie task`; lint via `bun run wishes:lint`. | +| `wish` | Keep — core (rewritten here) | DESIGN.md → WISH.md with groups + DAG. Scaffold is now a `cp` of `skills/wish/templates/wish-template.md`; per-group tasks via `genie task`; lint via `grep -q '"wishes:lint"' package.json 2>/dev/null && bun run wishes:lint`. | | `work` | Keep — core (rewritten here) | Wave dispatch + fix loops + validation. Dispatch is now the Agent tool (native team), state via `genie task checkout/done`, completion by notification (no polling). | | `review` | Keep — core (rewritten here) | SHIP/FIX-FIRST/BLOCKED gate. Verdict is the output (reported, not a task mutation); dispatched as a separate subagent (reviewer ≠ engineer) via the Agent tool. | | `genie` | Keep — portable now | Natural-language router into the other skills. Routing logic is runtime-agnostic; any command hand-offs re-point to the `genie` namespace during its own port. | diff --git a/skills/brainstorm/SKILL.md b/skills/brainstorm/SKILL.md index f0ca95e78..bda863299 100644 --- a/skills/brainstorm/SKILL.md +++ b/skills/brainstorm/SKILL.md @@ -88,7 +88,7 @@ At WRS = 100: ```bash git add .genie/brainstorms//DESIGN.md .genie/brainstorms//DRAFT.md ``` - Stage exactly these two; other brainstorm artifacts stay untracked. `bun run wishes:lint` fails any wish whose design link doesn't resolve to a real file — uncommitted brainstorms are missing in CI and sibling worktrees, so never skip the stage. + Stage exactly these two; other brainstorm artifacts stay untracked. The genie repo's wish linter fails any wish whose design link doesn't resolve to a real file — uncommitted brainstorms are missing in CI and sibling worktrees, so never skip the stage. 4. Update the jar — move the entry to Poured with the wish link. 5. Create a board pointer; if this fails (no `.genie/genie.db` yet, CLI unavailable), warn and continue — DESIGN.md and the jar in git are the source of truth: ```bash diff --git a/skills/wish/SKILL.md b/skills/wish/SKILL.md index 8da00b29c..2563e9245 100644 --- a/skills/wish/SKILL.md +++ b/skills/wish/SKILL.md @@ -35,9 +35,9 @@ test -f .genie/brainstorms//DESIGN.md 5. **Scaffold** — always copy the template, never hand-write WISH.md: ```bash mkdir -p .genie/wishes/ - cp templates/wish-template.md .genie/wishes//WISH.md + cp "${CLAUDE_SKILL_DIR}/templates/wish-template.md" .genie/wishes//WISH.md ``` - `templates/wish-template.md` (genie repo) is the single source of truth for wish structure — a plain git document, no runtime scaffolder. Copying guarantees the skeleton the parser and linter expect; ad-hoc wishes regularly fail `bun run wishes:lint`. + The template ships inside this skill as the single source of truth for wish structure — a plain document, no runtime scaffolder. Copying guarantees the skeleton the parser and linter expect; ad-hoc wishes regularly fail structural lint. 6. **Fill:** replace the `{{slug}}`/`{{date}}` tokens and every `` marker with real content. Every group gets acceptance criteria plus a validation command. 7. **Declare dependencies:** `depends-on` between execution groups and cross-wish `depends-on`/`blocks` in the WISH.md — the DAG is a planning artifact in git. 8. **Create tasks** — one per execution group, so `/work` can claim and complete each group and the board reflects progress: @@ -46,7 +46,7 @@ test -f .genie/brainstorms//DESIGN.md genie task list --wish # inspect what was created ``` Tasks carry the `--wish`/`--group` linkage; the dependency DAG stays in the WISH.md document, not in task rows. If creation fails (no `.genie/genie.db` yet, CLI unavailable), warn and continue — WISH.md in git is the source of truth and must remain usable by `/work` without task rows. -9. **Handoff:** run `bun run wishes:lint`. If it reports any error, surface it and stop — never hand a structurally broken wish onward. Only after lint passes, auto-invoke `/review` (plan review) on the WISH.md. Never suggest `/work` directly — the review gate comes first. +9. **Handoff:** run the wish linter — inside the genie repo, `grep -q '"wishes:lint"' package.json 2>/dev/null && bun run wishes:lint`. If it reports any error, surface it and stop — never hand a structurally broken wish onward. Only after lint passes, auto-invoke `/review` (plan review) on the WISH.md. Never suggest `/work` directly — the review gate comes first. ## Wish Document Sections @@ -64,8 +64,8 @@ test -f .genie/brainstorms//DESIGN.md | Assumptions / Risks | No | What could invalidate the plan | ## Rules -- Never write WISH.md from scratch — always `cp templates/wish-template.md`, then edit. -- Lint before handoff: `bun run wishes:lint` must pass before `/review` sees the wish. +- Never write WISH.md from scratch — always copy the in-skill template, then edit. +- Lint before handoff: the genie repo's wish linter must pass before `/review` sees the wish. - Never emit a bracket-link to a non-existent brainstorm — use the `_No brainstorm — direct wish_` stub. - No implementation during `/wish` — planning only. - Every group testable, bite-sized, and independently shippable; no vague tasks ("improve everything"). diff --git a/templates/wish-template.md b/skills/wish/templates/wish-template.md similarity index 100% rename from templates/wish-template.md rename to skills/wish/templates/wish-template.md diff --git a/tests/e2e/v5-lifecycle.sh b/tests/e2e/v5-lifecycle.sh index 7433890fb..00377e805 100755 --- a/tests/e2e/v5-lifecycle.sh +++ b/tests/e2e/v5-lifecycle.sh @@ -153,7 +153,7 @@ step "author wish documents" WISH_DIR="$FIXTURE/.genie/wishes/$SLUG" mkdir -p "$WISH_DIR" # Render a WISH.md from the repo template (skills copy this template verbatim). -sed "s/{{slug}}/$SLUG/g; s/{{date}}/$(date +%F)/g" "$REPO_ROOT/templates/wish-template.md" > "$WISH_DIR/WISH.md" +sed "s/{{slug}}/$SLUG/g; s/{{date}}/$(date +%F)/g" "$REPO_ROOT/skills/wish/templates/wish-template.md" > "$WISH_DIR/WISH.md" # A brainstorm design note (the skills' upstream artifact). printf '# Design: %s\n\nZero-daemon lifecycle proof.\n' "$SLUG" > "$WISH_DIR/DESIGN.md" git -C "$FIXTURE" add ".genie/wishes/$SLUG/WISH.md" ".genie/wishes/$SLUG/DESIGN.md" From 203c97dfa897bfc54140c2b4430a0fa0458c4ab6 Mon Sep 17 00:00:00 2001 From: namastex888 Date: Fri, 10 Jul 2026 01:39:06 -0300 Subject: [PATCH 2/4] feat(ci): fresh-install smoke + plugin release-lag docs (G3) --- .github/workflows/ci.yml | 3 + plugins/genie/README.md | 13 +++ scripts/fresh-install-smoke.test.ts | 51 +++++++++ scripts/fresh-install-smoke.ts | 154 ++++++++++++++++++++++++++++ 4 files changed, 221 insertions(+) create mode 100644 scripts/fresh-install-smoke.test.ts create mode 100644 scripts/fresh-install-smoke.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ccdcc1b6..c5738a558 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,6 +74,9 @@ jobs: - name: Wishes lint run: bun run wishes:lint + - name: Fresh-install smoke + run: bun run scripts/fresh-install-smoke.ts + - name: Test run: bun test diff --git a/plugins/genie/README.md b/plugins/genie/README.md index 405825e9f..ffe1509f4 100644 --- a/plugins/genie/README.md +++ b/plugins/genie/README.md @@ -39,6 +39,19 @@ The multi-perspective engine ships as a native dynamic workflow, not a skill: - **Requirements**: Claude Code ≥ 2.1.154 with dynamic workflows available (paid plans; an org-level `disableWorkflows` setting turns the command off). - **Override**: a project-level `.claude/workflows/council.js` takes precedence over the personal stamped copy. +## Release lag: pinned versions and update cadence + +Installed plugin versions **pin to GitHub Releases** — a `/plugin install` snapshots +whatever release is current and stays there until you update. It does **not** track +`dev` or `main`. The update cadence is manual: run `/plugin update` to advance a +machine to the latest published release. + +Because the pin is sticky, a machine can drift well behind the source tree. Observed +example: a machine sat pinned at `5.260703.5` for a week while `dev` moved on — the +plugin kept working, but none of the intervening fixes reached it until someone ran +`/plugin update`. Treat `/plugin update` as a periodic hygiene step, not a one-time +setup action. + ## Directory Structure ```text diff --git a/scripts/fresh-install-smoke.test.ts b/scripts/fresh-install-smoke.test.ts new file mode 100644 index 000000000..57b4ce383 --- /dev/null +++ b/scripts/fresh-install-smoke.test.ts @@ -0,0 +1,51 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const SMOKE_SCRIPT = join(import.meta.dir, 'fresh-install-smoke.ts'); + +function runSmoke(args: string[] = []): { code: number; stdout: string; stderr: string } { + const result = Bun.spawnSync(['bun', SMOKE_SCRIPT, ...args], { + stdout: 'pipe', + stderr: 'pipe', + }); + return { + code: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + }; +} + +describe('fresh-install-smoke', () => { + test('exits 0 against the real repository skills tree', () => { + const result = runSmoke(); + // Surface the failure reason if this ever regresses. + expect(result.stdout + result.stderr).toContain('fresh-install-smoke: OK'); + expect(result.code).toBe(0); + }); + + describe('broken fixture', () => { + let skillsDir: string; + + beforeEach(() => { + skillsDir = mkdtempSync(join(tmpdir(), 'genie-fresh-install-fixture-')); + const skill = join(skillsDir, 'brokenskill'); + mkdirSync(skill, { recursive: true }); + writeFileSync( + join(skill, 'SKILL.md'), + '# Broken skill\n\n```bash\ncp "${CLAUDE_SKILL_DIR}/templates/does-not-exist.md" out.md\n```\n', + ); + }); + + afterEach(() => { + rmSync(skillsDir, { recursive: true, force: true }); + }); + + test('exits non-zero when a SKILL.md references a missing ${CLAUDE_SKILL_DIR} path', () => { + const result = runSmoke(['--skills-dir', skillsDir]); + expect(result.code).not.toBe(0); + expect(result.stderr).toContain('does not resolve to a real file'); + }); + }); +}); diff --git a/scripts/fresh-install-smoke.ts b/scripts/fresh-install-smoke.ts new file mode 100644 index 000000000..7d268e695 --- /dev/null +++ b/scripts/fresh-install-smoke.ts @@ -0,0 +1,154 @@ +#!/usr/bin/env bun +/** + * fresh-install-smoke: a broken fresh install must never reach a release + * unnoticed. Two guarantees, both exercised against the shipped skill tree: + * + * (a) every `${CLAUDE_SKILL_DIR}/` reference inside a SKILL.md + * resolves to a real file INSIDE that skill's own directory — a plugin + * install materializes each skill under its own CLAUDE_SKILL_DIR, so a + * reference that escapes the dir or points at a missing file is a + * ship-blocking breakage the moment the plugin is installed. + * + * (b) the /wish scaffold step (template copy via the resolved + * CLAUDE_SKILL_DIR) succeeds in a fresh git repo with NO genie CLI on + * PATH, and the copied skeleton carries the structural checklist the + * parser and linter expect. + * + * Exits non-zero with a clear message on any violation. Temp dirs are removed + * even when an assertion fails. + * + * Usage: bun run scripts/fresh-install-smoke.ts [--skills-dir ] + */ + +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve, sep } from 'node:path'; + +const REPO_ROOT = new URL('..', import.meta.url).pathname.replace(/\/$/, ''); + +function fail(message: string): never { + console.error(`fresh-install-smoke: FAIL — ${message}`); + process.exit(1); +} + +function parseArgs(argv: string[]): { skillsDir: string } { + let skillsDir = join(REPO_ROOT, 'skills'); + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--skills-dir') { + const next = argv[i + 1]; + if (!next) fail('--skills-dir requires a path argument'); + skillsDir = resolve(next); + i++; + } + } + return { skillsDir }; +} + +function listSkillDirs(skillsDir: string): string[] { + return readdirSync(skillsDir) + .map((name) => join(skillsDir, name)) + .filter((p) => statSync(p).isDirectory() && existsSync(join(p, 'SKILL.md'))); +} + +/** + * (a) Resolve every `${CLAUDE_SKILL_DIR}/` reference against the skill + * that owns the SKILL.md. Returns the number of references verified. + */ +function checkSkillDirReferences(skillsDir: string): number { + let refs = 0; + for (const skillDir of listSkillDirs(skillsDir)) { + const text = readFileSync(join(skillDir, 'SKILL.md'), 'utf8'); + // Capture the path chars after the token, stopping at whitespace, quote, + // backtick, or closing paren — the delimiters that surround it in prose or + // a shell fence. + const re = /\$\{CLAUDE_SKILL_DIR\}\/([^\s"'`)\\]+)/g; + let m: RegExpExecArray | null = re.exec(text); + while (m !== null) { + refs++; + const relPath = m[1].replace(/[.,;:)]+$/, ''); + const resolved = resolve(skillDir, relPath); + const within = resolved === skillDir || resolved.startsWith(skillDir + sep); + const label = `${skillDir}/SKILL.md: \${CLAUDE_SKILL_DIR}/${relPath}`; + if (!within) fail(`${label} escapes the skill directory`); + if (!existsSync(resolved) || !statSync(resolved).isFile()) { + fail(`${label} does not resolve to a real file`); + } + m = re.exec(text); + } + } + return refs; +} + +/** + * (b) Materialize the skill tree the way a plugin install lays it down, then + * run the /wish scaffold step in a fresh git repo with a genie-free PATH. + */ +function runWishScaffoldSmoke(skillsDir: string): void { + if (!existsSync(join(skillsDir, 'wish', 'SKILL.md'))) { + fail(`no wish skill under ${skillsDir} — cannot exercise the scaffold step`); + } + + const workRoot = mkdtempSync(join(tmpdir(), 'genie-fresh-install-')); + try { + // Copy the skills the way an installed plugin materializes them. + const pluginSkills = join(workRoot, 'plugin', 'skills'); + mkdirSync(dirname(pluginSkills), { recursive: true }); + cpSync(skillsDir, pluginSkills, { recursive: true }); + + // A fresh consumer repo with no genie state. + const repo = join(workRoot, 'consumer-repo'); + mkdirSync(repo, { recursive: true }); + const git = Bun.spawnSync(['git', 'init', '-q'], { cwd: repo, stdout: 'pipe', stderr: 'pipe' }); + if (git.exitCode !== 0) fail(`git init failed: ${git.stderr.toString().trim()}`); + + // Execute the /wish scaffold verbatim, under a PATH that cannot see genie. + const installedWishDir = join(pluginSkills, 'wish'); + const slug = 'smoke-wish'; + const script = [ + 'set -e', + 'if command -v genie >/dev/null 2>&1; then echo "genie unexpectedly on PATH" >&2; exit 3; fi', + `mkdir -p ".genie/wishes/${slug}"`, + `cp "\${CLAUDE_SKILL_DIR}/templates/wish-template.md" ".genie/wishes/${slug}/WISH.md"`, + ].join('\n'); + const scaffold = Bun.spawnSync(['bash', '-c', script], { + cwd: repo, + env: { PATH: '/usr/bin:/bin:/usr/sbin:/sbin', CLAUDE_SKILL_DIR: installedWishDir }, + stdout: 'pipe', + stderr: 'pipe', + }); + if (scaffold.exitCode !== 0) { + fail(`wish scaffold step failed (exit ${scaffold.exitCode}): ${scaffold.stderr.toString().trim()}`); + } + + // Structural checklist presence in the copied skeleton. + const wishPath = join(repo, '.genie', 'wishes', slug, 'WISH.md'); + if (!existsSync(wishPath)) fail('scaffold produced no WISH.md'); + const wish = readFileSync(wishPath, 'utf8'); + const required = [ + '## Summary', + '## Scope', + '### IN', + '### OUT', + '## Success Criteria', + '## Execution Strategy', + '## Execution Groups', + ]; + const missing = required.filter((section) => !wish.includes(section)); + if (missing.length > 0) { + fail(`scaffolded WISH.md missing structural section(s): ${missing.join(', ')}`); + } + } finally { + rmSync(workRoot, { recursive: true, force: true }); + } +} + +function main(): void { + const { skillsDir } = parseArgs(process.argv.slice(2)); + if (!existsSync(skillsDir)) fail(`skills dir not found: ${skillsDir}`); + const refs = checkSkillDirReferences(skillsDir); + runWishScaffoldSmoke(skillsDir); + const summary = `${refs} \${CLAUDE_SKILL_DIR} reference(s) resolved, wish scaffold works with no genie on PATH`; + console.log(`fresh-install-smoke: OK (${summary})`); +} + +main(); From bbd6439e17d50e6596dd3bee614fff55ed267be9 Mon Sep 17 00:00:00 2001 From: namastex888 Date: Fri, 10 Jul 2026 01:41:26 -0300 Subject: [PATCH 3/4] =?UTF-8?q?feat(lint):=20resource-shipping=20rule=20?= =?UTF-8?q?=E2=80=94=20skills=20must=20ship=20their=20own=20resources=20(G?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/skills-lint.test.ts | 169 ++++++++++++++++++++++++++++++++++++ scripts/skills-lint.ts | 130 ++++++++++++++++++++++++--- 2 files changed, 288 insertions(+), 11 deletions(-) create mode 100644 scripts/skills-lint.test.ts diff --git a/scripts/skills-lint.test.ts b/scripts/skills-lint.test.ts new file mode 100644 index 000000000..92e385e6e --- /dev/null +++ b/scripts/skills-lint.test.ts @@ -0,0 +1,169 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + checkResourceLine, + collectResourceViolations, + extractInlineCodeSpans, + isResourceAllowlisted, +} from './skills-lint.ts'; + +const SCRIPT = join(import.meta.dir, 'skills-lint.ts'); + +describe('checkResourceLine — imperative discriminators', () => { + test('flags an imperative repo-root template copy', () => { + expect(checkResourceLine('cp templates/wish-template.md dest.md').map((v) => v.rule)).toEqual(['cp-repo-template']); + expect(checkResourceLine('cp -r templates/foo bar').map((v) => v.rule)).toEqual(['cp-repo-template']); + expect(checkResourceLine('cp ./templates/foo.md dest').map((v) => v.rule)).toEqual(['cp-repo-template']); + }); + + test('passes a ${CLAUDE_SKILL_DIR}-addressed template copy', () => { + expect(checkResourceLine('cp "${CLAUDE_SKILL_DIR}/templates/wish-template.md" dest.md')).toEqual([]); + expect(checkResourceLine('cp "${CLAUDE_PLUGIN_ROOT}/templates/foo.md" dest.md')).toEqual([]); + }); + + test('flags an unguarded repo-only lint invocation', () => { + expect(checkResourceLine('bun run wishes:lint').map((v) => v.rule)).toEqual(['unguarded-repo-lint']); + expect(checkResourceLine('bun run skills:lint').map((v) => v.rule)).toEqual(['unguarded-repo-lint']); + }); + + test('passes a SAME-LINE package.json-guarded invocation', () => { + const guarded = `grep -q '"wishes:lint"' package.json 2>/dev/null && bun run wishes:lint`; + expect(checkResourceLine(guarded)).toEqual([]); + }); + + test('flags an imperative repo-script invocation but not a descriptive mention', () => { + expect(checkResourceLine('bun run scripts/skills-lint.ts').map((v) => v.rule)).toEqual(['repo-script-invocation']); + expect(checkResourceLine('node scripts/foo.ts').map((v) => v.rule)).toEqual(['repo-script-invocation']); + // Descriptive/paraphrase mention with no run verb must NOT trip. + expect(checkResourceLine('The linter (scripts/wishes-lint.ts) accepts the stub text.')).toEqual([]); + }); +}); + +describe('collectResourceViolations — fence + inline surfaces', () => { + test('scans inline-code spans, not just fences', () => { + const md = 'Run the linter — `bun run wishes:lint` after editing.'; + expect(collectResourceViolations(md).map((v) => v.rule)).toEqual(['unguarded-repo-lint']); + }); + + test('same-line guard inside one inline span passes', () => { + const md = 'Handoff: `grep -q \'"wishes:lint"\' package.json 2>/dev/null && bun run wishes:lint`.'; + expect(collectResourceViolations(md)).toEqual([]); + }); + + test('SPLIT-LINE guard (probe on line N, command on line N+1) still FAILS', () => { + const md = ['```bash', `grep -q '"wishes:lint"' package.json 2>/dev/null`, 'bun run wishes:lint', '```'].join('\n'); + expect(collectResourceViolations(md).map((v) => v.rule)).toEqual(['unguarded-repo-lint']); + }); + + test('descriptive prose path mention outside code context is clean', () => { + const md = 'The template lives under templates/ and scripts/foo.ts documents it.'; + expect(collectResourceViolations(md)).toEqual([]); + }); +}); + +describe('extractInlineCodeSpans', () => { + test('captures single-line backtick spans, skips fences-only content', () => { + expect(extractInlineCodeSpans('a `one` b `two` c')).toEqual(['one', 'two']); + expect(extractInlineCodeSpans('no code here')).toEqual([]); + }); +}); + +describe('end-to-end: skills-lint against fixture skills trees', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'skills-lint-')); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + function writeSkill(name: string, body: string): void { + const skillDir = join(dir, name); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, 'SKILL.md'), body); + } + + function runLint(): { code: number; stdout: string; stderr: string } { + try { + const stdout = execFileSync('bun', [SCRIPT], { + env: { ...process.env, SKILLS_LINT_DIR: dir }, + encoding: 'utf8', + }); + return { code: 0, stdout, stderr: '' }; + } catch (err) { + const e = err as { status?: number; stdout?: Buffer | string; stderr?: Buffer | string }; + return { + code: e.status ?? 1, + stdout: e.stdout?.toString() ?? '', + stderr: e.stderr?.toString() ?? '', + }; + } + } + + test('an offending skill (cp templates/...) exits non-zero', () => { + writeSkill('bad', ['# bad', '', '```bash', 'cp templates/wish-template.md dest.md', '```', ''].join('\n')); + const { code, stderr } = runLint(); + expect(code).not.toBe(0); + expect(stderr).toContain('cp-repo-template'); + }); + + test('a ${CLAUDE_SKILL_DIR} skill passes', () => { + writeSkill( + 'good', + ['# good', '', '```bash', 'cp "${CLAUDE_SKILL_DIR}/templates/wish-template.md" dest.md', '```', ''].join('\n'), + ); + expect(runLint().code).toBe(0); + }); + + test('allowlisted genie-hacks content passes even with repo-root recipes', () => { + writeSkill( + 'genie-hacks', + ['# hacks', '', '```bash', 'cp templates/foo.md dest.md', 'bun run wishes:lint', '```', ''].join('\n'), + ); + expect(runLint().code).toBe(0); + }); + + test('a same-line-guarded invocation passes while a split-line guard fails', () => { + writeSkill( + 'guarded', + [ + '# guarded', + '', + 'Handoff: `grep -q \'"wishes:lint"\' package.json 2>/dev/null && bun run wishes:lint`.', + '', + ].join('\n'), + ); + expect(runLint().code).toBe(0); + + rmSync(join(dir, 'guarded'), { recursive: true, force: true }); + writeSkill( + 'split', + [ + '# split', + '', + '```bash', + `grep -q '"wishes:lint"' package.json 2>/dev/null`, + 'bun run wishes:lint', + '```', + '', + ].join('\n'), + ); + const { code, stderr } = runLint(); + expect(code).not.toBe(0); + expect(stderr).toContain('unguarded-repo-lint'); + }); +}); + +describe('isResourceAllowlisted', () => { + test('genie-hacks is allowlisted; README.md is not', () => { + const skillsDir = '/repo/skills'; + expect(isResourceAllowlisted('/repo/skills/genie-hacks/SKILL.md', skillsDir)).toBe(true); + expect(isResourceAllowlisted('/repo/skills/genie-hacks/references/catalog.md', skillsDir)).toBe(true); + expect(isResourceAllowlisted('/repo/skills/README.md', skillsDir)).toBe(false); + expect(isResourceAllowlisted('/repo/skills/wish/SKILL.md', skillsDir)).toBe(false); + }); +}); diff --git a/scripts/skills-lint.ts b/scripts/skills-lint.ts index ef46e2664..1b201f5eb 100644 --- a/scripts/skills-lint.ts +++ b/scripts/skills-lint.ts @@ -10,10 +10,25 @@ import { execSync } from 'node:child_process'; import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; -import { join, relative } from 'node:path'; +import { join, relative, sep } from 'node:path'; const ROOT = new URL('..', import.meta.url).pathname.replace(/\/$/, ''); -const SKILLS_DIR = join(ROOT, 'skills'); +// SKILLS_LINT_DIR lets tests point the scanner at a fixture tree; defaults to +// the repo's own skills/ directory. +const SKILLS_DIR = process.env.SKILLS_LINT_DIR ?? join(ROOT, 'skills'); + +// Resource-shipping allowlist: catalog/recipe content is allowed to show +// repo-root command recipes verbatim (they are illustrative, not runtime +// instructions). Matched by the first path segment under the scanned skills +// dir. skills/README.md is intentionally NOT allowlisted — real skill prose +// must ship its own resources via ${CLAUDE_SKILL_DIR}/${CLAUDE_PLUGIN_ROOT}. +const RESOURCE_ALLOWLIST_SEGMENTS = new Set(['genie-hacks']); + +export function isResourceAllowlisted(file: string, skillsDir: string = SKILLS_DIR): boolean { + const rel = relative(skillsDir, file); + const first = rel.split(sep)[0]; + return RESOURCE_ALLOWLIST_SEGMENTS.has(first); +} function collectSubcommands(helpText: string): Set { const cmds = new Set(); @@ -116,9 +131,84 @@ function extractInvocations(fence: string, tool: 'genie' | 'omni'): string[] { return hits; } +/** Extract inline-code spans (single-line backtick spans) from markdown. */ +export function extractInlineCodeSpans(text: string): string[] { + const spans: string[] = []; + const re = /`([^`\n]+)`/g; + let m: RegExpExecArray | null = re.exec(text); + while (m !== null) { + spans.push(m[1]); + m = re.exec(text); + } + return spans; +} + +export type ResourceRule = 'cp-repo-template' | 'unguarded-repo-lint' | 'repo-script-invocation'; + +export interface ResourceViolation { + rule: ResourceRule; + snippet: string; +} + +/** + * Inspect a single line of command context (a fence line or an inline-code + * span) for imperative resource-shipping violations. Skill-shipped files MUST + * be addressed via ${CLAUDE_SKILL_DIR}/${CLAUDE_PLUGIN_ROOT}; repo-only + * commands MUST be guarded by a same-line package.json existence probe. Bare + * descriptive path mentions in prose never reach here (only code context does) + * and never match — every rule keys on an imperative verb. + */ +export function checkResourceLine(line: string): ResourceViolation[] { + const violations: ResourceViolation[] = []; + const snippet = line.trim(); + + // (a) Imperative repo-root template copy: `cp templates/...`. The shipped + // form is `cp "${CLAUDE_SKILL_DIR}/templates/..."`, whose source token is + // NOT a bare `templates/`, so it is not matched. + if (/\bcp\b(?:\s+-\S+)*\s+["']?(?:\.\/)?templates\//.test(line)) { + violations.push({ rule: 'cp-repo-template', snippet }); + } + + // (b) Repo-only lint invocation without the SAME-LINE package.json guard. + // A split-line guard (probe on the previous line) does not count — the probe + // must sit on the same line as the command it protects. + if (/\bbun run (?:wishes|skills):lint\b/.test(line) && !line.includes('package.json')) { + violations.push({ rule: 'unguarded-repo-lint', snippet }); + } + + // (c) Imperative execution of a repo-root script — scripts/*.ts is repo-only. + // Runtime instructions must address skill-shipped scripts via + // ${CLAUDE_SKILL_DIR}. A descriptive `scripts/foo.ts` mention (no run verb) + // does not match. + if (/(?:\bbun run |\bbun |\bnode |\.\/|\bsh |\bbash )scripts\/[A-Za-z0-9_./-]+\.ts\b/.test(line)) { + violations.push({ rule: 'repo-script-invocation', snippet }); + } + + return violations; +} + +/** + * Collect resource-shipping violations across a skill's command surface: + * every bash/sh fence line AND every inline-code span. Prose outside code + * context is never scanned, so descriptive path mentions cannot trip the rule. + */ +export function collectResourceViolations(text: string): ResourceViolation[] { + const lines: string[] = []; + for (const fence of extractBashFences(text)) { + lines.push(...fence.split('\n')); + } + lines.push(...extractInlineCodeSpans(text)); + const violations: ResourceViolation[] = []; + for (const line of lines) { + violations.push(...checkResourceLine(line)); + } + return violations; +} + interface Report { skill: string; missingCommands: Array<{ tool: string; command: string }>; + resourceViolations: ResourceViolation[]; } function main() { @@ -136,7 +226,7 @@ function main() { // is only probed when some scanned skill actually references it; when the // probe fails, getOmniCommands() returns null and omni checks are skipped // (loudly) instead of failing the gate — see its contract comment. - const scanned: Array<{ file: string; genie: string[]; omni: string[] }> = []; + const scanned: Array<{ file: string; genie: string[]; omni: string[]; resource: ResourceViolation[] }> = []; for (const file of files) { const text = readFileSync(file, 'utf8'); if (text.includes('')) continue; @@ -146,14 +236,17 @@ function main() { genie.push(...extractInvocations(fence, 'genie')); omni.push(...extractInvocations(fence, 'omni')); } - scanned.push({ file, genie, omni }); + // Catalog/recipe content (genie-hacks) is allowed to show repo-root + // recipes verbatim; every other skill must ship its own resources. + const resource = isResourceAllowlisted(file) ? [] : collectResourceViolations(text); + scanned.push({ file, genie, omni, resource }); } const omniNeeded = scanned.some((s) => s.omni.length > 0); const omniCmds = omniNeeded ? getOmniCommands() : new Set(); const omniSkipped = omniCmds === null; - for (const { file, genie, omni } of scanned) { + for (const { file, genie, omni, resource } of scanned) { const missing: Report['missingCommands'] = []; for (const cmd of genie) { if (!genieCmds.has(cmd)) missing.push({ tool: 'genie', command: cmd }); @@ -163,18 +256,33 @@ function main() { if (!omniCmds.has(cmd)) missing.push({ tool: 'omni', command: cmd }); } } - reports.push({ skill: relative(ROOT, file), missingCommands: missing }); + reports.push({ skill: relative(ROOT, file), missingCommands: missing, resourceViolations: resource }); } - const failed = reports.filter((r) => r.missingCommands.length > 0); + const missingFailed = reports.filter((r) => r.missingCommands.length > 0); + const resourceFailed = reports.filter((r) => r.resourceViolations.length > 0); console.log(JSON.stringify(reports, null, 2)); - if (failed.length > 0) { - console.error(`\nskills-lint: ${failed.length} skill(s) reference missing commands`); + if (missingFailed.length > 0 || resourceFailed.length > 0) { + if (missingFailed.length > 0) { + console.error(`\nskills-lint: ${missingFailed.length} skill(s) reference missing commands`); + } + if (resourceFailed.length > 0) { + console.error(`\nskills-lint: ${resourceFailed.length} skill(s) reference repo-only resources`); + console.error('skills-lint: skill-shipped paths must use ${CLAUDE_SKILL_DIR}/${CLAUDE_PLUGIN_ROOT}'); + for (const r of resourceFailed) { + for (const v of r.resourceViolations) { + console.error(` ${r.skill}: [${v.rule}] ${v.snippet}`); + } + } + } process.exit(1); } const omniNote = omniSkipped ? ', omni checks skipped' : ''; - console.error(`skills-lint: OK (${reports.length} files scanned, 0 missing${omniNote})`); + console.error(`skills-lint: OK (${reports.length} files scanned, 0 missing, 0 resource violations${omniNote})`); } -main(); +// Only run the linter when executed directly, not when imported by tests. +if (import.meta.main) { + main(); +} From 6a3143b437236c78f93d10291e1447ae64fe1fcd Mon Sep 17 00:00:00 2001 From: namastex888 Date: Fri, 10 Jul 2026 01:55:41 -0300 Subject: [PATCH 4/4] =?UTF-8?q?docs(wishes):=20plugin-resource-shipping=20?= =?UTF-8?q?execution=20review=20=E2=80=94=20SHIP=20after=20branch=20surger?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .genie/wishes/plugin-resource-shipping/WISH.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.genie/wishes/plugin-resource-shipping/WISH.md b/.genie/wishes/plugin-resource-shipping/WISH.md index fdbb7220e..24ebdbe87 100644 --- a/.genie/wishes/plugin-resource-shipping/WISH.md +++ b/.genie/wishes/plugin-resource-shipping/WISH.md @@ -178,7 +178,7 @@ _What must be verified on dev after merge. The QA agent tests each criterion._ **Plan review (2026-07-09): SHIP** after 2 fix loops. R1 FIX-FIRST: CRITICAL — repo-root template deletion would break the required CI e2e (`tests/e2e/v5-lifecycle.sh:156` consumer invisible to the extension-filtered sweep) + 3 MEDIUM (README consumer unplanned; invocation-vs-prose guard scope; G2 scan-surface underspec). R2: residual G1↔G2 self-contradiction → paraphrase rule; sweep hardening. R3: SHIP with one mechanical correction (raw-new-path content exclusion in the G1 sweep — applied to this document at gate close, reviewer-supplied line). **Hermes counter-read: UNAVAILABLE both attempts** (cegonha unreachable) — degradation policy applied, retry at execution review. -_Execution review: populated by `/review` after execution completes._ +**Execution review (final gate fable·high, 2026-07-10): FIX-FIRST → SHIP after branch surgery.** All three groups landed with 0 fix loops each (engineers opus·high, reviewers opus·xhigh — first wish executed under the routing matrix); full repo gate exits 0 (773 pass / 1 skip / 0 fail) and all 5 Success Criteria proven with fresh gate-produced evidence: template ships in-skill with routing-matrix columns and the repo-root copy is gone; no `cp templates/` or unguarded wish-linter invocations in skill files; skills:lint enforces the resource rule (15/15 fixture tests incl. the must-fail case); fresh-install smoke resolves both `${CLAUDE_SKILL_DIR}` refs and scaffolds a wish in a bare repo with no genie CLI; CI runs the smoke and the release-lag note is live. The gate's only HIGH/MEDIUM findings were topology, not code: a concurrent session had switched the shared checkout to `wish/agent-sync`, so the group commits landed there and the template rename rode a foreign docs commit. **Surgery (orchestrator, evidenced inline):** branch rebuilt off origin/dev in an isolated worktree — G1 recreated with the R100 rename folded in (`ecbb67fc`: rename + exactly the 4 reference repoints), G3/G2 cherry-picked clean (`203c97df`, `bbd6439e`); tree vs the gate-validated tip differs only by the dropped foreign `.genie/agent-sync` docs and the newer dev version strings. QA watch items (LOW, follow-up): `fresh-install-smoke.ts` temp-dir cleanup is bypassed by `process.exit` on phase-b failures; G1's validation sweep is not replay-safe post-G2 (trips on the lint rule's own negative fixtures); G2's same-line guard discriminator is substring-based ("package.json" mention passes). Live installed-plugin scaffold QA (wish QA criterion 1) pending the next plugin release. **Hermes counter-read: cegonha still unreachable — fail-open applied (third consecutive gate), logged.** ---