shapeup-sdlc 1.7.0 → 3.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 (98) hide show
  1. package/.claude/settings.local.example.json +5 -5
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/AGENTS.md +17 -3
  4. package/README.md +124 -103
  5. package/SECURITY.md +37 -30
  6. package/bin/init.mjs +77 -47
  7. package/bin/lib/grant.mjs +145 -0
  8. package/commands/build.md +7 -6
  9. package/commands/ship.md +29 -16
  10. package/commands/wire.md +1 -1
  11. package/hooks/dispatch-receipt.mjs +195 -0
  12. package/hooks/gate-intake.mjs +3 -3
  13. package/hooks/gate-zerowork.mjs +25 -24
  14. package/hooks/hooks.json +9 -48
  15. package/hooks/lib/decision.mjs +19 -8
  16. package/hooks/safety-spine.mjs +3 -3
  17. package/hooks/sandbox-guard.mjs +84 -40
  18. package/{skills/tech-lead/scripts/compile-order.mjs → kernel/compile.mjs} +283 -41
  19. package/{skills/tech-lead/scripts/gate-answers.mjs → kernel/gate.mjs} +58 -14
  20. package/kernel/harness.mjs +134 -0
  21. package/{skills/tech-lead/scripts/fit-check.mjs → kernel/init/fit.mjs} +15 -12
  22. package/{skills/tech-lead/scripts/init-run.mjs → kernel/init/run.mjs} +152 -31
  23. package/{skills/tech-lead/scripts → kernel}/lib/argv.mjs +52 -31
  24. package/{skills/tech-lead/scripts/lib/contract-md.mjs → kernel/lib/contract.mjs} +251 -7
  25. package/kernel/lib/paths.mjs +491 -0
  26. package/kernel/probe/concurrency.mjs +510 -0
  27. package/{skills/tech-lead/scripts/aegis-digest.mjs → kernel/probe/digest.mjs} +9 -10
  28. package/kernel/probe/eval.mjs +77 -0
  29. package/kernel/probe/leg.mjs +125 -0
  30. package/{skills/tech-lead/scripts/resume-state.mjs → kernel/probe/resume.mjs} +204 -63
  31. package/{skills/tech-lead/scripts → kernel/probe}/stats.mjs +93 -11
  32. package/kernel/probe/t0.mjs +66 -0
  33. package/{skills/ba-pitch-analyzer/scripts/board-derive.mjs → kernel/reduce/board.mjs} +99 -23
  34. package/kernel/reduce/graph.mjs +437 -0
  35. package/{skills/tech-lead/scripts/hill-derive.mjs → kernel/reduce/hill.mjs} +28 -9
  36. package/kernel/reduce/ingest.mjs +633 -0
  37. package/{hooks/slop-cleaner.mjs → kernel/reduce/leftovers.mjs} +40 -57
  38. package/{skills/tech-lead/scripts/ship-report.mjs → kernel/reduce/ship.mjs} +61 -13
  39. package/{skills/tech-lead/scripts/run-snapshot.mjs → kernel/reduce/snapshot.mjs} +21 -15
  40. package/{skills/spec-evaluator/scripts/verdict-ledger.mjs → kernel/reduce/verdict.mjs} +13 -7
  41. package/kernel/report/export.mjs +325 -0
  42. package/kernel/report/facts.mjs +347 -0
  43. package/{skills/tech-lead/scripts/budget-check.mjs → kernel/verify/budget.mjs} +15 -12
  44. package/kernel/verify/dispatch.mjs +114 -0
  45. package/{skills/tech-lead/scripts/validate-envelope.mjs → kernel/verify/envelope.mjs} +20 -15
  46. package/{skills/tech-lead/scripts/lib → kernel/verify}/ratchet-tree.mjs +36 -14
  47. package/kernel/verify/skills.mjs +125 -0
  48. package/kernel/verify/spec.mjs +559 -0
  49. package/{skills/tech-lead/scripts/t0-verify.mjs → kernel/verify/t0.mjs} +134 -24
  50. package/{skills/tech-lead/scripts/trace-lint.mjs → kernel/verify/trace.mjs} +26 -11
  51. package/oracles/http-oracle.mjs +1 -1
  52. package/oracles/process-oracle.mjs +1 -1
  53. package/oracles/snapshot-oracle.mjs +1 -1
  54. package/oracles/test-oracle.mjs +1 -1
  55. package/package.json +11 -9
  56. package/skills/ba-pitch-analyzer/SKILL.md +5 -5
  57. package/skills/ba-pitch-analyzer/assets/templates/_index.tmpl.md +1 -1
  58. package/skills/ba-pitch-analyzer/assets/templates/api-feasibility.tmpl.md +12 -7
  59. package/skills/ba-pitch-analyzer/assets/templates/contracts/third-party-api.contract.tmpl.md +6 -3
  60. package/skills/ba-pitch-analyzer/assets/templates/cross-context/migration-plan.tmpl.md +5 -4
  61. package/skills/ba-pitch-analyzer/assets/templates/cross-context/team-handoff.tmpl.md +23 -13
  62. package/skills/ba-pitch-analyzer/assets/templates/scope-summary.tmpl.md +17 -12
  63. package/skills/ba-pitch-analyzer/assets/templates/synthesis.tmpl.md +17 -38
  64. package/skills/ba-pitch-analyzer/assets/templates/task.tmpl.md +3 -0
  65. package/skills/ba-pitch-analyzer/references/contract-patterns.md +8 -4
  66. package/skills/ba-pitch-analyzer/references/doc-schemas.md +2 -0
  67. package/skills/ba-pitch-analyzer/references/task-generation.md +2 -2
  68. package/skills/qa-edge-hunter/SKILL.md +7 -3
  69. package/skills/scope-architect/SKILL.md +40 -6
  70. package/skills/solution-architect/SKILL.md +19 -4
  71. package/skills/spec-evaluator/SKILL.md +12 -2
  72. package/skills/task-executor/SKILL.md +7 -6
  73. package/skills/tech-lead/SKILL.md +50 -38
  74. package/skills/tech-lead/references/gates.md +69 -20
  75. package/skills/tech-lead/references/protocol.md +832 -0
  76. package/skills/tech-lead/schemas/domain.schema.json +341 -66
  77. package/skills/tech-lead/schemas/work-order.schema.json +11 -2
  78. package/skills/tech-lead/schemas/work-result.schema.json +1 -1
  79. package/skills/tech-lead/workflows/shapeup-run.js +1313 -750
  80. package/hooks/anti-rationalization.mjs +0 -238
  81. package/hooks/compact-snapshot.mjs +0 -47
  82. package/hooks/gate-deadline.mjs +0 -147
  83. package/hooks/gate-l2.mjs +0 -161
  84. package/hooks/session-rehydrate.mjs +0 -108
  85. package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +0 -239
  86. package/skills/spec-evaluator/README.md +0 -93
  87. package/skills/tech-lead/README.md +0 -71
  88. package/skills/tech-lead/references/delegation.md +0 -261
  89. package/skills/tech-lead/references/hard-rules.md +0 -34
  90. package/skills/tech-lead/references/invocation.md +0 -45
  91. package/skills/tech-lead/references/ledger-schema.md +0 -213
  92. package/skills/tech-lead/references/round-protocol.md +0 -234
  93. package/skills/tech-lead/references/state-model.md +0 -66
  94. package/skills/tech-lead/scripts/ingest-result.mjs +0 -258
  95. package/skills/tech-lead/scripts/lib/is-main.mjs +0 -81
  96. package/skills/tech-lead/scripts/lib/paths.mjs +0 -280
  97. package/skills/tech-lead/scripts/run-workflow.mjs +0 -381
  98. package/skills/translator/README.md +0 -66
@@ -13,13 +13,13 @@
13
13
  //
14
14
  // Usage:
15
15
  // Scope attempt (isolated attempt loop):
16
- // node skills/tech-lead/scripts/compile-order.mjs --scope shapeup/<slug>/scopes/<id>.md \
16
+ // node kernel/harness.mjs compile --scope shapeup/<slug>/scopes/<id>.md \
17
17
  // --round N --attempt M [--cwd <dir>] [--test-cmd "<cmd>"]
18
18
  // Single task (no scope contracts — pre-v0.3.0 boards):
19
- // node skills/tech-lead/scripts/compile-order.mjs --task TASK-003 --slug <slug> [--worker task-executor]
20
- // node skills/tech-lead/scripts/compile-order.mjs --next --slug <slug>
19
+ // node kernel/harness.mjs compile --task TASK-003 --slug <slug> [--worker task-executor]
20
+ // node kernel/harness.mjs compile --next --slug <slug>
21
21
  // Non-build operation (planner/judge/QA dispatches; flag surface → operation + whitelist):
22
- // node skills/tech-lead/scripts/compile-order.mjs --operation reconcile --slug <slug> [--round N]
22
+ // node kernel/harness.mjs compile --operation reconcile --slug <slug> [--round N]
23
23
  // [--worker ba-pitch-analyzer] [--payload '<json>']
24
24
  //
25
25
  // Output: .shapeup/<slug>/orders/<order-file>.json (schema-validated before write; a
@@ -28,21 +28,25 @@
28
28
  import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from "node:fs";
29
29
  import { resolve, join, dirname, basename } from "node:path";
30
30
  import { fileURLToPath } from "node:url";
31
- import { validate } from "./validate-envelope.mjs";
32
- import { readTrials } from "./t0-verify.mjs";
33
- import { isMain } from "./lib/is-main.mjs";
31
+ import { validate } from "./verify/envelope.mjs";
32
+ import { readTrials } from "./verify/t0.mjs";
34
33
  import { runArgs } from "./lib/argv.mjs";
34
+ import { readRunId } from "./lib/paths.mjs";
35
35
  // `specDir` is aliased: this module has a local `let specDir` holding the resolved, possibly
36
36
  // --spec-overridden directory, and the import is the convention-derived default.
37
37
  import {
38
38
  tasksDir, specDir as defaultSpecDir, roundLedger, trials, verdictsDir, ordersDir,
39
- relShared, globLocal, globShared, relKnowledgeBase,
39
+ relShared, globLocal, globShared, relKnowledgeBase, resultsDir, scopesDir,
40
40
  } from "./lib/paths.mjs";
41
- import { readContract, SCOPE_CONTRACT } from "./lib/contract-md.mjs";
42
- import { writeActiveOrder } from "./resume-state.mjs";
41
+ import { readContract, tasksForScope, SCOPE_CONTRACT } from "./lib/contract.mjs";
42
+ import { writeActiveOrder } from "./probe/resume.mjs";
43
+ // The SAME matcher the sandbox hook enforces with. "Is this cited file inside this scope's
44
+ // substrate" has to mean exactly what the guard means, or a bug is addressed to a scope that is
45
+ // then denied the write that fixes it.
46
+ import { matchesAny } from "../hooks/sandbox-guard.mjs";
43
47
 
44
48
  const HERE = dirname(fileURLToPath(import.meta.url));
45
- const ORDER_SCHEMA = JSON.parse(readFileSync(resolve(HERE, "../schemas/work-order.schema.json"), "utf8"));
49
+ const ORDER_SCHEMA = JSON.parse(readFileSync(resolve(HERE, "./../skills/tech-lead/schemas/work-order.schema.json"), "utf8"));
46
50
 
47
51
  // --- tiny frontmatter reader (scalar keys + [a, b] inline lists) --------------------------
48
52
  /**
@@ -72,7 +76,7 @@ export function frontmatter(md) {
72
76
  * Parse one TASK-NNN.md file into the task entry a WorkOrder carries.
73
77
  * @param {string} path - Absolute path to the task Markdown file.
74
78
  * @returns {{id:string, title:string, status:string, priority:number, depends_on:string[],
75
- * use_case_refs:string[], body_path:string, acceptance_criteria:Array<string|{text:string,
79
+ * use_case_refs:string[], scope_id:string, body_path:string, acceptance_criteria:Array<string|{text:string,
76
80
  * covers:string[]}>}} The task entry. Each acceptance criterion is the checkbox text
77
81
  * byte-identical (so ingest can tick it back); a trailing `(covers: REQ-…)` clause yields
78
82
  * {text, covers} instead of a plain string. `priority` defaults to 999 when unset.
@@ -99,6 +103,7 @@ export function parseTaskFile(path) {
99
103
  priority: Number(fm.priority) || 999,
100
104
  depends_on: Array.isArray(fm.depends_on) ? fm.depends_on : [],
101
105
  use_case_refs: Array.isArray(fm.use_case_refs) ? fm.use_case_refs : [],
106
+ scope_id: fm.scope_id || "",
102
107
  body_path: path,
103
108
  acceptance_criteria,
104
109
  };
@@ -144,6 +149,25 @@ export function ledgerDecisions(ledgerText, scopeId) {
144
149
  return decisions;
145
150
  }
146
151
 
152
+ /**
153
+ * Operation → owning worker (mirrors `domain.schema.json#/$defs/Operation` ownership). Lets a
154
+ * non-build dispatch resolve its worker from the operation alone, without a redundant `--worker`.
155
+ *
156
+ * Exported and at module scope so it is ONE table. Every value here must be a member of
157
+ * `WorkerName`: an operation mapped to a name the enum does not carry compiles an order that fails
158
+ * its own schema, and the dispatch is then denied by the order gate for a reason that names the
159
+ * envelope rather than the typo. That parity is checked by the suite, which can only read a table
160
+ * it can import.
161
+ */
162
+ export const OP_OWNER = {
163
+ analyze: "ba-pitch-analyzer", reconcile: "ba-pitch-analyzer",
164
+ "retrofit-surface": "ba-pitch-analyzer", coverage: "ba-pitch-analyzer",
165
+ "map-scopes": "scope-architect",
166
+ wire: "solution-architect", evaluate: "spec-evaluator", orient: "orient",
167
+ hunt: "qa-edge-hunter", translate: "translator",
168
+ hammer: "scope-hammer", coach: "coach",
169
+ };
170
+
147
171
  /**
148
172
  * Resolve the write-contract (sandbox substrate) for an operation — one whitelist template per
149
173
  * operation, so mode/flag differences are enforced by the sandbox hook reading the order's substrate, not trusted to prose.
@@ -281,7 +305,7 @@ export function selectTrialHistory(trials, { scopeId, round } = {}) {
281
305
  }
282
306
 
283
307
  /**
284
- * The stagnation term of the inner circuit breaker (plan §2.6).
308
+ * The stagnation term of the inner circuit breaker.
285
309
  *
286
310
  * `attempt_budget` counts attempts; it cannot see that the last two produced nothing. One term
287
311
  * joins it: `no_progress_k` consecutive non-`kept` trials ends the scope early and queues the
@@ -302,6 +326,162 @@ export function stagnation(trials, k = 2) {
302
326
  return { stagnant: streak >= k && k > 0, streak, k };
303
327
  }
304
328
 
329
+ // --- the FAIL verdict's bugs, carried into the round that must fix them ----------------------
330
+ //
331
+ // WHY THE KERNEL OWNS THIS AND NOT THE ORCHESTRATOR (measured twice, two different ways).
332
+ //
333
+ // `WorkOrderPayload.bugs` is defined as "the EVAL report's bug entries for this task — touch
334
+ // nothing else", and AGENTS.md states the regression rule as "bugs + full Test Surface of touched
335
+ // UC". Nothing ever populated it. The orchestrator's first fix threaded the evaluator's findings
336
+ // from memory into the build leg's `payload`, and it could not work, for two independent reasons:
337
+ //
338
+ // 1. A BUILD ORDER TAKES NO PAYLOAD FROM THE CALLER. Build legs override the compile line
339
+ // (`compile --scope … --round … --attempt …`) because an order is addressed by scope, round
340
+ // and attempt; only the generic fallback line serialises `--payload`. So the payload object
341
+ // the orchestrator built for a build leg was constructed, filtered, and dropped on the floor.
342
+ // 2. IT LIVED ONLY IN MEMORY. Round r's verdict is what round r+1 must act on, and a run killed
343
+ // after EVAL and relaunched — the normal case here — starts round r+1 in a fresh process with
344
+ // an empty variable. The verdict was on disk the whole time.
345
+ //
346
+ // Both vanish if the evidence is read where the order is written: the same read on a fresh round
347
+ // and a resumed one, no process boundary to survive, and no model in the path — an LLM courier
348
+ // transcribing a repro string is a repro string that no longer reproduces.
349
+ //
350
+ // The shape is also RICHER than the in-memory channel ever was: the ledgered verdict carries
351
+ // severity, file:line, repro, expected and actual per bug, where the structured return carried
352
+ // only `{id, criterion, evidence}`.
353
+
354
+ /**
355
+ * The previous round's cited defects, if that round returned FAIL.
356
+ *
357
+ * @param {string} cwd - Project root.
358
+ * @param {string} slug - Feature slug.
359
+ * @param {number} [round] - The round being compiled; round 1 has no predecessor.
360
+ * @returns {Array<object>} Bug entries, minus any the adversarial check overturned. Empty for a
361
+ * first round, a PASS, or an absent/unreadable result — a fix round with nothing to fix is a
362
+ * normal build round, never an error.
363
+ */
364
+ export function verdictBugs(cwd, slug, round) {
365
+ if (!round || round < 2) return [];
366
+ const p = join(resultsDir(cwd, slug), `evaluate-r${round - 1}.json`);
367
+ if (!existsSync(p)) return [];
368
+ let v;
369
+ try { v = JSON.parse(readFileSync(p, "utf8"))?.verdict; } catch { return []; }
370
+ if (v?.overall !== "FAIL" || !Array.isArray(v.bugs)) return [];
371
+ // A refuted acceptance criterion is one the judge withdrew. Re-dispatching it would send a
372
+ // worker to "fix" behaviour that was found correct.
373
+ const refuted = new Set(
374
+ (Array.isArray(v.refuted) ? v.refuted : [])
375
+ .flatMap((r) => [r?.id, r?.ac_id, r?.criterion, typeof r === "string" ? r : null])
376
+ .filter(Boolean).map(String),
377
+ );
378
+ return v.bugs.filter((b) => !refuted.has(String(b?.id)) && !refuted.has(String(b?.criterion)));
379
+ }
380
+
381
+ /**
382
+ * Every repo-relative file a bug is cited against.
383
+ *
384
+ * A LOCATOR NAMES MORE THAN ONE SITE, ROUTINELY. The judge writes what it found, and what it finds
385
+ * is often the same defect at several lines, sometimes across files:
386
+ * `"bin/todo.js:53, bin/todo.js:85"`, or `"lib/parse-index.js:5, :13, :21 (rendered by
387
+ * bin/todo.js:66)"`. A parser that accepts only a lone `file:line` returns nothing for those, and
388
+ * "nothing" routes the bug to every scope as unowned — the safe direction, but it throws away an
389
+ * address the judge did supply. On the measured round-1 verdict that was two of five bugs.
390
+ *
391
+ * @param {object} bug - One bug entry.
392
+ * @returns {string[]} Distinct paths, `:line` suffixes and `./` prefixes stripped, in first-seen
393
+ * order. Empty when the entry carries no locator this can read.
394
+ */
395
+ export function bugLocations(bug) {
396
+ const raw = String(bug?.location ?? bug?.file ?? "").trim();
397
+ // A path-looking token: at least one dot-extension, and no whitespace. The extension is what
398
+ // keeps prose out ("exit 1", "Node 20") without needing to know the project's layout.
399
+ const out = [];
400
+ for (const m of raw.matchAll(/(?:^|[\s(,[])([\w@][\w./-]*\.[A-Za-z][A-Za-z0-9]{0,5})(?::\d+)?/g)) {
401
+ const p = m[1].replace(/^\.\//, "");
402
+ if (!out.includes(p)) out.push(p);
403
+ }
404
+ return out;
405
+ }
406
+
407
+ /**
408
+ * Elect the ONE scope that should fix a defect cited against a given file.
409
+ *
410
+ * OWNERSHIP IS BY SUBSTRATE, because that is what the sandbox enforces: a scope is exactly the set
411
+ * of files its worker may write, so a scope whose substrate excludes the cited line cannot fix it
412
+ * however well it understands the bug.
413
+ *
414
+ * BUT A MATCH IS NOT AN ELECTION. An entry point is routinely SHARED — on the measured run
415
+ * `bin/todo.js` sits in five scopes' substrate at once — so "address it to every scope that
416
+ * matches" hands the same one-line fix to five workers building concurrently against one file.
417
+ * That is a write race the harness sets up itself, and four of the five fixes are waste even when
418
+ * it resolves. So: prefer a scope that owns the file EXCLUSIVELY (allowed, not shared), and among
419
+ * equals take the lowest scope id — a rule that needs no coordination to agree with itself, since
420
+ * each leg compiles its own order in its own process.
421
+ *
422
+ * @param {string} path - Repo-relative file the bug cites.
423
+ * @param {Array<{scope_id:string, allowed:string[], shared:string[]}>} scopes - Every scope.
424
+ * @returns {string|null} The elected scope id, or null when no scope may write that file.
425
+ */
426
+ export function electOwner(path, scopes) {
427
+ const can = (scopes || []).filter((s) => matchesAny(path, s.allowed));
428
+ if (!can.length) return null;
429
+ const exclusive = can.filter((s) => !matchesAny(path, s.shared || []));
430
+ return (exclusive.length ? exclusive : can).map((s) => s.scope_id).sort()[0];
431
+ }
432
+
433
+ /**
434
+ * Address each bug to the scope that must fix it.
435
+ *
436
+ * AN UNOWNED BUG GOES TO EVERYONE, MARKED. A cited defect matching no scope's substrate — or
437
+ * carrying no locator this can read — has no owner, and dropping it silently is precisely how a
438
+ * judged defect survives a fix round to reappear in the next verdict. Better one scope reads a bug
439
+ * it turns out not to own than the run forgets a defect it already paid to find.
440
+ *
441
+ * @param {Array<object>} bugs - The previous verdict's bugs (see {@link verdictBugs}).
442
+ * @param {string|undefined} scopeId - The scope being compiled for.
443
+ * @param {Array<{scope_id:string, allowed:string[], shared:string[]}>} scopes - Every scope.
444
+ * @returns {Array<object>} The subset this order should carry; unowned entries get `unowned: true`.
445
+ */
446
+ export function bugsForScope(bugs, scopeId, scopes) {
447
+ const out = [];
448
+ for (const b of bugs || []) {
449
+ if (b?.scope_id) {
450
+ if (b.scope_id === scopeId) out.push(b);
451
+ continue;
452
+ }
453
+ const owners = bugLocations(b).map((p) => electOwner(p, scopes)).filter(Boolean);
454
+ if (owners.includes(scopeId)) out.push(b);
455
+ else if (!owners.length) out.push({ ...b, unowned: true });
456
+ }
457
+ return out;
458
+ }
459
+
460
+ /**
461
+ * Every scope's write substrate, for the election in {@link electOwner}.
462
+ *
463
+ * @param {string} cwd - Project root.
464
+ * @param {string} slug - Feature slug.
465
+ * @returns {Array<{scope_id:string, allowed:string[], shared:string[]}>} One entry per readable
466
+ * contract; `[]` when the scopes directory is absent or unreadable.
467
+ */
468
+ export function scopeSubstrates(cwd, slug) {
469
+ const dir = scopesDir(cwd, slug);
470
+ let files;
471
+ try { files = readdirSync(dir).filter((f) => f.endsWith(".md") || f.endsWith(".json")); } catch { return []; }
472
+ const out = [];
473
+ for (const f of files) {
474
+ const c = readContract(join(dir, f), SCOPE_CONTRACT)?.contract;
475
+ if (!c?.scope_id || !Array.isArray(c.allowed_file_substrate)) continue;
476
+ out.push({
477
+ scope_id: c.scope_id,
478
+ allowed: c.allowed_file_substrate,
479
+ shared: Array.isArray(c.shared_substrate) ? c.shared_substrate : [],
480
+ });
481
+ }
482
+ return out;
483
+ }
484
+
305
485
  /**
306
486
  * Assemble a WorkOrder envelope. Pure given its inputs — the CLI wrapper does the disk reads.
307
487
  * @param {object} opts - The order inputs (destructured):
@@ -316,27 +496,32 @@ export function stagnation(trials, k = 2) {
316
496
  * @param {Array<{id:string,answer:string}>} [opts.decisions] - This scope's advisor answers.
317
497
  * @param {Array<object>} [opts.digestedErrors] - Prior-attempt AEGIS triples (payload.digested_errors).
318
498
  * @param {Array<object>} [opts.trialHistory] - Compacted trial rows (payload.trial_history).
499
+ * @param {Array<object>} [opts.bugs] - The previous round's cited defects (payload.bugs).
319
500
  * @param {string} [opts.testCmd] - Verify command, recorded under payload.verify.test_cmd.
320
501
  * @param {object} [opts.payloadExtra] - Extra payload fields merged last (spec_folder, feature, …).
321
502
  * @param {string} [opts.specDir] - Spec directory, threaded into the substrate template.
322
503
  * @param {object} [opts.interaction] - Interaction flags (e.g. {pause_gates}).
323
- * @returns {object} A WorkOrder: {schema_version, order_id ("<slug>/<suffix>"), worker, mode,
324
- * operation?, interaction?, substrate (from {@link substrateFor}), payload{…}}. A coachable
325
- * worker also gets payload.kb_rules_path. Not validated here the CLI validates before writing.
504
+ * @param {string} [opts.runId] - The run key (`mintRunId` in `lib/paths.mjs`); omitted when no receipt is readable.
505
+ * @param {string} [opts.compiledAt] - ISO compile time; omitted rather than invented.
506
+ * @returns {object} A WorkOrder: {schema_version, order_id ("<slug>/<suffix>"), run_id?,
507
+ * compiled_at?, worker, mode, operation?, interaction?, substrate (from {@link substrateFor}),
508
+ * payload{…}}. A coachable worker also gets payload.kb_rules_path. Not validated here — the CLI
509
+ * validates before writing.
326
510
  */
327
511
  export function compileOrder({
328
512
  slug, worker, mode = "orchestrated", operation, round, attempt,
329
- scope, tasks, decisions, digestedErrors, trialHistory, testCmd, payloadExtra, specDir, interaction,
513
+ scope, tasks, decisions, digestedErrors, trialHistory, bugs, testCmd, payloadExtra, specDir, interaction,
514
+ runId, compiledAt,
330
515
  }) {
331
516
  // A build order's id carries its SCOPE. Without it, `r<round>-a<attempt>` is the same name for
332
517
  // every scope in a round, so scope 2's order file overwrites scope 1's the moment it is compiled
333
518
  // — measured on the kill/resume probe, where it made `orders/ minus results/` read EMPTY on a run
334
519
  // that was re-dispatching a completed phase. The contract row watching that property therefore
335
- // passed on the exact failure it exists to catch. `t0-verify.mjs`'s verdict artifacts have always
520
+ // passed on the exact failure it exists to catch. ``harness verify t0``'s verdict artifacts have always
336
521
  // been self-identifying this way (`r<R>-a<A>-t<T>.json`, `wx`-created); orders now match, so
337
522
  // `orders/` is an audit trail of dispatches rather than a rolling buffer of the last one.
338
523
  //
339
- // The shape stays `<slug>/<suffix>`: every consumer splits on the FIRST "/" (ingest-result.mjs
524
+ // The shape stays `<slug>/<suffix>`: every consumer splits on the FIRST "/" (`harness reduce ingest`
340
525
  // reads [0] as the slug and [1] as the file stem), and a scope id is already filename-safe
341
526
  // because it is a scope contract's own basename.
342
527
  // `scope` arrives as the PARSED contract object (the CLI reads the .md and embeds it), so the id
@@ -349,10 +534,36 @@ export function compileOrder({
349
534
  : (scope ? String(scope).split("/").pop().replace(/\.(md|json)$/, "") : null);
350
535
  const scopeId = String(rawScopeId || "").toLowerCase().replace(/[^a-z0-9.-]/g, "-").replace(/^[^a-z0-9]+/, "");
351
536
  const buildSuffix = round && attempt ? (scopeId ? `${scopeId}-r${round}-a${attempt}` : `r${round}-a${attempt}`) : null;
352
- const suffix = buildSuffix || (round ? `${operation}-r${round}` : operation);
537
+ // A non-BUILD order was suffixed by operation+round alone, with no per-leg discriminator — so two
538
+ // concurrent non-BUILD legs of the SAME operation and round, dispatched for different scopes
539
+ // (e.g. two scopes both running `evaluate` in the same round), compiled to the identical suffix
540
+ // and the second write clobbered the first order file on disk while the run read green. `scopeId`
541
+ // is already derived above for any operation, not only BUILD; folding it in here — only when a
542
+ // scope was actually passed — closes that collision. Operation-level dispatches (orient, analyze,
543
+ // wire, map-scopes, evaluate, hunt, hammer) never pass a scope, so `scopeId` is empty for them and
544
+ // this reduces to the unchanged `${operation}-r${round}` / `${operation}` shape.
545
+ const scopedRoundSuffix = scopeId
546
+ ? (round ? `${operation}-${scopeId}-r${round}` : `${operation}-${scopeId}`)
547
+ : (round ? `${operation}-r${round}` : operation);
548
+ const suffix = buildSuffix || scopedRoundSuffix;
353
549
  const order = {
354
550
  schema_version: 1,
355
551
  order_id: `${slug}/${suffix}`,
552
+ // THE TWO ANALYTIC FIELDS, and why they are on the ORDER rather than the result.
553
+ //
554
+ // `order_id` identifies a dispatch within a run and repeats across runs of the same slug, so
555
+ // the dispatch record needed a run key to be groupable at all. It is stamped here because this
556
+ // is the one place every lane passes through — the same reason the active-order pointer is
557
+ // published here — so the workflow lane, `--tiny`, the prose round loop and a standalone
558
+ // dispatch all carry it without four separate stamps to keep in step.
559
+ //
560
+ // The RESULT deliberately gets neither. It is written by the worker, and a field a worker has
561
+ // to remember to copy is a field that goes missing under exactly the conditions you most want
562
+ // the record: the run that went wrong. Results join to orders on `order_id`, which they already
563
+ // echo and `validate-envelope` already checks — so the key reaches the result leg through a
564
+ // join that is enforced, instead of through a worker's cooperation.
565
+ ...(runId ? { run_id: runId } : {}),
566
+ ...(compiledAt ? { compiled_at: compiledAt } : {}),
356
567
  worker,
357
568
  mode,
358
569
  ...(operation ? { operation } : {}),
@@ -364,6 +575,9 @@ export function compileOrder({
364
575
  ...(decisions?.length ? { decisions } : {}),
365
576
  ...(digestedErrors?.length ? { digested_errors: digestedErrors } : {}),
366
577
  ...(trialHistory?.length ? { trial_history: trialHistory } : {}),
578
+ // Before `payloadExtra`, so an explicit `--payload '{"bugs":…}'` still wins: an operator
579
+ // hand-addressing a defect outranks the derivation.
580
+ ...(bugs?.length ? { bugs } : {}),
367
581
  ...(testCmd ? { verify: { test_cmd: testCmd, env: [] } } : {}),
368
582
  ...(payloadExtra || {}),
369
583
  },
@@ -376,7 +590,7 @@ export function compileOrder({
376
590
  // ---------------------------------------------------------------------------
377
591
  /** The typed argv contract (see `./lib/argv.mjs`). */
378
592
  export const ARGV_SPEC = {
379
- usage: "compile-order.mjs (--scope <contract.json> --round N --attempt M | --task TASK-NNN --slug <slug> " +
593
+ usage: "harness.mjs compile (--scope <contract.json> --round N --attempt M | --task TASK-NNN --slug <slug> " +
380
594
  "| --next --slug <slug> | --operation <op> --slug <slug>) [--worker <skill>] [--payload '<json>'] " +
381
595
  "[--spec <dir>] [--test-cmd \"<cmd>\"] [--cwd <dir>] [--pause-gates]",
382
596
  _: { arity: 0, max: 0, name: "(no positional operands)" },
@@ -395,9 +609,15 @@ export const ARGV_SPEC = {
395
609
  "pause-gates": { type: "flag" },
396
610
  };
397
611
 
398
- const isMainModule = isMain(import.meta.url);
399
- if (isMainModule) {
400
- const argv = runArgs(ARGV_SPEC);
612
+ /**
613
+ * Compile a WorkOrder for one dispatch and publish the substrate pointer that fences it.
614
+ *
615
+ * @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
616
+ * @returns {(Promise<void>|void)} Settles when the subcommand has written its output; most paths
617
+ * call `process.exit()` with the subcommand's documented code rather than returning.
618
+ */
619
+ export async function cli(rawArgv) {
620
+ const argv = runArgs(ARGV_SPEC, rawArgv);
401
621
  /**
402
622
  * Read a parsed flag's value, normalising "absent" to null (the shape the body below expects).
403
623
  * @param {string} name - Flag name without leading dashes.
@@ -433,17 +653,26 @@ if (isMainModule) {
433
653
 
434
654
  const round = flag("round");
435
655
  const attempt = flag("attempt");
436
- // Operation → owning worker (mirrors domain.schema.json $defs/Operation ownership). Lets a
437
- // non-build dispatch resolve its worker from the operation alone, without a redundant --worker.
438
- const OP_OWNER = {
439
- analyze: "ba-pitch-analyzer", reconcile: "ba-pitch-analyzer",
440
- "retrofit-surface": "ba-pitch-analyzer", coverage: "ba-pitch-analyzer",
441
- "map-scopes": "scope-architect",
442
- wire: "solution-architect", evaluate: "spec-evaluator", orient: "orient",
443
- hunt: "qa-edge-hunter", translate: "translator",
444
- hammer: "scope-hammer", coach: "coach",
445
- };
446
- let operation = flag("operation") || (scopePath || flag("task") || has("next") ? "execute" : null);
656
+
657
+ // The fix round's inbound evidence. Derived here, from the ledgered verdict, for every lane
658
+ // the workflow, `--tiny`, the prose round loop and a standalone `/build` all compile through
659
+ // this line, and none of them can pass a payload to a build order (see the banner above).
660
+ const bugs = scope ? bugsForScope(verdictBugs(cwd, slug, round), scope.scope_id, scopeSubstrates(cwd, slug)) : [];
661
+
662
+ // A ROUND CARRYING CITED DEFECTS IS A `fix`, AND THE ORDER HAS TO SAY SO.
663
+ //
664
+ // `payload.bugs` is the only field task-executor's input contract binds to a specific operation:
665
+ // "`fix` (only the bugs in `payload.bugs` — touch nothing else)". Dispatching a fix round as
666
+ // `execute` hands the worker a field its own contract associates with a different operation,
667
+ // against a Zero-memory rule that says to treat anything not in the order as unknown. Delivering
668
+ // evidence the receiving contract does not claim is half a delivery.
669
+ //
670
+ // Free of blast radius by construction: `substrateFor` returns the same whitelist for
671
+ // execute/fix/spike, so the sandbox contract is byte-identical, and the order id is addressed by
672
+ // scope/round/attempt rather than by operation, so no filename moves. An explicit `--operation`
673
+ // still wins — an operator naming the operation outranks the derivation.
674
+ let operation = flag("operation")
675
+ || (scopePath || flag("task") || has("next") ? (bugs.length ? "fix" : "execute") : null);
447
676
  const worker = flag("worker")
448
677
  || (scopePath || flag("task") || has("next") ? "task-executor" : null)
449
678
  || (operation ? OP_OWNER[operation] : null);
@@ -460,10 +689,18 @@ if (isMainModule) {
460
689
  tasks = board.filter((t) => t.status === "ready" && t.depends_on.every((d) => doneIds.has(d))).slice(0, 1);
461
690
  if (!tasks.length) { console.error("compile-order: no ready task with satisfied dependencies"); process.exit(2); }
462
691
  } else if (scope) {
463
- // Scope attempt: the scope's own task list if the contract names one, else every
464
- // not-done task on the board (the attempt loop owns sequencing, not the worker).
465
- const named = new Set(scope.tasks || []);
466
- tasks = board.filter((t) => (named.size ? named.has(t.id) : t.status !== "done"));
692
+ // Scope attempt: the board tasks anchored to this scope's use cases, else every not-done task
693
+ // on the board (the attempt loop owns sequencing, not the worker).
694
+ //
695
+ // The contract names USE CASES, never task ids: it is committed and the board is not, so a
696
+ // contract holding `TASK-004` pointed into a gitignored per-machine tier and resolved to
697
+ // nothing on a fresh clone — silently, because an empty filter and an absent board are the
698
+ // same empty array. A scope that anchors no use case still falls back to the whole open board
699
+ // rather than dispatching nothing; spec-lint's SCOPE-ANCHOR is what reports the missing anchor.
700
+ const anchored = tasksForScope(board, scope);
701
+ tasks = anchored.length || (scope.use_cases || []).length
702
+ ? anchored
703
+ : board.filter((t) => t.status !== "done");
467
704
  }
468
705
 
469
706
  // Ledger decisions for this scope.
@@ -474,7 +711,7 @@ if (isMainModule) {
474
711
 
475
712
  // inspect() — the attempt loop's history (see selectTrialHistory above).
476
713
  //
477
- // NON-REGRESSION (plan §7). With no `trials.jsonl` on disk this falls back to exactly today's
714
+ // NON-REGRESSION. With no `trials.jsonl` on disk this falls back to exactly the pre-ratchet
478
715
  // read — the previous attempt's verdict artifact, AEGIS triples only, byte-for-byte the same
479
716
  // order. Every new arm is skipped when its artifact is absent, per the ✦/✚ convention.
480
717
  let digestedErrors = [];
@@ -508,9 +745,14 @@ if (isMainModule) {
508
745
  if (!payloadExtra.feature) payloadExtra.feature = slug;
509
746
 
510
747
  const order = compileOrder({
511
- slug, worker, operation, round, attempt, scope, tasks, decisions, digestedErrors, trialHistory,
748
+ slug, worker, operation, round, attempt, scope, tasks, decisions, digestedErrors, trialHistory, bugs,
512
749
  testCmd: flag("test-cmd"), payloadExtra, specDir,
513
750
  interaction: has("pause-gates") ? { pause_gates: true } : { pause_gates: false },
751
+ // Read off the receipt rather than passed in: a standalone `compile-order` invocation gets the
752
+ // same key as one the workflow drove, and a dispatch in a workspace with no open run simply
753
+ // carries no key instead of failing — the analytic field must never be able to block a build.
754
+ runId: readRunId(cwd, slug),
755
+ compiledAt: new Date().toISOString(),
514
756
  });
515
757
 
516
758
  const { valid, errors } = validate(order, ORDER_SCHEMA);
@@ -32,10 +32,10 @@
32
32
  // pre-recorded answer") rather than a silent wait that gets reported as a slow harness.
33
33
  //
34
34
  // USAGE
35
- // node gate-answers.mjs --init [--preset ci|guarded|interactive] [--out <path>] [--by "<name>"]
36
- // node gate-answers.mjs --resolve <gate-id> [--file <path>|--preset <name>] [--slug <slug>]
37
- // node gate-answers.mjs --list [--file <path>|--preset <name>]
38
- // node gate-answers.mjs --verify [--file <path>|--preset <name>] [--auto-level unattended]
35
+ // node `harness gate` --init [--preset ci|guarded|interactive] [--out <path>] [--by "<name>"]
36
+ // node `harness gate` --resolve <gate-id> [--file <path>|--preset <name>] [--slug <slug>]
37
+ // node `harness gate` --list [--file <path>|--preset <name>]
38
+ // node `harness gate` --verify [--file <path>|--preset <name>] [--auto-level unattended]
39
39
  //
40
40
  // RESOLUTION ORDER for --resolve / --list / --verify, first hit wins:
41
41
  // 1. --file <path>
@@ -50,11 +50,10 @@
50
50
  // 5 no answer and on_missing=abort → the run aborts here, attributably
51
51
  // 2 usage / validation error
52
52
 
53
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
53
+ import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync } from "node:fs";
54
54
  import { join, dirname } from "node:path";
55
- import { isMain } from "./lib/is-main.mjs";
56
55
  import { runArgs } from "./lib/argv.mjs";
57
- import { gateAnswerCandidates, LOCAL } from "./lib/paths.mjs";
56
+ import { gateAnswerCandidates, gates as gatesPath, LOCAL } from "./lib/paths.mjs";
58
57
 
59
58
  export const GATE_IDS = ["L0", "L1a", "L1a.5", "L1b", "L2", "L3", "QA", "H", "L4", "COACH-1"];
60
59
 
@@ -204,6 +203,33 @@ export function resolve(set, gate, source) {
204
203
  };
205
204
  }
206
205
 
206
+ /**
207
+ * Append one row to the per-slug gate-crossing ledger — `.shapeup/<slug>/gates.jsonl` ({@link
208
+ * gatesPath}, from `lib/paths.mjs`).
209
+ *
210
+ * WRITER RULE, same shape as {@link appendTrial} in `verify/t0.mjs` and the dispatch-receipt hook's
211
+ * ownership of `decisions.jsonl`: one small file, one writer, append-only JSONL so concurrent
212
+ * scopes crossing gates in the same round cannot clobber each other's row.
213
+ *
214
+ * Records every resolution that reached a real status — a crossing (`ok`), a recorded pause
215
+ * (`ask`), or an abort (`abort`) — because "the run stopped here" is exactly the kind of fact this
216
+ * ledger exists to hold. Best-effort: a ledger write that could fail a gate crossing would cost more
217
+ * than the row is worth, matching {@link appendTrial}'s own trade-off.
218
+ *
219
+ * @param {string} cwd - Project root.
220
+ * @param {string} slug - Feature slug.
221
+ * @param {object} row - The ledger row (gate, decision, source, note, round, status).
222
+ * @returns {boolean} True when the row was written.
223
+ */
224
+ export function appendGateLedger(cwd, slug, row) {
225
+ try {
226
+ const p = gatesPath(cwd, slug);
227
+ mkdirSync(dirname(p), { recursive: true });
228
+ appendFileSync(p, JSON.stringify(row) + "\n");
229
+ return true;
230
+ } catch { return false; }
231
+ }
232
+
207
233
  /** Gates this lane will actually hit — used by --verify to catch a set that stalls halfway. */
208
234
  export function requiredGates({ autoLevel = "unattended", tiny = false, qa = true } = {}) {
209
235
  if (tiny) return ["L0", "L4"];
@@ -239,9 +265,9 @@ export function discover({ cwd = process.cwd(), file = null, preset = null, slug
239
265
 
240
266
  /** The typed argv contract (see `./lib/argv.mjs`). */
241
267
  export const ARGV_SPEC = {
242
- usage: "gate-answers.mjs (--init | --list | --verify | --resolve <gate-id>) [--preset <name>] " +
268
+ usage: "harness.mjs gate (--init | --list | --verify | --resolve <gate-id>) [--preset <name>] " +
243
269
  "[--file <path>] [--slug <slug>] [--cwd <dir>] [--out <path>] [--by <who>] " +
244
- "[--auto-level <level>] [--tiny] [--no-qa]",
270
+ "[--auto-level <level>] [--tiny] [--no-qa] [--round <n>]",
245
271
  _: { arity: 0, max: 0, name: "(no positional operands)" },
246
272
  cwd: { type: "path" },
247
273
  init: { type: "flag" },
@@ -256,6 +282,10 @@ export const ARGV_SPEC = {
256
282
  "auto-level": { type: "str" },
257
283
  tiny: { type: "flag" },
258
284
  "no-qa": { type: "flag" },
285
+ // The current BUILD round, when the caller has one — L2/L3 are crossed once per round, so the
286
+ // ledger row needs it to key a `GateDecision` node uniquely per crossing. Round-independent gates
287
+ // (L0, L1a, …) simply omit it and the row carries `round: null`.
288
+ round: { type: "int", min: 1 },
259
289
  };
260
290
 
261
291
  function out(obj, code = 0) {
@@ -267,8 +297,15 @@ function die(msg, code = 2) {
267
297
  process.exit(code);
268
298
  }
269
299
 
270
- export function main() {
271
- const args = runArgs(ARGV_SPEC);
300
+ /**
301
+ * Resolve, verify, list or initialise the gate answer set.
302
+ *
303
+ * @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
304
+ * @returns {(Promise<void>|void)} Settles when the subcommand has written its output; most paths
305
+ * call `process.exit()` with the subcommand's documented code rather than returning.
306
+ */
307
+ export function cli(rawArgv) {
308
+ const args = runArgs(ARGV_SPEC, rawArgv);
272
309
  const cwd = args.cwd || process.cwd();
273
310
 
274
311
  if (args.init) {
@@ -327,11 +364,18 @@ export function main() {
327
364
 
328
365
  const r = resolve(found.set, gate, found.source);
329
366
  if (r.status === "error") die(r.reason);
367
+ // A gate with no `--slug` (e.g. `--file` used ad hoc, outside any run) has nowhere to file a
368
+ // per-run ledger row — the same reasoning `resolveRunId` uses for "no run is active": absence is
369
+ // the correct answer, not an error, so the write is skipped rather than guessing a location.
370
+ if (args.slug) {
371
+ appendGateLedger(cwd, args.slug, {
372
+ gate: r.gate, status: r.status, decision: r.decision ?? null,
373
+ source: r.source ?? found.source, note: r.note ?? r.reason ?? null,
374
+ round: args.round ?? null,
375
+ });
376
+ }
330
377
  if (r.status === "ask") out({ ...r, ok: false }, 4);
331
378
  if (r.status === "abort") out({ ...r, ok: false }, 5);
332
379
  out({ ...r, ok: true }, 0);
333
380
  }
334
381
 
335
- if (isMain(import.meta.url)) {
336
- main();
337
- }