shapeup-sdlc 1.6.2 → 1.7.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 (85) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/AGENTS.md +36 -103
  3. package/README.md +60 -67
  4. package/SECURITY.md +31 -16
  5. package/bin/init.mjs +24 -69
  6. package/commands/build.md +19 -0
  7. package/commands/eval.md +5 -4
  8. package/commands/scopes.md +5 -4
  9. package/commands/shape.md +1 -1
  10. package/commands/ship.md +37 -4
  11. package/hooks/anti-rationalization.mjs +7 -13
  12. package/hooks/compact-snapshot.mjs +5 -5
  13. package/hooks/gate-deadline.mjs +12 -16
  14. package/hooks/gate-intake.mjs +13 -14
  15. package/hooks/gate-l2.mjs +3 -3
  16. package/hooks/gate-zerowork.mjs +97 -16
  17. package/hooks/lib/decision.mjs +21 -13
  18. package/hooks/safety-spine.mjs +1 -1
  19. package/hooks/sandbox-guard.mjs +83 -47
  20. package/hooks/session-rehydrate.mjs +8 -9
  21. package/hooks/slop-cleaner.mjs +1 -1
  22. package/oracles/_shared.mjs +1 -1
  23. package/oracles/http-oracle.mjs +1 -1
  24. package/oracles/index.mjs +1 -1
  25. package/oracles/process-oracle.mjs +1 -1
  26. package/oracles/snapshot-oracle.mjs +1 -1
  27. package/oracles/test-oracle.mjs +1 -1
  28. package/package.json +3 -7
  29. package/skills/ba-pitch-analyzer/SKILL.md +20 -15
  30. package/skills/ba-pitch-analyzer/references/doc-schemas.md +1 -1
  31. package/skills/ba-pitch-analyzer/references/task-generation.md +6 -6
  32. package/skills/ba-pitch-analyzer/references/test-surface.md +1 -1
  33. package/skills/ba-pitch-analyzer/scripts/board-derive.mjs +5 -5
  34. package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +2 -2
  35. package/skills/coach/SKILL.md +3 -3
  36. package/skills/orient/SKILL.md +2 -1
  37. package/skills/qa-edge-hunter/SKILL.md +9 -39
  38. package/skills/scope-architect/SKILL.md +17 -12
  39. package/skills/scope-hammer/SKILL.md +5 -5
  40. package/skills/shapeup/SKILL.md +3 -3
  41. package/skills/shapeup/resources/context-compaction.md +4 -3
  42. package/skills/solution-architect/SKILL.md +18 -11
  43. package/skills/spec-evaluator/SKILL.md +12 -5
  44. package/skills/spec-evaluator/references/dimensions/_registry.md +2 -2
  45. package/skills/spec-evaluator/references/dimensions/completeness.md +1 -1
  46. package/skills/spec-evaluator/references/dimensions/integration.md +98 -67
  47. package/skills/spec-evaluator/references/dimensions/spec-conformance.md +4 -4
  48. package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +1 -1
  49. package/skills/spec-evaluator/references/probing.md +42 -23
  50. package/skills/spec-evaluator/references/report-schema.md +2 -2
  51. package/skills/spec-evaluator/scripts/verdict-ledger.mjs +1 -1
  52. package/skills/task-executor/SKILL.md +25 -12
  53. package/skills/tech-lead/SKILL.md +115 -438
  54. package/skills/tech-lead/references/delegation.md +38 -31
  55. package/skills/tech-lead/references/gates.md +75 -33
  56. package/skills/tech-lead/references/hard-rules.md +34 -0
  57. package/skills/tech-lead/references/invocation.md +2 -2
  58. package/skills/tech-lead/references/ledger-schema.md +17 -18
  59. package/skills/tech-lead/references/round-protocol.md +77 -27
  60. package/skills/tech-lead/references/state-model.md +1 -1
  61. package/skills/tech-lead/schemas/domain.schema.json +320 -148
  62. package/skills/tech-lead/schemas/gate-answers.schema.json +2 -2
  63. package/skills/tech-lead/schemas/work-result.schema.json +55 -17
  64. package/skills/tech-lead/scripts/aegis-digest.mjs +1 -1
  65. package/skills/tech-lead/scripts/budget-check.mjs +7 -13
  66. package/skills/tech-lead/scripts/compile-order.mjs +69 -22
  67. package/skills/tech-lead/scripts/fit-check.mjs +10 -10
  68. package/skills/tech-lead/scripts/gate-answers.mjs +11 -12
  69. package/skills/tech-lead/scripts/hill-derive.mjs +133 -0
  70. package/skills/tech-lead/scripts/ingest-result.mjs +7 -19
  71. package/skills/tech-lead/scripts/init-run.mjs +60 -18
  72. package/skills/tech-lead/scripts/lib/argv.mjs +1 -1
  73. package/skills/tech-lead/scripts/lib/contract-md.mjs +10 -10
  74. package/skills/tech-lead/scripts/lib/is-main.mjs +11 -12
  75. package/skills/tech-lead/scripts/lib/paths.mjs +15 -13
  76. package/skills/tech-lead/scripts/resume-state.mjs +387 -0
  77. package/skills/tech-lead/scripts/run-snapshot.mjs +1 -6
  78. package/skills/tech-lead/scripts/run-workflow.mjs +381 -0
  79. package/skills/tech-lead/scripts/ship-report.mjs +33 -2
  80. package/skills/tech-lead/scripts/stats.mjs +9 -10
  81. package/skills/tech-lead/scripts/t0-verify.mjs +4 -4
  82. package/skills/tech-lead/scripts/trace-lint.mjs +2 -2
  83. package/skills/tech-lead/workflows/shapeup-run.js +911 -0
  84. package/skills/translator/SKILL.md +2 -2
  85. package/skills/advisor-protocol/SKILL.md +0 -171
@@ -0,0 +1,381 @@
1
+ #!/usr/bin/env node
2
+ // run-workflow — a Bash-invoked control plane for Workflow-format orchestrator scripts.
3
+ //
4
+ // WHY THIS FILE EXISTS. The `Workflow` tool — the only lane for scoped specs — is denied in a
5
+ // headless session with "Review dynamic workflow before running". Left to it, `shapeup-run.js`
6
+ // executes zero times and the agent improvises the feature by hand instead; a session can reach
7
+ // GATE L4 with a valid receipt while the lane never started. Bash HAS a path-scoped grantable
8
+ // prefix, and `npx shapeup-sdlc init` already
9
+ // writes exactly that rule (`Bash(node ${CLAUDE_PLUGIN_ROOT}/skills/<owner>/scripts/:*)` —
10
+ // bin/init.mjs mergePipelinePermissions), so this file runs the SAME Workflow-format script through
11
+ // a surface the install already grants.
12
+ //
13
+ // ⟐ ONE CORRECTION, from probing the permission layer rather than concluding from denials. It is
14
+ // NOT true that no permission string can grant the tool: a bare `"Workflow"` entry in
15
+ // `permissions.allow` grants it, and with that entry removed the same call is denied. So the tool
16
+ // was never ungrantable — the installer simply never wrote the entry, because it writes Bash
17
+ // prefixes only. The defect is real, and it is an INSTALLER defect: the plugin never granted the
18
+ // permission its own lane needs.
19
+ //
20
+ // TWO THINGS SURVIVE THE CORRECTION, and they are the reason this file still exists rather than a
21
+ // one-line change to `init`:
22
+ // 1. THE GRANT CANNOT BE SCOPED. `Workflow(<path>)` and `Workflow(<script>)` are both denied —
23
+ // only the bare token works, which grants EVERY dynamic workflow script in the project,
24
+ // including one a model writes at runtime. A harness whose thesis is "gates the agent cannot
25
+ // talk its way past" should not ask for blanket dynamic-code execution. The Bash prefix is
26
+ // path-scoped to this directory.
27
+ // 2. It costs no new grant at all: existing installs already allow it.
28
+ // The one-line `"Workflow"` grant remains a legitimate alternative for anyone who prefers the
29
+ // native runtime's resume/isolation, and the upgrade notes document it as such. It is a choice
30
+ // with a real trade-off, which is why it is documented rather than silently taken.
31
+ //
32
+ // IT LIVES IN `scripts/` FOR A LOAD-BEARING REASON, not a filing one. The grant `init` already
33
+ // writes is a PREFIX rule over this exact directory, so shipping the launcher here means every
34
+ // install that ever ran `npx shapeup-sdlc init` can already start the lane — zero new permission
35
+ // strings, zero migration for existing users. Putting it anywhere else would require a new grant
36
+ // and reproduce the same denial one directory over. The structural suite asserts that the
37
+ // documented call site is covered by a prefix `bin/init.mjs` actually writes.
38
+ //
39
+ // PROVENANCE: prototyped and proven before it shipped — a headless `acceptEdits` session runs the
40
+ // lane through a granted Bash prefix with zero denials, this loader executes the unmodified
41
+ // `shapeup-run.js`, and a real worker dispatches under `acceptEdits`.
42
+ //
43
+ // WHAT IT PROVIDES to the script — the Workflow runtime surface shapeup-run.js actually uses:
44
+ // args, agent(prompt, {label, phase, schema, model, effort}), parallel(thunks),
45
+ // pipeline(items, ...stages), phase(title), log(msg), budget, workflow() [stub — throws].
46
+ // Scripts keep the Workflow tool's contract: `export const meta = {...}` + a bare top-level body
47
+ // with top-level `await` and `return`. The loader rewrites the one export and wraps the body in an
48
+ // AsyncFunction; nothing about the script format changes, which is the point — shapeup-run.js runs
49
+ // through this file byte-identical to how it ships.
50
+ //
51
+ // HOW agent() DISPATCHES. Each call spawns a fresh headless CLI session:
52
+ // claude -p <prompt> --model <m> --output-format json --permission-mode acceptEdits
53
+ // (detached process group, SIGTERM-then-SIGKILL escalation on timeout, so a hook grandchild can
54
+ // never hold a dead worker's pipe open — without that, a time-capped session can outlive its cap
55
+ // many times over). Workers are
56
+ // stateless, craft-only, pipeline-blind (the envelope port) — fresh processes fit that contract
57
+ // exactly; nothing here shares context between dispatches. `schema` is enforced by instruction +
58
+ // parse + shallow validation + one retry; a worker that still fails returns null, which is the
59
+ // Workflow tool's own documented behavior for a dead subagent and the case every shapeup-run.js
60
+ // call site already survives (mechEnvelope).
61
+ //
62
+ // DIVERGENCES from the Workflow tool, stated rather than silent. None is reached by
63
+ // `shapeup-run.js`, which is why this lane can carry it today; each is a real gap for any other
64
+ // script, and a reader deciding whether to write one should read this list as a limit, not a note:
65
+ // - budget counts USD (summed from each worker envelope's total_cost_usd), not output tokens.
66
+ // Interface is identical: {total, spent(), remaining()}; total comes from --budget-usd.
67
+ // - Date.now()/Math.random() are NOT banned inside scripts. The tool bans them for replay-safe
68
+ // resume; this file journals every dispatch (journal.jsonl) but does not implement
69
+ // resume-from-journal. shapeup-run.js resumes from DISK state by design, not from the
70
+ // journal — which is why the lane's kill/resume story (`kill-resume-probe: PASS`, four
71
+ // assertions on a live SIGKILL) does not depend on this and survives the surface swap.
72
+ // - workflow() (child workflows) throws. shapeup-run.js inlines its round loop and never calls it.
73
+ // - isolation: 'worktree' throws. shapeup-run.js is sequential today (design doc D3).
74
+ // - schema validation is shallow (type + required keys + declared property types, one level).
75
+ //
76
+ // A RUN OUTLIVES A FOREGROUND TOOL CALL. A real pipeline runs for tens of minutes; every
77
+ // foreground Bash call has a ceiling well below that. Launch it as a BACKGROUND Bash call and read
78
+ // `<run-dir>/result.json`, which this file writes on completion with the same `{ok, result}` shape
79
+ // stdout carries. Headless callers must also set `CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0`, or the
80
+ // wait is cut at 600 s and a truncated run is reported as a clean one.
81
+ //
82
+ // STDOUT DISCIPLINE: stdout carries exactly one JSON line — {ok, result} — because whatever
83
+ // launched this process (a mech courier, an outer session, a test) reads stdout as data. All
84
+ // narration goes to stderr.
85
+ //
86
+ // exit 0: script completed; stdout = {"ok":true,"result":...}
87
+ // exit 1: script threw or the launch failed after parsing; stdout = {"ok":false,"error":...}
88
+ // exit 2: argv rejected before anything ran (lib/argv.mjs) — nothing spawned, nothing written.
89
+
90
+ import { spawn } from "node:child_process";
91
+ import { readFileSync, writeFileSync, mkdirSync, appendFileSync } from "node:fs";
92
+ import { resolve, basename } from "node:path";
93
+ import process from "node:process";
94
+ import { runArgs } from "./lib/argv.mjs";
95
+ import { isMain } from "./lib/is-main.mjs";
96
+
97
+ /**
98
+ * The typed argv boundary.
99
+ *
100
+ * FAILING CLOSED IS THE POINT HERE, not hygiene. The cost of a silent launch failure is not that the lane
101
+ * refused to start — it is that the refusal was quiet enough for an agent to route around, so a
102
+ * run that never happened reported like one that did. A launcher that accepts `--max-concurrency`
103
+ * with no value and proceeds on `NaN` is the same shape of defect: this rejects at exit 2 with a
104
+ * machine-readable reason before a single worker is spawned or a run directory created.
105
+ */
106
+ export const ARGV_SPEC = {
107
+ usage: 'run-workflow.mjs <workflow-script.js> [--args <json> | --args-file <path>] ' +
108
+ "[--run-dir <dir>] [--worker-permission-mode <mode>] [--worker-cwd <dir>] " +
109
+ "[--max-concurrency <n>] [--agent-timeout-s <n>] [--budget-usd <n>]",
110
+ _: { arity: 1, name: "workflow-script.js" },
111
+ args: { type: "json" },
112
+ "args-file": { type: "path" },
113
+ "run-dir": { type: "path" },
114
+ "worker-permission-mode": { type: "enum", values: ["acceptEdits", "default", "plan", "bypassPermissions"] },
115
+ "worker-cwd": { type: "path" },
116
+ "max-concurrency": { type: "int", min: 1, max: 32 },
117
+ "agent-timeout-s": { type: "int", min: 1 },
118
+ "budget-usd": { type: "num", min: 0 },
119
+ };
120
+
121
+ /**
122
+ * Resolve parsed argv into the loader's configuration.
123
+ * @param {object} a - The object `runArgs(ARGV_SPEC)` returns.
124
+ * @returns {object} Fully defaulted, absolute-path configuration.
125
+ */
126
+ export function configure(a) {
127
+ const script = resolve(a._[0]);
128
+ return {
129
+ script,
130
+ args: a.argsFile ? JSON.parse(readFileSync(a.argsFile, "utf8")) : (a.args ?? {}),
131
+ runDir: a.runDir
132
+ ? resolve(a.runDir)
133
+ : resolve(`.run-workflow-${basename(script).replace(/\.[^.]+$/, "")}-${process.pid}`),
134
+ workerPermissionMode: a.workerPermissionMode || "acceptEdits",
135
+ workerCwd: a.workerCwd ? resolve(a.workerCwd) : process.cwd(),
136
+ maxConcurrency: a.maxConcurrency ?? 4,
137
+ agentTimeoutS: a.agentTimeoutS ?? 900,
138
+ budgetUsd: a.budgetUsd ?? null,
139
+ };
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------------------------
143
+ // Loader — the Workflow tool's script format, executed as-is. One rewrite (`export const meta`
144
+ // -> `const meta`), then the whole body becomes an AsyncFunction so top-level `await` and
145
+ // top-level `return` mean exactly what the tool defines them to mean.
146
+ // ---------------------------------------------------------------------------------------------
147
+ function loadWorkflow(path) {
148
+ let src = readFileSync(path, "utf8");
149
+ src = src.replace(/^export\s+const\s+meta\s*=/m, "const meta =");
150
+ if (/^export\s/m.test(src)) {
151
+ throw new Error(`${basename(path)}: unsupported export — Workflow scripts export only \`const meta\``);
152
+ }
153
+ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
154
+ return new AsyncFunction(
155
+ "args", "agent", "parallel", "pipeline", "phase", "log", "budget", "workflow",
156
+ `"use strict";\n${src}`
157
+ );
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------------------------
161
+ // Concurrency — a plain semaphore; excess agent() calls queue, mirroring the tool's cap.
162
+ // ---------------------------------------------------------------------------------------------
163
+ function makeSemaphore(max) {
164
+ let active = 0; const queue = [];
165
+ const release = () => { active--; const next = queue.shift(); if (next) { active++; next(); } };
166
+ const acquire = () => new Promise((res) => {
167
+ if (active < max) { active++; res(); } else queue.push(res);
168
+ });
169
+ return { acquire, release };
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------------------------
173
+ // Worker spawn — one headless CLI session per dispatch. Process-group kill on timeout
174
+ // (hook grandchildren keep pipes open, so
175
+ // signaling one pid is not a cap).
176
+ // ---------------------------------------------------------------------------------------------
177
+ const KILL_GRACE_MS = 10_000;
178
+
179
+ function runClaude({ prompt, model, permissionMode, cwd, timeoutMs }) {
180
+ return new Promise((resolvePromise) => {
181
+ const child = spawn("claude", [
182
+ "-p", prompt,
183
+ "--model", model,
184
+ "--output-format", "json",
185
+ "--permission-mode", permissionMode,
186
+ ], { cwd, env: process.env, detached: true });
187
+
188
+ let stdout = "", stderr = "", settled = false, killed = false;
189
+ const settle = (r) => { if (!settled) { settled = true; clearTimeout(timer); clearTimeout(hardTimer); resolvePromise(r); } };
190
+
191
+ let hardTimer = null;
192
+ const timer = setTimeout(() => {
193
+ killed = true;
194
+ try { process.kill(-child.pid, "SIGTERM"); } catch { /* already gone */ }
195
+ hardTimer = setTimeout(() => { try { process.kill(-child.pid, "SIGKILL"); } catch { /* already gone */ } }, KILL_GRACE_MS);
196
+ }, timeoutMs);
197
+
198
+ child.stdout.on("data", (d) => { stdout += d; });
199
+ child.stderr.on("data", (d) => { stderr += d; });
200
+ child.on("error", (e) => settle({ ok: false, error: `cannot spawn claude: ${e.message}`, killed, stdout, stderr }));
201
+ child.on("close", (code) => {
202
+ let envelope = null;
203
+ try { envelope = JSON.parse(stdout.trim()); } catch { /* non-JSON stdout stays raw */ }
204
+ settle({ ok: code === 0 && envelope !== null && envelope.is_error !== true, code, killed, envelope, stdout, stderr });
205
+ });
206
+ });
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------------------------
210
+ // Structured output — instruction + extraction + shallow validation + one retry.
211
+ // Extraction is parseMechJson's balanced-scan (shapeup-run.js:176) — proven against couriers
212
+ // that wrap clean JSON in commentary; the same failure mode applies to whole workers.
213
+ // ---------------------------------------------------------------------------------------------
214
+ function extractJson(text) {
215
+ if (typeof text !== "string") return null;
216
+ const s = text.trim();
217
+ try { return JSON.parse(s); } catch { /* fall through to extraction */ }
218
+ const start = s.search(/[{[]/);
219
+ if (start < 0) return null;
220
+ const open = s[start], close = open === "{" ? "}" : "]";
221
+ let depth = 0, inStr = false, esc = false;
222
+ for (let i = start; i < s.length; i++) {
223
+ const c = s[i];
224
+ if (inStr) {
225
+ if (esc) esc = false;
226
+ else if (c === "\\") esc = true;
227
+ else if (c === '"') inStr = false;
228
+ continue;
229
+ }
230
+ if (c === '"') { inStr = true; continue; }
231
+ if (c === open) depth++;
232
+ else if (c === close && --depth === 0) {
233
+ try { return JSON.parse(s.slice(start, i + 1)); } catch { return null; }
234
+ }
235
+ }
236
+ return null;
237
+ }
238
+
239
+ function shallowValidate(schema, value) {
240
+ const problems = [];
241
+ if (!schema || typeof schema !== "object") return problems;
242
+ if (schema.type === "object") {
243
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
244
+ return [`expected object, got ${value === null ? "null" : Array.isArray(value) ? "array" : typeof value}`];
245
+ }
246
+ for (const k of schema.required || []) if (!(k in value)) problems.push(`missing required key "${k}"`);
247
+ for (const [k, sub] of Object.entries(schema.properties || {})) {
248
+ if (!(k in value) || !sub.type) continue;
249
+ const v = value[k];
250
+ const t = sub.type === "integer" ? (Number.isInteger(v) ? "integer" : typeof v)
251
+ : Array.isArray(v) ? "array" : typeof v;
252
+ if (t !== sub.type && !(sub.type === "number" && typeof v === "number")) {
253
+ problems.push(`key "${k}": expected ${sub.type}, got ${t}`);
254
+ }
255
+ }
256
+ } else if (schema.type === "array" && !Array.isArray(value)) {
257
+ problems.push(`expected array, got ${typeof value}`);
258
+ }
259
+ return problems;
260
+ }
261
+
262
+ const schemaInstruction = (schema) =>
263
+ "\n\n---\nSTRUCTURED OUTPUT REQUIRED. Your final reply must be ONLY a single JSON value that " +
264
+ "validates against this JSON Schema — no prose, no markdown fences, nothing before or after " +
265
+ `it:\n${JSON.stringify(schema)}`;
266
+
267
+ // ---------------------------------------------------------------------------------------------
268
+ // main
269
+ // ---------------------------------------------------------------------------------------------
270
+ async function main() {
271
+ const cli = configure(runArgs(ARGV_SPEC));
272
+ mkdirSync(cli.runDir, { recursive: true });
273
+ const journalPath = resolve(cli.runDir, "journal.jsonl");
274
+ const journal = (entry) => appendFileSync(journalPath, JSON.stringify(entry) + "\n");
275
+ const note = (m) => process.stderr.write(`[run-workflow] ${m}\n`);
276
+
277
+ const sem = makeSemaphore(cli.maxConcurrency);
278
+ let seq = 0, costAccum = 0, currentPhase = null;
279
+
280
+ const budget = {
281
+ total: cli.budgetUsd,
282
+ spent: () => costAccum,
283
+ remaining: () => (cli.budgetUsd == null ? Infinity : Math.max(0, cli.budgetUsd - costAccum)),
284
+ };
285
+
286
+ async function agent(prompt, opts = {}) {
287
+ const id = ++seq;
288
+ const label = opts.label || `agent-${id}`;
289
+ const model = opts.model || "sonnet";
290
+ const phaseName = opts.phase || currentPhase;
291
+ if (budget.total != null && budget.remaining() <= 0) {
292
+ throw new Error(`budget exhausted ($${costAccum.toFixed(3)} of $${budget.total}) before agent "${label}"`);
293
+ }
294
+ if (opts.isolation) throw new Error(`agent "${label}": isolation: 'worktree' is not implemented on this lane`);
295
+ await sem.acquire();
296
+ const startedAt = new Date().toISOString();
297
+ const t0 = process.hrtime.bigint();
298
+ try {
299
+ let fullPrompt = opts.schema ? prompt + schemaInstruction(opts.schema) : prompt;
300
+ let result = null, attempts = 0, sessions = [];
301
+ while (attempts < 2) {
302
+ attempts++;
303
+ note(`agent#${id} "${label}" attempt ${attempts} (model=${model}, mode=${cli.workerPermissionMode})`);
304
+ const r = await runClaude({
305
+ prompt: fullPrompt, model, permissionMode: cli.workerPermissionMode,
306
+ cwd: cli.workerCwd, timeoutMs: cli.agentTimeoutS * 1000,
307
+ });
308
+ const env = r.envelope || {};
309
+ sessions.push({ session_id: env.session_id ?? null, cost_usd: env.total_cost_usd ?? null, is_error: env.is_error ?? null, killed: r.killed });
310
+ if (typeof env.total_cost_usd === "number") costAccum += env.total_cost_usd;
311
+ if (!r.ok) { result = null; if (r.killed) break; continue; }
312
+ if (!opts.schema) { result = env.result ?? null; break; }
313
+ const parsed = extractJson(env.result);
314
+ const problems = parsed === null ? ["reply contained no parseable JSON"] : shallowValidate(opts.schema, parsed);
315
+ if (problems.length === 0) { result = parsed; break; }
316
+ note(`agent#${id} "${label}" schema problems: ${problems.join("; ")}`);
317
+ fullPrompt = prompt + schemaInstruction(opts.schema) +
318
+ `\n\nA previous attempt failed validation: ${problems.join("; ")}. Correct this.`;
319
+ result = null;
320
+ }
321
+ const wallMs = Number(process.hrtime.bigint() - t0) / 1e6;
322
+ journal({ seq: id, label, phase: phaseName, model, permission_mode: cli.workerPermissionMode,
323
+ started_at: startedAt, wall_ms: Math.round(wallMs), attempts, sessions, ok: result !== null, result });
324
+ return result;
325
+ } finally {
326
+ sem.release();
327
+ }
328
+ }
329
+
330
+ // Thunk errors resolve to null and the call never rejects — the Workflow tool's contract,
331
+ // which shapeup-run.js's call sites (mechEnvelope, dispatch guards) are written against.
332
+ const parallel = (thunks) => Promise.all(
333
+ thunks.map((t) => Promise.resolve().then(t).catch((e) => { note(`parallel thunk failed: ${e.message}`); return null; }))
334
+ );
335
+
336
+ // No barrier between stages; a stage that throws drops the item to null and skips the rest.
337
+ const pipeline = (items, ...stages) => Promise.all(
338
+ items.map(async (item, i) => {
339
+ let acc = item;
340
+ for (const stage of stages) {
341
+ try { acc = await stage(acc, item, i); }
342
+ catch (e) { note(`pipeline item ${i} failed: ${e.message}`); return null; }
343
+ }
344
+ return acc;
345
+ })
346
+ );
347
+
348
+ const phase = (title) => { currentPhase = title; note(`── phase: ${title}`); };
349
+ const log = (m) => note(String(m));
350
+ const workflow = () => { throw new Error("workflow() child workflows are not implemented on this lane"); };
351
+
352
+ const fn = loadWorkflow(cli.script);
353
+ note(`running ${basename(cli.script)} (run-dir ${cli.runDir})`);
354
+ const result = await fn(cli.args, agent, parallel, pipeline, phase, log, budget, workflow);
355
+
356
+ const summary = { ok: true, script: basename(cli.script), agents_dispatched: seq,
357
+ cost_usd: Number(costAccum.toFixed(6)), result };
358
+ writeFileSync(resolve(cli.runDir, "result.json"), JSON.stringify(summary, null, 2) + "\n");
359
+ process.stdout.write(JSON.stringify({ ok: true, result }) + "\n");
360
+ return cli.runDir;
361
+ }
362
+
363
+ if (isMain(import.meta.url)) {
364
+ main().catch((e) => {
365
+ // The failure is written where a background caller will look for it, not only to a stdout
366
+ // nobody is reading: a launch that dies silently is the exact shape this file exists to end.
367
+ process.stdout.write(JSON.stringify({ ok: false, error: e.message }) + "\n");
368
+ process.stderr.write(`[run-workflow] FATAL ${e.stack}\n`);
369
+ try {
370
+ const dir = process.argv.includes("--run-dir")
371
+ ? resolve(process.argv[process.argv.indexOf("--run-dir") + 1])
372
+ : null;
373
+ if (dir) {
374
+ mkdirSync(dir, { recursive: true });
375
+ writeFileSync(resolve(dir, "result.json"),
376
+ JSON.stringify({ ok: false, error: e.message }, null, 2) + "\n");
377
+ }
378
+ } catch { /* the stdout line above is still the record */ }
379
+ process.exit(1);
380
+ });
381
+ }
@@ -35,6 +35,7 @@ import {
35
35
  roundLedger, discoveryLedger, receipt as receiptPath, harnessRun, relShared,
36
36
  } from "./lib/paths.mjs";
37
37
  import { readTrials } from "./t0-verify.mjs";
38
+ import { ratchetReport } from "./stats.mjs";
38
39
 
39
40
  /** @returns {string} Today as `YYYY-MM-DD` (UTC). */
40
41
  const today = () => new Date().toISOString().slice(0, 10);
@@ -164,7 +165,7 @@ export function section(md, heading) {
164
165
  */
165
166
  export function buildReport(facts) {
166
167
  const {
167
- slug, at, verdict, qa, rounds, board, t0, artifacts,
168
+ slug, at, verdict, qa, rounds, board, t0, artifacts, ratchet,
168
169
  evalCriteria, evalBugs, qaFindings, decisions, discovered, intakeSha,
169
170
  } = facts;
170
171
 
@@ -202,6 +203,35 @@ export function buildReport(facts) {
202
203
  L.push("");
203
204
  }
204
205
 
206
+ // The ratchet aggregate is derived, ~10 scalars that do not grow with the run, which is why it
207
+ // can live in the committed tier while `metrics/` correctly stays gitignored (ADR-0001: a
208
+ // committed shard keyed on $HOSTNAME only grows). Without this the instrument existed and was
209
+ // never read — the trial ledger it reduces is harvested at SHIP or lost with the local tier.
210
+ if (ratchet && ratchet.trials > 0) {
211
+ L.push("## Ratchet", "");
212
+ L.push("Measured over this run's trial ledger. A monotone series is a ratchet working; a flat or",
213
+ "sawtooth series says the loop is still a budgeted retry loop wearing a ratchet's shape.", "");
214
+ L.push("| | |", "|---|---|");
215
+ L.push(`| Trials | ${ratchet.trials} across ${ratchet.scopes} scope(s), ${ratchet.scopes_multi_trial} with more than one attempt |`);
216
+ L.push(`| Improvement rate | ${ratchet.improvement_rate} — kept ÷ trials after the first |`);
217
+ L.push(`| Monotone rate | ${ratchet.monotone_rate} — multi-trial scopes whose score never decreased |`);
218
+ L.push(`| Sawtooth count | ${ratchet.sawtooth_count} — a revert immediately after a keep |`);
219
+ L.push(`| Mean trials to green | ${ratchet.mean_trials_to_green ?? "— (no scope reached green)"} |`);
220
+ const hist = Object.entries(ratchet.status_histogram).map(([k, v]) => `${k} ${v}`).join(", ");
221
+ L.push(`| Statuses | ${hist || "—"} |`);
222
+ L.push("");
223
+ // A zero improvement_rate means one of two opposite things, and the number alone cannot say
224
+ // which: the loop tried again and failed to improve, or nothing ever needed a second attempt.
225
+ // Measured on the first two real runs — every scope went green on attempt 1 — so the reading a
226
+ // reviewer meets by default is the degenerate one. `stats --ratchet` says so on the terminal;
227
+ // the committed report has to say it too, or a 0 here is read as "the ratchet did not work".
228
+ if (ratchet.scopes_multi_trial === 0) {
229
+ L.push("> No scope needed a second attempt, so the rates above are vacuous rather than bad:",
230
+ "> the ratchet was never asked to climb. The Day-1 question — does the loop measurably",
231
+ "> improve across attempts — needs a run where at least one scope retries.", "");
232
+ }
233
+ }
234
+
205
235
  if (evalCriteria) L.push("## Evaluation", "", evalCriteria, "");
206
236
  if (evalBugs) L.push("### Refuted criteria and bugs", "", evalBugs, "");
207
237
  if (qaFindings) L.push("## QA findings", "", qaFindings, "");
@@ -210,7 +240,7 @@ export function buildReport(facts) {
210
240
 
211
241
  L.push("---", "",
212
242
  "*Run state (board, orders, results, T0 artifacts, evaluation and QA reports) stays in the",
213
- "gitignored local tier — see `docs/design/adr/0001-consumer-file-organization.md`. This report",
243
+ "gitignored local tier (ADR-0001). This report",
214
244
  "is the frozen conclusion of it.*", "");
215
245
  return L.join("\n");
216
246
  }
@@ -238,6 +268,7 @@ export function generate({ cwd, slug, verdict, qa }) {
238
268
  intakeSha: receipt.intake_sha256,
239
269
  board: boardCensus(cwd, slug),
240
270
  t0: t0Summary(cwd, slug),
271
+ ratchet: ratchetReport(readTrials(trials(cwd, slug))),
241
272
  artifacts: verdictArtifactCount(cwd, slug),
242
273
  evalCriteria: section(evalReport, /^#+\s.*criteria/i) || section(evalReport, /^#+\s*spec-conformance/i),
243
274
  evalBugs: section(evalReport, /^#+\s*Bugs?\b/i),
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- // Stats — the telemetry read-plane (v1.2, absorb-audit P3).
2
+ // Stats — the telemetry read-plane (v1.2).
3
3
  //
4
- // SHIP S.6 has been writing fact rows to shapeup/metrics/<machine-id>.jsonl since
4
+ // SHIP S.6 has been writing fact rows to .shapeup/metrics/<machine-id>.jsonl since
5
5
  // v0.x with exactly one documented reader: `cat *.jsonl`. This script is the missing
6
6
  // projection: rounds per pitch, hammer-cut rate, attempt-budget exhaustions, QA promotion
7
7
  // rate, and the round_count trend — the "is the KB flywheel actually working?" chart.
@@ -149,10 +149,10 @@ export function aggregate({ rows, pathologies, sources, rows_malformed }, { metr
149
149
  // they reduce over artifacts the harness already writes while doing its ordinary work.
150
150
 
151
151
  /**
152
- * `--ratchet` — DAY 1's exit criterion, measured.
152
+ * `--ratchet` — does the build loop actually ratchet?
153
153
  *
154
- * Every measurement in this project's record so far has been HARNESS VERSUS BARE AGENT — a
155
- * question already answered. This one is THE LOOP VERSUS ITS OWN FIRST ATTEMPT, and it cannot be
154
+ * The question harness-versus-bare-agent is already answered elsewhere. This one is THE LOOP
155
+ * VERSUS ITS OWN FIRST ATTEMPT, and it cannot be
156
156
  * won by a one-sentence control, because a one-sentence control has no second attempt to compare.
157
157
  *
158
158
  * A monotone series is a ratchet working. A flat or sawtooth series says the loop is still a
@@ -220,12 +220,11 @@ export function ratchetReport(trials) {
220
220
  }
221
221
 
222
222
  /**
223
- * `--hooks` — DAY 2's instrument.
223
+ * `--hooks` — do the enforcement points actually fire?
224
224
  *
225
- * Of the eight tools built against a MEASURED failure, several scores were previously unobtainable
226
- * because "never had to fire" and "never ran" produced the same evidence. With a decision row per
227
- * evaluation, `compact-snapshot` (0 PreCompact events across 1.2M tokens) and `gate-zerowork`
228
- * ("never had to fire after the fix") become SEPARABLE FACTS rather than the same blank.
225
+ * For a hook, "never had to fire" and "never ran" used to produce the same evidence: exit 0,
226
+ * empty stdout. With a decision row per evaluation, a hook that sat inert and a hook that
227
+ * inspected-and-permitted become SEPARABLE FACTS rather than the same blank.
229
228
  *
230
229
  * @param {Array<object>} decisions - Rows from `.shapeup/decisions.jsonl`.
231
230
  * @returns {{evaluations:number, hooks:number, per_hook:Array<object>}} Per-hook fire, allow, deny,
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
- // T0 mechanical verification layer (design spec v1.1 §3.5, Blueprint A/E).
2
+ // T0 mechanical verification layer.
3
3
  //
4
4
  // Runs a scope's e2e fixtures + DB probe (zero LLM tokens), then — on green — the seesaw
5
5
  // regression check (re-runs every FINISHED scope's fixtures from the registry). Writes one
6
6
  // verdict artifact per attempt that spec-evaluator (T1) must cite; a verdict without it is
7
- // structurally invalid (PA4 countermeasure, DD-7). No agent can fabricate this file's contents
7
+ // structurally invalid. No agent can fabricate this file's contents
8
8
  // because it is produced by actually running the commands.
9
9
  //
10
10
  // Zero dependencies, zero network — same discipline as oracles/* and gate-l2.mjs.
@@ -167,7 +167,7 @@ export function score({ fixtures, dbProbe, seesaw }) {
167
167
  * • REGRESSIONS DOMINATE. Breaking a previously-finished scope is never an improvement, whatever
168
168
  * the new scope's fixtures did. This is what lets the old seesaw branch collapse into the
169
169
  * general rule rather than needing a special case.
170
- * • DIFFERENT `fixtures_total` IS INCOMPARABLE, not worse. A scope split or remap changes the
170
+ * • DIFFERENT `fixtures_total` IS INCOMPARABLE, not worse. A re-slice changes the
171
171
  * denominator; comparing across it is a category error, so the ratchet treats it as a baseline
172
172
  * reset (`rebased`) rather than issuing a false verdict.
173
173
  *
@@ -322,7 +322,7 @@ function sha256(text) {
322
322
  * the retry, so the artifact recording the regression was silently replaced by the one recording
323
323
  * the recovery, at the same path. Reproduced against the shipped script: two runs at
324
324
  * `--round 1 --attempt 2`, one red and one green, left ONE file — the red verdict was gone. This
325
- * is the same missing-identity-key defect the benchmark logged as its own #9, recurring on the one
325
+ * is the same missing-identity-key defect logged elsewhere in this codebase, recurring on the one
326
326
  * artifact the evaluator is structurally required to cite.
327
327
  *
328
328
  * An `existsSync` guard would be check-then-write: still racy, and still a policy expressed in code
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // Traceability oracle (spine v1.3, plan docs/internal/plan/ratchet-and-receipt-plan.md §1.4 + §2).
2
+ // Traceability oracle (spine v1.3).
3
3
  //
4
4
  // ONE oracle, TWO mechanically-checkable assertions — nothing that merely *asserts* quality:
5
5
  //
@@ -260,7 +260,7 @@ export function traceLint(slug, { cwd, gate = false }) {
260
260
  }
261
261
  if (wiringFound) wiringMap = wiringFound.contract;
262
262
 
263
- // HD-001. A map whose `## Wiring` table is under a heading the parser does not claim reads as
263
+ // A map whose `## Wiring` table is under a heading the parser does not claim reads as
264
264
  // zero entries, and the loop below then walks nothing and reports `0/0 engines reach <entry>` —
265
265
  // GREEN, for a committed file holding six correct rows. The gate whose entire purpose is that no
266
266
  // engine ships orphaned failing open, on a file that looks right to every human who reviews it.