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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .genie/wishes/plugin-resource-shipping/WISH.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**

---

Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions plugins/genie/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions scripts/fresh-install-smoke.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
154 changes: 154 additions & 0 deletions scripts/fresh-install-smoke.ts
Original file line number Diff line number Diff line change
@@ -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}/<path>` 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 <path>]
*/

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);
}
Comment on lines +29 to +32

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Calling process.exit(1) inside fail immediately terminates the process, which bypasses the finally block in runWishScaffoldSmoke and leaves temporary directories on disk. Changing fail to throw an error allows the finally block to execute and clean up the temporary directories before the process exits.

function fail(message: string): never {
  throw new Error(message);
}


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}/<path>` 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})`);
}
Comment on lines +145 to +152

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Wrap the main execution in a try-catch block to catch the error thrown by fail, print the failure message, and exit with code 1. This ensures that any errors thrown during the smoke test are gracefully handled and the process exits with a non-zero code after cleaning up temporary directories.

Suggested change
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})`);
}
function main(): void {
try {
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})`);
} catch (err) {
console.error(`fresh-install-smoke: FAIL — ${(err as Error).message}`);
process.exit(1);
}
}


main();
Loading