loop-engineering-harness 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +266 -0
  2. package/bin/cli.js +166 -0
  3. package/docs/langfuse-dashboards.md +39 -0
  4. package/package.json +34 -0
  5. package/src/detect.js +79 -0
  6. package/src/install.js +58 -0
  7. package/src/loop.js +181 -0
  8. package/src/merge.js +173 -0
  9. package/src/metrics.js +70 -0
  10. package/src/uninstall.js +63 -0
  11. package/template/.claude/agents/context-researcher.md +47 -0
  12. package/template/.claude/agents/planner.md +71 -0
  13. package/template/.claude/agents/reviewer.md +64 -0
  14. package/template/.claude/agents/specialist-backend.md +34 -0
  15. package/template/.claude/agents/specialist-database.md +32 -0
  16. package/template/.claude/agents/specialist-frontend.md +35 -0
  17. package/template/.claude/commands/feature.md +147 -0
  18. package/template/.claude/hooks/artifact-size.py +71 -0
  19. package/template/.claude/hooks/commit-gate.py +85 -0
  20. package/template/.claude/hooks/dispatch-gate.py +193 -0
  21. package/template/.claude/hooks/divergence-monitor.py +98 -0
  22. package/template/.claude/hooks/harness_lib.py +260 -0
  23. package/template/.claude/hooks/loop_lib.py +108 -0
  24. package/template/.claude/hooks/precompact-guard.py +91 -0
  25. package/template/.claude/hooks/req_lib.py +148 -0
  26. package/template/.claude/hooks/requirements-gate.py +62 -0
  27. package/template/.claude/hooks/retrieval-nudge.py +160 -0
  28. package/template/.claude/hooks/session-rehydrate.py +65 -0
  29. package/template/.claude/hooks/state-updater.py +119 -0
  30. package/template/.claude/hooks/trace-emitter.py +128 -0
  31. package/template/.claude/hooks/version-gate.py +86 -0
  32. package/template/.claude/hooks/version-tracker.py +67 -0
  33. package/template/.claude/hooks/version_lib.py +243 -0
  34. package/template/.claude/scripts/approve-plan.py +91 -0
  35. package/template/.claude/scripts/changeset.py +18 -0
  36. package/template/.claude/scripts/extract-deps.py +288 -0
  37. package/template/.claude/scripts/version.py +144 -0
  38. package/template/.claude/skills/contract-writing/SKILL.md +50 -0
  39. package/template/.claude/skills/discovery-interview/SKILL.md +56 -0
  40. package/template/.claude/skills/review-taxonomy/SKILL.md +51 -0
  41. package/template/CLAUDE.harness.md +68 -0
  42. package/template/harness.config.json +58 -0
  43. package/template/settings.harness.json +87 -0
package/src/loop.js ADDED
@@ -0,0 +1,181 @@
1
+ 'use strict';
2
+ // L4 — the outer driver (DevelopmentUpdates.md §4).
3
+ //
4
+ // Pulls features off a queue and runs each in a FRESH headless session
5
+ // (`claude -p`), one feature = one session (Rule 4). It never revives a context:
6
+ // resuming a parked feature is a brand-new session rehydrated from specs/ by the
7
+ // session-rehydrate hook — recover-from-compaction and resume-from-parking are
8
+ // deliberately the same mechanism.
9
+ //
10
+ // Exactly three terminal states are read back from specs/<f>/state.json:
11
+ // committed -> feature done, move on
12
+ // escalated -> breaker tripped / task escalated: PARK (a question for the
13
+ // human) and keep the queue moving — escalations do not stall it
14
+ // needs_approval -> plan drafted but not signed: PARK for the human gate
15
+ //
16
+ // The `runner` is injectable so the queue logic is unit-testable without
17
+ // spawning a model. The default runner shells out to `claude -p`.
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const { spawnSync } = require('child_process');
22
+
23
+ function readJSON(p, dflt) {
24
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (_) { return dflt; }
25
+ }
26
+ function writeJSON(p, obj) {
27
+ fs.mkdirSync(path.dirname(p), { recursive: true });
28
+ fs.writeFileSync(p, JSON.stringify(obj, null, 2) + '\n');
29
+ }
30
+ function slug(s) {
31
+ return String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'feature';
32
+ }
33
+
34
+ function queuePath(root) { return path.join(root, 'specs', '_queue.json'); }
35
+ function answersPath(root) { return path.join(root, 'specs', '_answers.json'); }
36
+
37
+ function loadQueue(root) {
38
+ return readJSON(queuePath(root), { features: [] });
39
+ }
40
+ function addToQueue(root, description, opts) {
41
+ opts = opts || {};
42
+ const q = loadQueue(root);
43
+ const id = opts.id || slug(description);
44
+ if (q.features.some((f) => f.id === id)) throw new Error(`feature '${id}' already queued`);
45
+ q.features.push({
46
+ id, description, status: 'pending',
47
+ auto_approve: !!opts.autoApprove, queuedAt: opts.now || null,
48
+ });
49
+ writeJSON(queuePath(root), q);
50
+ return id;
51
+ }
52
+
53
+ // ---- terminal-state detection (reads only disk) --------------------------
54
+ function classify(root, id) {
55
+ const spec = path.join(root, 'specs', id);
56
+ const state = readJSON(path.join(spec, 'state.json'), null);
57
+ const plan = fs.existsSync(path.join(spec, 'plan.md'));
58
+ const approved = fs.existsSync(path.join(spec, 'approvals.json'));
59
+
60
+ if (state && state.phase === 'committed') return { terminal: 'committed' };
61
+ if (state && (state.health === 'tripped' ||
62
+ Object.values(state.tasks || {}).some((t) => t.status === 'escalate'))) {
63
+ return { terminal: 'escalated', reasons: state.health_reasons || ['task escalated'] };
64
+ }
65
+ if (plan && !approved) return { terminal: 'needs_approval', reasons: ['awaiting human plan approval'] };
66
+ return { terminal: 'incomplete', reasons: ['session ended without a terminal state'] };
67
+ }
68
+
69
+ function park(root, feature, cls) {
70
+ const ans = readJSON(answersPath(root), { parked: [] });
71
+ ans.parked = ans.parked.filter((p) => p.id !== feature.id);
72
+ ans.parked.push({
73
+ id: feature.id, terminal: cls.terminal,
74
+ reasons: cls.reasons || [], description: feature.description,
75
+ question: `Resolve '${feature.id}' (${cls.terminal}): ${(cls.reasons || []).join('; ')}. ` +
76
+ `Append the answer to specs/${feature.id}/discovery.md, then re-queue.`,
77
+ });
78
+ writeJSON(answersPath(root), ans);
79
+ }
80
+
81
+ // ---- token burndown producer (L3) ----------------------------------------
82
+ // The driver is the one place with a real token count (from `claude -p` usage),
83
+ // so it is where burndown is fed. recompute_health reads divergence.json.tokens_spent.
84
+ function recordTokens(root, id, usage) {
85
+ if (!usage) return;
86
+ const spec = path.join(root, 'specs', id);
87
+ const dp = path.join(spec, 'divergence.json');
88
+ const div = readJSON(dp, {});
89
+ const spent = (usage.input_tokens || 0) + (usage.output_tokens || 0);
90
+ div.tokens_spent = (div.tokens_spent || 0) + spent;
91
+ writeJSON(dp, div);
92
+ return div.tokens_spent;
93
+ }
94
+
95
+ // ---- the default (real) runner: a fresh `claude -p` session --------------
96
+ function claudeRunner(feature, ctx) {
97
+ const resume = fs.existsSync(path.join(ctx.root, 'specs', feature.id, 'plan.md'));
98
+ const prompt = resume
99
+ ? `Resume feature '${feature.id}'. A session is being rehydrated from ` +
100
+ `specs/${feature.id}/. Read state.json, then continue the pipeline per ` +
101
+ `task-graph.json to a terminal state.`
102
+ : `/feature ${feature.description}`;
103
+ const auto = ctx.autoApprove || feature.auto_approve;
104
+ const bin = ctx.claudeBin || 'claude';
105
+ const args = ['-p', prompt, '--output-format', 'json'];
106
+ if (ctx.permissionMode) args.push('--permission-mode', ctx.permissionMode);
107
+ const r = spawnSync(bin, args, {
108
+ cwd: ctx.root, encoding: 'utf8', timeout: ctx.timeoutMs || 1800000,
109
+ env: Object.assign({}, process.env, auto ? { HARNESS_AUTO_APPROVE: '1' } : {}),
110
+ });
111
+ if (r.error) throw new Error(`failed to spawn '${bin}': ${r.error.message}. Is Claude Code installed and on PATH?`);
112
+ let usage = null;
113
+ try { usage = (JSON.parse(r.stdout) || {}).usage || null; } catch (_) { /* non-JSON tail */ }
114
+ return { usage, raw: r.stdout };
115
+ }
116
+
117
+ // ---- the loop -------------------------------------------------------------
118
+ async function processQueue(root, opts) {
119
+ opts = opts || {};
120
+ root = path.resolve(root);
121
+ const runner = opts.runner || claudeRunner;
122
+ const ctx = {
123
+ root, autoApprove: !!opts.autoApprove, claudeBin: opts.claudeBin,
124
+ permissionMode: opts.permissionMode, timeoutMs: opts.timeoutMs,
125
+ };
126
+ const summary = { committed: [], escalated: [], needs_approval: [], incomplete: [] };
127
+
128
+ while (true) {
129
+ const q = loadQueue(root);
130
+ const feature = q.features.find((f) => f.status === 'pending');
131
+ if (!feature) break;
132
+
133
+ if (opts.log) opts.log(`▶ ${feature.id}: starting fresh session`);
134
+ let usage = null;
135
+ try {
136
+ const res = await runner(feature, ctx);
137
+ usage = res && res.usage;
138
+ } catch (err) {
139
+ feature.status = 'parked';
140
+ feature.error = err.message;
141
+ writeJSON(queuePath(root), q);
142
+ park(root, feature, { terminal: 'incomplete', reasons: [err.message] });
143
+ summary.incomplete.push(feature.id);
144
+ if (opts.once) break; else continue;
145
+ }
146
+ const total = recordTokens(root, feature.id, usage);
147
+
148
+ const cls = classify(root, feature.id);
149
+ if (cls.terminal === 'committed') {
150
+ feature.status = 'done';
151
+ summary.committed.push(feature.id);
152
+ } else {
153
+ feature.status = 'parked';
154
+ park(root, feature, cls);
155
+ summary[cls.terminal].push(feature.id);
156
+ }
157
+ feature.lastTokens = total || undefined;
158
+ writeJSON(queuePath(root), q); // persist after each feature (crash-safe)
159
+ if (opts.log) opts.log(` ${feature.id} -> ${cls.terminal}${total ? ` (${total} tok)` : ''}`);
160
+ if (opts.once) break;
161
+ }
162
+ return summary;
163
+ }
164
+
165
+ // Re-queue a parked feature (after a human appended the answer to discovery.md).
166
+ function requeue(root, id, now) {
167
+ const q = loadQueue(root);
168
+ const f = q.features.find((x) => x.id === id);
169
+ if (!f) throw new Error(`no queued feature '${id}'`);
170
+ f.status = 'pending';
171
+ f.requeuedAt = now || null;
172
+ writeJSON(queuePath(root), q);
173
+ const ans = readJSON(answersPath(root), { parked: [] });
174
+ ans.parked = ans.parked.filter((p) => p.id !== id);
175
+ writeJSON(answersPath(root), ans);
176
+ }
177
+
178
+ module.exports = {
179
+ processQueue, addToQueue, loadQueue, requeue, classify, claudeRunner,
180
+ queuePath, answersPath, slug,
181
+ };
package/src/merge.js ADDED
@@ -0,0 +1,173 @@
1
+ 'use strict';
2
+ // Low-level merge primitives shared by install/uninstall/detect.
3
+ // Zero runtime dependencies — Node stdlib only.
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { execSync } = require('child_process');
8
+
9
+ const BEGIN = '<!-- harness:begin v1.0 -->';
10
+ const END = '<!-- harness:end -->';
11
+
12
+ function read(p) {
13
+ try { return fs.readFileSync(p, 'utf8'); } catch (_) { return null; }
14
+ }
15
+ function readJSON(p) {
16
+ const raw = read(p);
17
+ if (raw === null) return undefined;
18
+ return JSON.parse(raw); // caller decides how to handle a throw (we abort)
19
+ }
20
+ function writeJSON(p, obj) {
21
+ fs.mkdirSync(path.dirname(p), { recursive: true });
22
+ fs.writeFileSync(p, JSON.stringify(obj, null, 2) + '\n');
23
+ }
24
+
25
+ // Detect a Python interpreter for the hook commands. Preference order lets the
26
+ // same payload work on Windows (python / py) and unix (python3).
27
+ function detectPython(explicit) {
28
+ const candidates = explicit ? [explicit] : ['python3', 'python', 'py -3'];
29
+ for (const c of candidates) {
30
+ try {
31
+ execSync(`${c} --version`, { stdio: 'ignore' });
32
+ return c;
33
+ } catch (_) { /* try next */ }
34
+ }
35
+ // Fall back to python3; the hooks fail-open, and the adopter can edit config.
36
+ return 'python3';
37
+ }
38
+
39
+ // ---- CLAUDE.md ------------------------------------------------------------
40
+ // Append a delimited block; upgrades replace only the block; never touch the
41
+ // adopter's own content.
42
+ function mergeClaudeMd(targetPath, blockWithMarkers) {
43
+ const existing = read(targetPath);
44
+ if (existing === null) {
45
+ fs.writeFileSync(targetPath, blockWithMarkers.trimEnd() + '\n');
46
+ return { action: 'created' };
47
+ }
48
+ const b = existing.indexOf(BEGIN);
49
+ const e = existing.indexOf(END);
50
+ if (b !== -1 && e !== -1 && e > b) {
51
+ const before = existing.slice(0, b);
52
+ const after = existing.slice(e + END.length);
53
+ const next = before + blockWithMarkers.trim() + after;
54
+ fs.writeFileSync(targetPath, next);
55
+ return { action: 'upgraded' };
56
+ }
57
+ const sep = existing.endsWith('\n') ? '\n' : '\n\n';
58
+ fs.writeFileSync(targetPath, existing + sep + blockWithMarkers.trimEnd() + '\n');
59
+ return { action: 'appended' };
60
+ }
61
+
62
+ function stripClaudeMd(targetPath) {
63
+ const existing = read(targetPath);
64
+ if (existing === null) return { action: 'absent' };
65
+ const b = existing.indexOf(BEGIN);
66
+ const e = existing.indexOf(END);
67
+ if (b === -1 || e === -1 || e < b) return { action: 'no-block' };
68
+ let before = existing.slice(0, b);
69
+ let after = existing.slice(e + END.length);
70
+ let next = (before.replace(/\s+$/, '') + '\n' + after.replace(/^\s+/, '')).trim();
71
+ // If nothing but the block remained, remove the file entirely (byte-identity).
72
+ if (next === '') { fs.rmSync(targetPath, { force: true }); return { action: 'removed-file' }; }
73
+ fs.writeFileSync(targetPath, next + '\n');
74
+ return { action: 'stripped' };
75
+ }
76
+
77
+ // ---- settings.json --------------------------------------------------------
78
+ // Deep-merge hook arrays. Existing entries are preserved and never reordered;
79
+ // harness entries are appended, each already carrying "_harness": true.
80
+ // A parse failure ABORTS — we never write a best-guess settings file.
81
+ function mergeSettings(targetPath, payloadHooks, python) {
82
+ let settings = {};
83
+ const raw = read(targetPath);
84
+ if (raw !== null && raw.trim() !== '') {
85
+ try { settings = JSON.parse(raw); }
86
+ catch (err) {
87
+ throw new Error(
88
+ `Refusing to merge: ${targetPath} is not valid JSON (${err.message}). ` +
89
+ `Fix or remove it, then re-run.`);
90
+ }
91
+ }
92
+ settings.hooks = settings.hooks || {};
93
+ const appended = [];
94
+ for (const event of Object.keys(payloadHooks)) {
95
+ settings.hooks[event] = settings.hooks[event] || [];
96
+ for (const group of payloadHooks[event]) {
97
+ const g = JSON.parse(JSON.stringify(group));
98
+ for (const h of g.hooks) {
99
+ if (typeof h.command === 'string') h.command = h.command.replace(/__PYTHON__/g, python);
100
+ }
101
+ settings.hooks[event].push(g);
102
+ appended.push(event);
103
+ }
104
+ }
105
+ writeJSON(targetPath, settings);
106
+ return { appended };
107
+ }
108
+
109
+ // Remove every hook entry marked _harness; drop empty event arrays; if the whole
110
+ // file reduces to {} remove it so the tree is byte-identical to pre-install.
111
+ function stripSettings(targetPath) {
112
+ const raw = read(targetPath);
113
+ if (raw === null) return { action: 'absent' };
114
+ let settings;
115
+ try { settings = JSON.parse(raw); } catch (_) { return { action: 'unparseable-skip' }; }
116
+ if (!settings.hooks) return { action: 'no-hooks' };
117
+ for (const event of Object.keys(settings.hooks)) {
118
+ const groups = settings.hooks[event];
119
+ if (!Array.isArray(groups)) continue;
120
+ const kept = [];
121
+ for (const group of groups) {
122
+ const hooks = (group.hooks || []).filter((h) => h._harness !== true &&
123
+ !(typeof h.command === 'string' && /\.claude[\\/]hooks[\\/]/.test(h.command) &&
124
+ /(retrieval-nudge|commit-gate|dispatch-gate|requirements-gate|state-updater|trace-emitter|divergence-monitor|precompact-guard|session-rehydrate|artifact-size|version-tracker|version-gate)\.py/.test(h.command)));
125
+ if (hooks.length) kept.push(Object.assign({}, group, { hooks }));
126
+ }
127
+ if (kept.length) settings.hooks[event] = kept;
128
+ else delete settings.hooks[event];
129
+ }
130
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
131
+ if (Object.keys(settings).length === 0) {
132
+ fs.rmSync(targetPath, { force: true });
133
+ return { action: 'removed-file' };
134
+ }
135
+ writeJSON(targetPath, settings);
136
+ return { action: 'stripped' };
137
+ }
138
+
139
+ // ---- collision-safe file copy --------------------------------------------
140
+ // Returns { dest, status: 'added'|'identical'|'renamed', renamedTo? }
141
+ function copyFileSafe(src, dest) {
142
+ const content = fs.readFileSync(src);
143
+ if (fs.existsSync(dest)) {
144
+ const cur = fs.readFileSync(dest);
145
+ if (cur.equals(content)) return { dest, status: 'identical' };
146
+ const ext = path.extname(dest);
147
+ const renamed = dest.slice(0, -ext.length || undefined) + '.harness' + ext;
148
+ fs.mkdirSync(path.dirname(renamed), { recursive: true });
149
+ fs.writeFileSync(renamed, content);
150
+ return { dest, status: 'renamed', renamedTo: renamed };
151
+ }
152
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
153
+ fs.writeFileSync(dest, content);
154
+ return { dest, status: 'added' };
155
+ }
156
+
157
+ const SKIP = new Set(['__pycache__', '.pytest_cache', '.DS_Store']);
158
+ function walk(dir) {
159
+ const out = [];
160
+ for (const name of fs.readdirSync(dir)) {
161
+ if (SKIP.has(name) || name.endsWith('.pyc')) continue; // never ship build caches
162
+ const p = path.join(dir, name);
163
+ if (fs.statSync(p).isDirectory()) out.push(...walk(p));
164
+ else out.push(p);
165
+ }
166
+ return out;
167
+ }
168
+
169
+ module.exports = {
170
+ BEGIN, END, read, readJSON, writeJSON, detectPython,
171
+ mergeClaudeMd, stripClaudeMd, mergeSettings, stripSettings,
172
+ copyFileSafe, walk,
173
+ };
package/src/metrics.js ADDED
@@ -0,0 +1,70 @@
1
+ 'use strict';
2
+ // L5 — flywheel metrics (DevelopmentUpdates.md §5-6, Rule 5).
3
+ //
4
+ // The derived metric that matters is **tokens-per-passing-task**: when it creeps
5
+ // up, a compression stage is leaking. This computes it per feature from disk
6
+ // (state.json + divergence.json + approvals.json) so it works with or without
7
+ // Langfuse. Langfuse dashboards (docs/langfuse-dashboards.md) chart the same
8
+ // numbers over time; this is the local, always-available view.
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ function readJSON(p, dflt) {
14
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (_) { return dflt; }
15
+ }
16
+
17
+ function computeMetrics(root) {
18
+ root = path.resolve(root);
19
+ const specs = path.join(root, 'specs');
20
+ const out = [];
21
+ if (!fs.existsSync(specs)) return out;
22
+
23
+ for (const name of fs.readdirSync(specs).sort()) {
24
+ if (name.startsWith('_')) continue; // queue/answer files
25
+ const dir = path.join(specs, name);
26
+ if (!fs.statSync(dir).isDirectory()) continue;
27
+
28
+ const state = readJSON(path.join(dir, 'state.json'), { tasks: {} });
29
+ const div = readJSON(path.join(dir, 'divergence.json'), {});
30
+ const approvals = readJSON(path.join(dir, 'approvals.json'), {});
31
+
32
+ const tasks = Object.values(state.tasks || {});
33
+ const passing = tasks.filter((t) => t.status === 'done').length;
34
+ const spent = div.tokens_spent || 0;
35
+ const budget = approvals.budget || 0;
36
+
37
+ out.push({
38
+ feature: name,
39
+ phase: state.phase || 'unknown',
40
+ health: state.health || 'ok',
41
+ tasks_total: tasks.length,
42
+ tasks_passing: passing,
43
+ tokens_spent: spent,
44
+ budget: budget || null,
45
+ burndown_pct: budget ? Math.round((100 * spent) / budget) : null,
46
+ tokens_per_passing_task: passing ? Math.round(spent / passing) : null,
47
+ footprint_violations: Object.values(div.footprint_violations || {}).reduce((a, b) => a + b, 0),
48
+ });
49
+ }
50
+ return out;
51
+ }
52
+
53
+ function formatMetrics(rows) {
54
+ if (!rows.length) return 'No features found under specs/.';
55
+ const L = ['Loop Engineering — flywheel metrics', ''];
56
+ for (const r of rows) {
57
+ L.push(`• ${r.feature} [${r.phase}${r.health === 'tripped' ? ', BREAKER TRIPPED' : ''}]`);
58
+ L.push(` tasks: ${r.tasks_passing}/${r.tasks_total} passing` +
59
+ (r.footprint_violations ? ` footprint-violations: ${r.footprint_violations}` : ''));
60
+ L.push(` tokens: ${r.tokens_spent}` +
61
+ (r.budget ? ` / ${r.budget} budget (${r.burndown_pct}% burndown)` : ' (no budget set)'));
62
+ if (r.tokens_per_passing_task != null) {
63
+ L.push(` tokens-per-passing-task: ${r.tokens_per_passing_task} ← leak detector`);
64
+ }
65
+ L.push('');
66
+ }
67
+ return L.join('\n');
68
+ }
69
+
70
+ module.exports = { computeMetrics, formatMetrics };
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+ // Reverses an install. With a manifest it is exact (byte-identical repo, minus
3
+ // specs/). Without one it falls back to a conservative heuristic. specs/ is the
4
+ // adopter's work product and is ALWAYS left in place.
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const m = require('./merge');
9
+ const { MANIFEST_REL } = require('./install');
10
+
11
+ function tryRmEmptyDirsUp(startDir, stopDir) {
12
+ let dir = startDir;
13
+ while (dir.startsWith(stopDir) && dir !== stopDir) {
14
+ try { fs.rmdirSync(dir); } catch (_) { break; } // non-empty -> stop
15
+ dir = path.dirname(dir);
16
+ }
17
+ }
18
+
19
+ function uninstall(target) {
20
+ target = path.resolve(target);
21
+ const manifestPath = path.join(target, MANIFEST_REL);
22
+ const manifest = m.readJSON(manifestPath);
23
+ const removed = [];
24
+
25
+ // settings + CLAUDE.md first (independent of manifest presence)
26
+ const setRes = m.stripSettings(path.join(target, '.claude', 'settings.json'));
27
+ const claudeRes = m.stripClaudeMd(path.join(target, 'CLAUDE.md'));
28
+
29
+ const toDelete = [];
30
+ if (manifest) {
31
+ for (const rel of manifest.filesAdded.concat(manifest.filesRenamed)) {
32
+ if (rel === 'harness.config.json' && !manifest.configWritten) continue;
33
+ toDelete.push(rel);
34
+ }
35
+ } else {
36
+ // Heuristic: delete the files this template is known to ship.
37
+ const { planClaudeFiles } = require('./detect');
38
+ for (const f of planClaudeFiles(target)) {
39
+ toDelete.push(path.relative(target, f.dest).replace(/\\/g, '/'));
40
+ }
41
+ }
42
+
43
+ for (const rel of toDelete) {
44
+ const abs = path.join(target, rel);
45
+ if (fs.existsSync(abs)) {
46
+ fs.rmSync(abs, { force: true });
47
+ removed.push(rel);
48
+ tryRmEmptyDirsUp(path.dirname(abs), target);
49
+ }
50
+ }
51
+
52
+ // remove the harness state dir (markers, manifest) and clean empties
53
+ fs.rmSync(path.join(target, '.claude', '.harness'), { recursive: true, force: true });
54
+ tryRmEmptyDirsUp(path.join(target, '.claude', 'agents'), target);
55
+ for (const d of ['agents', 'skills', 'commands', 'hooks', 'scripts']) {
56
+ tryRmEmptyDirsUp(path.join(target, '.claude', d), target);
57
+ }
58
+ tryRmEmptyDirsUp(path.join(target, '.claude'), path.dirname(target));
59
+
60
+ return { removed, settings: setRes.action, claudeMd: claudeRes.action, usedManifest: !!manifest };
61
+ }
62
+
63
+ module.exports = { uninstall };
@@ -0,0 +1,47 @@
1
+ ---
2
+ name: context-researcher
3
+ description: Expands graphify queries and targeted source reading into a compact codebase brief. Use after discovery.md exists and before planning. Burns tokens freely in its own window; returns only context-pack.md.
4
+ tools: Read, Grep, Glob, Bash
5
+ ---
6
+
7
+ You are the **context-researcher**. You run in an isolated window: you may read
8
+ 40k+ tokens of graph output and source, but the orchestrator will only ever see
9
+ the one file you write. Spend context lavishly here so the orchestrator doesn't
10
+ have to.
11
+
12
+ ## Retrieval order (mandatory): graphify → skills → memory
13
+ 1. **graphify first.** Start every investigation with the knowledge graph, not a
14
+ grep. If `graphify-out/graph.json` exists, run `graphify query "<question>"`
15
+ for each subsystem the feature touches. If it does **not** exist, the repo is
16
+ not initialized — stop and tell the orchestrator to run `/graphify .` (it
17
+ self-installs and builds the graph); do not fall back to blind scanning.
18
+ 2. **Then** read the specific files the graph surfaced as god nodes, shared
19
+ dependents, or seam points.
20
+ 3. Broad `Grep`/`Glob` is a last resort and is nudged against by a hook — if you
21
+ reach for it, you probably skipped step 1.
22
+
23
+ ## Reads
24
+ - `specs/<feature>/discovery.md` (your assignment)
25
+ - `graphify query` output; source files it points to
26
+
27
+ ## Writes — exactly one file
28
+ - `specs/<feature>/context-pack.md`, **≤ ~2000 tokens**. Start with these
29
+ feature-wide sections:
30
+ - **What exists** — the modules/entities relevant to this feature
31
+ - **Dependency map** — what depends on what (name the shared dependents; these
32
+ become sequential edges later)
33
+ - **Conventions** — house patterns to match (error handling, naming, test style)
34
+ - **Risk notes** — shared code, cross-cutting callers, anything that will bite
35
+ a specialist (e.g. "refund logic is also called from the admin panel")
36
+ - **Then, per-area sections keyed to the anticipated work areas (Context Rule 2).**
37
+ Structure the pack so each future specialist can be handed ONLY its slice — a
38
+ `## Area: database`, `## Area: backend`, `## Area: frontend` (name the areas the
39
+ feature actually spans). The orchestrator lifts one area into each task-scoped
40
+ dispatch prompt; feature-wide context inside a single task's window is itself a
41
+ distractor. If you cannot cleanly separate an area, say so in Risk notes rather
42
+ than dumping everything into one blob.
43
+
44
+ ## Must not
45
+ - Do not write code, plans, or contracts. Do not edit anything but your one file.
46
+ - Do not use Bash for anything other than `graphify` and read-only inspection.
47
+ - Do not exceed the token budget — a bloated pack defeats the whole design.
@@ -0,0 +1,71 @@
1
+ ---
2
+ name: planner
3
+ description: Decomposes a feature into tasks with explicit file footprints and writes interface contracts BEFORE any implementation. Its final action always invokes extract-deps.py. Use after context-pack.md exists.
4
+ tools: Read, Write, Grep, Bash
5
+ ---
6
+
7
+ You are the **planner**. You turn discovery + context into a decomposition and,
8
+ critically, the **contracts** that let specialists work in parallel without
9
+ colliding. Contract-first planning is the seam-integrity mechanism of the whole
10
+ harness — interfaces are fixed here, before anyone implements.
11
+
12
+ Load the `contract-writing` skill before writing contracts.
13
+
14
+ ## Reads
15
+ - `specs/<feature>/discovery.md`, `specs/<feature>/context-pack.md`
16
+
17
+ ## Writes
18
+ 1. `specs/<feature>/contracts/` — the interface agreements, written FIRST:
19
+ `api.yaml` (endpoint schemas), `migration.md` (DB shape), `components.md`
20
+ (props / client contracts). Whatever the feature needs; name interfaces so
21
+ tasks can reference them.
22
+ 2. `specs/<feature>/plan.md` — one `` ```task `` block per task. Every task
23
+ carries an **explicit file footprint** (the files it is allowed to touch) and,
24
+ where relevant, which contract interfaces it `produces` / `consumes`:
25
+
26
+ ```task
27
+ id: backend-orders-api
28
+ agent: specialist-backend
29
+ footprint:
30
+ - api/orders.ts
31
+ produces: [cancel-api]
32
+ consumes: [migration]
33
+ satisfies: [AC1, AC2]
34
+ est_tokens: 12000
35
+ ```
36
+
37
+ **`satisfies:` is the anti-divergence contract.** Tag every task with the
38
+ acceptance-criteria ids (from `discovery.md`) it delivers. The requirements
39
+ gate refuses to proceed unless **every** AC is covered by some task and **no**
40
+ task cites an AC that isn't in discovery.md. So: cover all of what was asked
41
+ (no gaps) and build only what was asked (no scope creep). If you find you need
42
+ a task with no AC, the requirement is missing — stop and flag it, don't invent
43
+ scope.
44
+
45
+ Give every task an **`est_tokens`** estimate (implementation + review + a
46
+ retry's worth of headroom). These sum into the approval package's budget and
47
+ feed the estimate-vs-actual metric (Rule 5) — the burndown ceiling the circuit
48
+ breaker later enforces is only as honest as these numbers, so estimate
49
+ deliberately, not reflexively.
50
+
51
+ Footprint rules: be precise and minimal. A footprint that is too broad forces
52
+ false sequential edges; one that is too narrow risks a specialist needing a
53
+ file it can't touch (which it must then report, not grab). Prefer specific
54
+ files over directories unless the task genuinely owns the whole directory.
55
+
56
+ 3. **Declare the release impact.** Put a line `VERSION-IMPACT: major|minor|patch`
57
+ in `plan.md` — the Version Controller uses it to compute the semver bump.
58
+ Breaking API/contract changes → `major`; new capability → `minor`; fix-only →
59
+ `patch`. This is part of what the human signs at approval.
60
+
61
+ 4. **Last action, always:** invoke the extractor so the schedule matches the plan:
62
+
63
+ ```
64
+ python .claude/scripts/extract-deps.py --plan specs/<feature>/plan.md --out specs/<feature>/task-graph.json
65
+ ```
66
+
67
+ ## Must not
68
+ - Do not implement anything. Do not assign two tasks overlapping footprints and
69
+ call them parallel — the extractor will serialize them; that's correct, don't
70
+ fight it. Do not finish without running extract-deps.py (a stale or missing
71
+ task-graph.json will block every dispatch).
@@ -0,0 +1,64 @@
1
+ ---
2
+ name: reviewer
3
+ description: Reviews specialist work in two modes — per-task (diff vs plan + contract) and integration (combined diff + contract conformance + graphify pass over touched files). Emits a valid review.json with a failure_class. The native evaluation producer of the flywheel.
4
+ tools: Read, Grep, Glob, Bash
5
+ ---
6
+
7
+ You are the **reviewer**. You are the harness's evaluation producer: your verdict
8
+ is an artifact, and the commit gate is an exit code that reads it. Load the
9
+ `review-taxonomy` skill before judging.
10
+
11
+ ## Two modes
12
+ **Per-task** — review one specialist's diff against `plan.md` (did it stay in
13
+ footprint?) and against `contracts/` (does it match the agreed interface?).
14
+
15
+ **Integration** (after every task is `done`) — review the **combined** feature
16
+ diff. Check exactly three things:
17
+ 1. the combined diff itself,
18
+ 2. `contracts/` conformance across all tasks,
19
+ 3. a `graphify query` over every touched file — did anyone change something that
20
+ other, untouched code depends on? (This is where cross-specialist seam bugs
21
+ surface — a renamed field a parallel task still reads the old way.)
22
+ 4. `specs/<feature>/dependencies.json` — if `has_major` is true, treat the
23
+ dependency bump as a finding to scrutinize (breaking upgrade, security, license).
24
+ Once satisfied it's safe, the human/orchestrator acknowledges it
25
+ (`version.py ack-deps`); an unacknowledged major bump keeps the version gate shut.
26
+
27
+ ## Reads
28
+ - `git diff` (per-task: the task's files; integration: `git diff main...HEAD`)
29
+ - `specs/<feature>/plan.md`, `contracts/`, prior `review.<task>.*.json`
30
+ - `graphify query` on touched files
31
+
32
+ ## Writes — exactly one verdict file per review
33
+ - Per-task: `specs/<feature>/review.<task>.<attempt>.json`
34
+ - Integration: `specs/<feature>/review.integration.<attempt>.json`
35
+
36
+ Schema (must be valid JSON):
37
+ ```json
38
+ {
39
+ "task": "backend-orders-api",
40
+ "attempt": 2,
41
+ "status": "pass | fail",
42
+ "failure_class": "mechanical | contract | ambiguity | security | null",
43
+ "repeat_finding": false,
44
+ "findings": [
45
+ { "id": "F1", "severity": "error", "file": "api/orders.ts", "detail": "..." }
46
+ ],
47
+ "changeset": "<output of: python .claude/scripts/changeset.py>"
48
+ }
49
+ ```
50
+
51
+ ## Rules
52
+ - Classify every failure. The class sets the retry policy the dispatch gate
53
+ enforces — get it right (see the `review-taxonomy` skill for the decision tree).
54
+ - **Diff your own findings across attempts.** If a finding repeats verbatim from
55
+ the previous attempt, set `"repeat_finding": true` — two identical failures
56
+ predict a third; the harness escalates instead of burning another retry.
57
+ - For an integration review you intend to pass, you MUST record `changeset` from
58
+ `python .claude/scripts/changeset.py` — the commit gate only opens for the exact
59
+ diff you reviewed.
60
+ - `security` and `ambiguity` are always `status: "fail"` with zero retries. Never
61
+ soften a security finding to get a run unblocked.
62
+
63
+ ## Must not
64
+ - Do not fix the code. Do not edit `state.json`. You judge; you do not implement.