shapeup-sdlc 1.6.3 → 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 (118) hide show
  1. package/.claude/settings.local.example.json +5 -5
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/AGENTS.md +54 -107
  4. package/README.md +165 -151
  5. package/SECURITY.md +49 -27
  6. package/bin/init.mjs +93 -108
  7. package/bin/lib/grant.mjs +145 -0
  8. package/commands/build.md +20 -0
  9. package/commands/eval.md +5 -4
  10. package/commands/scopes.md +5 -4
  11. package/commands/shape.md +1 -1
  12. package/commands/ship.md +53 -7
  13. package/commands/wire.md +1 -1
  14. package/hooks/dispatch-receipt.mjs +195 -0
  15. package/hooks/gate-intake.mjs +16 -17
  16. package/hooks/gate-zerowork.mjs +107 -25
  17. package/hooks/hooks.json +9 -48
  18. package/hooks/lib/decision.mjs +38 -19
  19. package/hooks/safety-spine.mjs +4 -4
  20. package/hooks/sandbox-guard.mjs +130 -50
  21. package/{skills/tech-lead/scripts/compile-order.mjs → kernel/compile.mjs} +344 -55
  22. package/{skills/tech-lead/scripts/gate-answers.mjs → kernel/gate.mjs} +69 -26
  23. package/kernel/harness.mjs +134 -0
  24. package/{skills/tech-lead/scripts/fit-check.mjs → kernel/init/fit.mjs} +25 -22
  25. package/kernel/init/run.mjs +489 -0
  26. package/{skills/tech-lead/scripts → kernel}/lib/argv.mjs +52 -31
  27. package/{skills/tech-lead/scripts/lib/contract-md.mjs → kernel/lib/contract.mjs} +261 -17
  28. package/kernel/lib/paths.mjs +491 -0
  29. package/kernel/probe/concurrency.mjs +510 -0
  30. package/{skills/tech-lead/scripts/aegis-digest.mjs → kernel/probe/digest.mjs} +10 -11
  31. package/kernel/probe/eval.mjs +77 -0
  32. package/kernel/probe/leg.mjs +125 -0
  33. package/kernel/probe/resume.mjs +528 -0
  34. package/{skills/tech-lead/scripts → kernel/probe}/stats.mjs +102 -21
  35. package/kernel/probe/t0.mjs +66 -0
  36. package/{skills/ba-pitch-analyzer/scripts/board-derive.mjs → kernel/reduce/board.mjs} +104 -28
  37. package/kernel/reduce/graph.mjs +437 -0
  38. package/kernel/reduce/hill.mjs +152 -0
  39. package/kernel/reduce/ingest.mjs +633 -0
  40. package/{hooks/slop-cleaner.mjs → kernel/reduce/leftovers.mjs} +40 -57
  41. package/{skills/tech-lead/scripts/ship-report.mjs → kernel/reduce/ship.mjs} +93 -14
  42. package/{skills/tech-lead/scripts/run-snapshot.mjs → kernel/reduce/snapshot.mjs} +22 -21
  43. package/{skills/spec-evaluator/scripts/verdict-ledger.mjs → kernel/reduce/verdict.mjs} +14 -8
  44. package/kernel/report/export.mjs +325 -0
  45. package/kernel/report/facts.mjs +347 -0
  46. package/{skills/tech-lead/scripts/budget-check.mjs → kernel/verify/budget.mjs} +22 -25
  47. package/kernel/verify/dispatch.mjs +114 -0
  48. package/{skills/tech-lead/scripts/validate-envelope.mjs → kernel/verify/envelope.mjs} +20 -15
  49. package/{skills/tech-lead/scripts/lib → kernel/verify}/ratchet-tree.mjs +36 -14
  50. package/kernel/verify/skills.mjs +125 -0
  51. package/kernel/verify/spec.mjs +559 -0
  52. package/{skills/tech-lead/scripts/t0-verify.mjs → kernel/verify/t0.mjs} +138 -28
  53. package/{skills/tech-lead/scripts/trace-lint.mjs → kernel/verify/trace.mjs} +28 -13
  54. package/oracles/_shared.mjs +1 -1
  55. package/oracles/http-oracle.mjs +2 -2
  56. package/oracles/index.mjs +1 -1
  57. package/oracles/process-oracle.mjs +2 -2
  58. package/oracles/snapshot-oracle.mjs +2 -2
  59. package/oracles/test-oracle.mjs +2 -2
  60. package/package.json +11 -13
  61. package/skills/ba-pitch-analyzer/SKILL.md +24 -19
  62. package/skills/ba-pitch-analyzer/assets/templates/_index.tmpl.md +1 -1
  63. package/skills/ba-pitch-analyzer/assets/templates/api-feasibility.tmpl.md +12 -7
  64. package/skills/ba-pitch-analyzer/assets/templates/contracts/third-party-api.contract.tmpl.md +6 -3
  65. package/skills/ba-pitch-analyzer/assets/templates/cross-context/migration-plan.tmpl.md +5 -4
  66. package/skills/ba-pitch-analyzer/assets/templates/cross-context/team-handoff.tmpl.md +23 -13
  67. package/skills/ba-pitch-analyzer/assets/templates/scope-summary.tmpl.md +17 -12
  68. package/skills/ba-pitch-analyzer/assets/templates/synthesis.tmpl.md +17 -38
  69. package/skills/ba-pitch-analyzer/assets/templates/task.tmpl.md +3 -0
  70. package/skills/ba-pitch-analyzer/references/contract-patterns.md +8 -4
  71. package/skills/ba-pitch-analyzer/references/doc-schemas.md +3 -1
  72. package/skills/ba-pitch-analyzer/references/task-generation.md +8 -8
  73. package/skills/ba-pitch-analyzer/references/test-surface.md +1 -1
  74. package/skills/coach/SKILL.md +3 -3
  75. package/skills/orient/SKILL.md +2 -1
  76. package/skills/qa-edge-hunter/SKILL.md +15 -41
  77. package/skills/scope-architect/SKILL.md +57 -18
  78. package/skills/scope-hammer/SKILL.md +5 -5
  79. package/skills/shapeup/SKILL.md +3 -3
  80. package/skills/shapeup/resources/context-compaction.md +4 -3
  81. package/skills/solution-architect/SKILL.md +37 -15
  82. package/skills/spec-evaluator/SKILL.md +24 -7
  83. package/skills/spec-evaluator/references/dimensions/_registry.md +2 -2
  84. package/skills/spec-evaluator/references/dimensions/completeness.md +1 -1
  85. package/skills/spec-evaluator/references/dimensions/integration.md +98 -67
  86. package/skills/spec-evaluator/references/dimensions/spec-conformance.md +4 -4
  87. package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +1 -1
  88. package/skills/spec-evaluator/references/probing.md +42 -23
  89. package/skills/spec-evaluator/references/report-schema.md +2 -2
  90. package/skills/task-executor/SKILL.md +32 -18
  91. package/skills/tech-lead/SKILL.md +127 -438
  92. package/skills/tech-lead/references/gates.md +140 -49
  93. package/skills/tech-lead/references/protocol.md +832 -0
  94. package/skills/tech-lead/schemas/domain.schema.json +645 -198
  95. package/skills/tech-lead/schemas/gate-answers.schema.json +2 -2
  96. package/skills/tech-lead/schemas/work-order.schema.json +11 -2
  97. package/skills/tech-lead/schemas/work-result.schema.json +56 -18
  98. package/skills/tech-lead/workflows/shapeup-run.js +1474 -0
  99. package/skills/translator/SKILL.md +2 -2
  100. package/hooks/anti-rationalization.mjs +0 -244
  101. package/hooks/compact-snapshot.mjs +0 -47
  102. package/hooks/gate-deadline.mjs +0 -151
  103. package/hooks/gate-l2.mjs +0 -161
  104. package/hooks/session-rehydrate.mjs +0 -109
  105. package/skills/advisor-protocol/SKILL.md +0 -171
  106. package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +0 -239
  107. package/skills/spec-evaluator/README.md +0 -93
  108. package/skills/tech-lead/README.md +0 -71
  109. package/skills/tech-lead/references/delegation.md +0 -254
  110. package/skills/tech-lead/references/invocation.md +0 -45
  111. package/skills/tech-lead/references/ledger-schema.md +0 -214
  112. package/skills/tech-lead/references/round-protocol.md +0 -184
  113. package/skills/tech-lead/references/state-model.md +0 -66
  114. package/skills/tech-lead/scripts/ingest-result.mjs +0 -270
  115. package/skills/tech-lead/scripts/init-run.mjs +0 -326
  116. package/skills/tech-lead/scripts/lib/is-main.mjs +0 -82
  117. package/skills/tech-lead/scripts/lib/paths.mjs +0 -278
  118. package/skills/translator/README.md +0 -66
@@ -4,7 +4,7 @@
4
4
  // The orchestrator's pipeline sub-layer: assembles the structured input envelope a worker is
5
5
  // dispatched with. Replaces tech-lead's hand-assembled `isolated_brief()` prose step and every
6
6
  // worker's GATE A/B plumbing (path resolution, run-state parse, board glob-matching, dependency
7
- // reads, mode detection). Deterministic, zero LLM tokens (DD-7).
7
+ // reads, mode detection). Deterministic, zero LLM tokens.
8
8
  //
9
9
  // A worker depends on its ORDER, never on filesystem topology — moving a directory again
10
10
  // (the v3.2 lesson) touches this script, zero skills.
@@ -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,20 +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";
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";
42
47
 
43
48
  const HERE = dirname(fileURLToPath(import.meta.url));
44
- 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"));
45
50
 
46
51
  // --- tiny frontmatter reader (scalar keys + [a, b] inline lists) --------------------------
47
52
  /**
@@ -71,7 +76,7 @@ export function frontmatter(md) {
71
76
  * Parse one TASK-NNN.md file into the task entry a WorkOrder carries.
72
77
  * @param {string} path - Absolute path to the task Markdown file.
73
78
  * @returns {{id:string, title:string, status:string, priority:number, depends_on:string[],
74
- * 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,
75
80
  * covers:string[]}>}} The task entry. Each acceptance criterion is the checkbox text
76
81
  * byte-identical (so ingest can tick it back); a trailing `(covers: REQ-…)` clause yields
77
82
  * {text, covers} instead of a plain string. `priority` defaults to 999 when unset.
@@ -98,6 +103,7 @@ export function parseTaskFile(path) {
98
103
  priority: Number(fm.priority) || 999,
99
104
  depends_on: Array.isArray(fm.depends_on) ? fm.depends_on : [],
100
105
  use_case_refs: Array.isArray(fm.use_case_refs) ? fm.use_case_refs : [],
106
+ scope_id: fm.scope_id || "",
101
107
  body_path: path,
102
108
  acceptance_criteria,
103
109
  };
@@ -143,11 +149,30 @@ export function ledgerDecisions(ledgerText, scopeId) {
143
149
  return decisions;
144
150
  }
145
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
+
146
171
  /**
147
172
  * Resolve the write-contract (sandbox substrate) for an operation — one whitelist template per
148
- * operation, so mode/flag differences are enforced by the sandbox hook, not trusted to prose.
149
- * @param {string} operation - The order's operation (execute|analyze|generate-board|reconcile|
150
- * retrofit-surface|coverage|map-scopes|remap|split-scope|wire|evaluate|hunt|recheck|orient|…).
173
+ * operation, so mode/flag differences are enforced by the sandbox hook reading the order's substrate, not trusted to prose.
174
+ * @param {string} operation - The order's operation (execute|fix|spike|analyze|reconcile|
175
+ * retrofit-surface|coverage|map-scopes|wire|evaluate|orient|hunt|translate|hammer|coach).
151
176
  * @param {{slug?:string, specDir?:string, scope?:object}} [ctx] - slug (names LOCAL/SHARED roots),
152
177
  * specDir (overrides the default spec path), scope (contract supplying allowed/shared substrates).
153
178
  * @returns {{allowed:string[], shared?:string[], frozen?:string[], append_only?:string[]}} The
@@ -171,11 +196,7 @@ export function substrateFor(operation, { slug, specDir, scope } = {}) {
171
196
  };
172
197
  case "analyze":
173
198
  return { allowed: [`${spec}/**`, `${local}/**`], frozen: [] };
174
- case "generate-board":
175
- return {
176
- allowed: [`${local}/tasks/**`, `${spec}/scope-summary.md`, `${working}/**`],
177
- frozen: [...FROZEN_SPEC_CORE, `${scopesDir}/**`],
178
- };
199
+
179
200
  case "reconcile":
180
201
  return {
181
202
  allowed: [`${local}/tasks/**`, `${spec}/scope-summary.md`, `${working}/**`],
@@ -184,14 +205,14 @@ export function substrateFor(operation, { slug, specDir, scope } = {}) {
184
205
  };
185
206
  case "retrofit-surface":
186
207
  return { allowed: [], append_only: [`${spec}/usecases/*.md#Test Surface`], frozen: FROZEN_SPEC_CORE };
208
+
187
209
  case "coverage":
188
- // ba-pitch-analyzer writes the SHARED requirement registry only the REQ source and the
189
- // spec core stay frozen (the registry is a separate derived file, never an edit of the source).
190
- return {
191
- allowed: [globShared(slug, "requirements.md")],
192
- frozen: [...FROZEN_SPEC_CORE, `${scopesDir}/**`, `${local}/tasks/**`],
193
- };
194
- case "map-scopes": case "remap": case "split-scope":
210
+ // The covers-closure input truth. Writes ONLY the derived registry: the REQ source it
211
+ // extracts from is frozen alongside the spec core, because a planner that may edit the
212
+ // requirements it is being measured against is not measuring anything.
213
+ return { allowed: [globShared(slug, "requirements.md")], frozen: FROZEN_SPEC_CORE };
214
+
215
+ case "map-scopes":
195
216
  return {
196
217
  allowed: [`${scopesDir}/*.md`, globShared(slug, "scope-board.md")],
197
218
  frozen: [...FROZEN_SPEC_CORE, `${local}/tasks/**`],
@@ -205,10 +226,16 @@ export function substrateFor(operation, { slug, specDir, scope } = {}) {
205
226
  };
206
227
  case "evaluate":
207
228
  return { allowed: [`${local}/evaluation/**`], frozen: [`${spec}/**`, `${local}/tasks/**`] };
208
- case "hunt": case "recheck":
229
+ case "hunt":
209
230
  return { allowed: [`${local}/qa/**`], frozen: [`${spec}/**`, `${local}/tasks/**`] };
210
231
  case "orient":
211
232
  return { allowed: [`${local}/orient/**`], frozen: [`${spec}/**`] };
233
+ case "translate":
234
+ return { allowed: [globShared(slug, "shaping/*.md"), globShared(slug, "glossary.md")] };
235
+ case "hammer":
236
+ return { allowed: [globShared(slug, "REPORT.md"), `${local}/reports/**`] };
237
+ case "coach":
238
+ return { allowed: [relKnowledgeBase("*")] };
212
239
  default:
213
240
  return { allowed: [`${local}/**`] };
214
241
  }
@@ -278,7 +305,7 @@ export function selectTrialHistory(trials, { scopeId, round } = {}) {
278
305
  }
279
306
 
280
307
  /**
281
- * The stagnation term of the inner circuit breaker (plan §2.6).
308
+ * The stagnation term of the inner circuit breaker.
282
309
  *
283
310
  * `attempt_budget` counts attempts; it cannot see that the last two produced nothing. One term
284
311
  * joins it: `no_progress_k` consecutive non-`kept` trials ends the scope early and queues the
@@ -299,6 +326,162 @@ export function stagnation(trials, k = 2) {
299
326
  return { stagnant: streak >= k && k > 0, streak, k };
300
327
  }
301
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
+
302
485
  /**
303
486
  * Assemble a WorkOrder envelope. Pure given its inputs — the CLI wrapper does the disk reads.
304
487
  * @param {object} opts - The order inputs (destructured):
@@ -313,22 +496,74 @@ export function stagnation(trials, k = 2) {
313
496
  * @param {Array<{id:string,answer:string}>} [opts.decisions] - This scope's advisor answers.
314
497
  * @param {Array<object>} [opts.digestedErrors] - Prior-attempt AEGIS triples (payload.digested_errors).
315
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).
316
500
  * @param {string} [opts.testCmd] - Verify command, recorded under payload.verify.test_cmd.
317
501
  * @param {object} [opts.payloadExtra] - Extra payload fields merged last (spec_folder, feature, …).
318
502
  * @param {string} [opts.specDir] - Spec directory, threaded into the substrate template.
319
503
  * @param {object} [opts.interaction] - Interaction flags (e.g. {pause_gates}).
320
- * @returns {object} A WorkOrder: {schema_version, order_id ("<slug>/<suffix>"), worker, mode,
321
- * operation?, interaction?, substrate (from {@link substrateFor}), payload{…}}. A coachable
322
- * 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.
323
510
  */
324
511
  export function compileOrder({
325
512
  slug, worker, mode = "orchestrated", operation, round, attempt,
326
- scope, tasks, decisions, digestedErrors, trialHistory, testCmd, payloadExtra, specDir, interaction,
513
+ scope, tasks, decisions, digestedErrors, trialHistory, bugs, testCmd, payloadExtra, specDir, interaction,
514
+ runId, compiledAt,
327
515
  }) {
328
- const suffix = round && attempt ? `r${round}-a${attempt}` : round ? `${operation}-r${round}` : operation;
516
+ // A build order's id carries its SCOPE. Without it, `r<round>-a<attempt>` is the same name for
517
+ // every scope in a round, so scope 2's order file overwrites scope 1's the moment it is compiled
518
+ // — measured on the kill/resume probe, where it made `orders/ minus results/` read EMPTY on a run
519
+ // that was re-dispatching a completed phase. The contract row watching that property therefore
520
+ // passed on the exact failure it exists to catch. ``harness verify t0``'s verdict artifacts have always
521
+ // been self-identifying this way (`r<R>-a<A>-t<T>.json`, `wx`-created); orders now match, so
522
+ // `orders/` is an audit trail of dispatches rather than a rolling buffer of the last one.
523
+ //
524
+ // The shape stays `<slug>/<suffix>`: every consumer splits on the FIRST "/" (`harness reduce ingest`
525
+ // reads [0] as the slug and [1] as the file stem), and a scope id is already filename-safe
526
+ // because it is a scope contract's own basename.
527
+ // `scope` arrives as the PARSED contract object (the CLI reads the .md and embeds it), so the id
528
+ // comes from its own `scope_id` field; a caller that passes a path instead still works. The id is
529
+ // lowercased and stripped to the character class `work-order.schema.json` allows after the "/" —
530
+ // an order that cannot pass its own schema is refused before it is written, and a naming
531
+ // improvement must not be able to cause that.
532
+ const rawScopeId = scope && typeof scope === "object"
533
+ ? scope.scope_id
534
+ : (scope ? String(scope).split("/").pop().replace(/\.(md|json)$/, "") : null);
535
+ const scopeId = String(rawScopeId || "").toLowerCase().replace(/[^a-z0-9.-]/g, "-").replace(/^[^a-z0-9]+/, "");
536
+ const buildSuffix = round && attempt ? (scopeId ? `${scopeId}-r${round}-a${attempt}` : `r${round}-a${attempt}`) : null;
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;
329
549
  const order = {
330
550
  schema_version: 1,
331
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 } : {}),
332
567
  worker,
333
568
  mode,
334
569
  ...(operation ? { operation } : {}),
@@ -340,6 +575,9 @@ export function compileOrder({
340
575
  ...(decisions?.length ? { decisions } : {}),
341
576
  ...(digestedErrors?.length ? { digested_errors: digestedErrors } : {}),
342
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 } : {}),
343
581
  ...(testCmd ? { verify: { test_cmd: testCmd, env: [] } } : {}),
344
582
  ...(payloadExtra || {}),
345
583
  },
@@ -352,7 +590,7 @@ export function compileOrder({
352
590
  // ---------------------------------------------------------------------------
353
591
  /** The typed argv contract (see `./lib/argv.mjs`). */
354
592
  export const ARGV_SPEC = {
355
- 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> " +
356
594
  "| --next --slug <slug> | --operation <op> --slug <slug>) [--worker <skill>] [--payload '<json>'] " +
357
595
  "[--spec <dir>] [--test-cmd \"<cmd>\"] [--cwd <dir>] [--pause-gates]",
358
596
  _: { arity: 0, max: 0, name: "(no positional operands)" },
@@ -371,9 +609,15 @@ export const ARGV_SPEC = {
371
609
  "pause-gates": { type: "flag" },
372
610
  };
373
611
 
374
- const isMainModule = isMain(import.meta.url);
375
- if (isMainModule) {
376
- 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);
377
621
  /**
378
622
  * Read a parsed flag's value, normalising "absent" to null (the shape the body below expects).
379
623
  * @param {string} name - Flag name without leading dashes.
@@ -409,17 +653,26 @@ if (isMainModule) {
409
653
 
410
654
  const round = flag("round");
411
655
  const attempt = flag("attempt");
412
- // Operation → owning worker (mirrors domain.schema.json $defs/Operation ownership). Lets a
413
- // non-build dispatch resolve its worker from the operation alone, without a redundant --worker.
414
- const OP_OWNER = {
415
- analyze: "ba-pitch-analyzer", "generate-board": "ba-pitch-analyzer", reconcile: "ba-pitch-analyzer",
416
- "retrofit-surface": "ba-pitch-analyzer", coverage: "ba-pitch-analyzer",
417
- "map-scopes": "scope-architect", remap: "scope-architect", "split-scope": "scope-architect",
418
- wire: "solution-architect", evaluate: "spec-evaluator", orient: "orient",
419
- hunt: "qa-edge-hunter", recheck: "qa-edge-hunter", translate: "translator",
420
- hammer: "scope-hammer", coach: "coach", adjudicate: "advisor-protocol",
421
- };
422
- 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);
423
676
  const worker = flag("worker")
424
677
  || (scopePath || flag("task") || has("next") ? "task-executor" : null)
425
678
  || (operation ? OP_OWNER[operation] : null);
@@ -436,10 +689,18 @@ if (isMainModule) {
436
689
  tasks = board.filter((t) => t.status === "ready" && t.depends_on.every((d) => doneIds.has(d))).slice(0, 1);
437
690
  if (!tasks.length) { console.error("compile-order: no ready task with satisfied dependencies"); process.exit(2); }
438
691
  } else if (scope) {
439
- // Scope attempt: the scope's own task list if the contract names one, else every
440
- // not-done task on the board (the attempt loop owns sequencing, not the worker).
441
- const named = new Set(scope.tasks || []);
442
- 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");
443
704
  }
444
705
 
445
706
  // Ledger decisions for this scope.
@@ -450,7 +711,7 @@ if (isMainModule) {
450
711
 
451
712
  // inspect() — the attempt loop's history (see selectTrialHistory above).
452
713
  //
453
- // 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
454
715
  // read — the previous attempt's verdict artifact, AEGIS triples only, byte-for-byte the same
455
716
  // order. Every new arm is skipped when its artifact is absent, per the ✦/✚ convention.
456
717
  let digestedErrors = [];
@@ -484,9 +745,14 @@ if (isMainModule) {
484
745
  if (!payloadExtra.feature) payloadExtra.feature = slug;
485
746
 
486
747
  const order = compileOrder({
487
- slug, worker, operation, round, attempt, scope, tasks, decisions, digestedErrors, trialHistory,
748
+ slug, worker, operation, round, attempt, scope, tasks, decisions, digestedErrors, trialHistory, bugs,
488
749
  testCmd: flag("test-cmd"), payloadExtra, specDir,
489
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(),
490
756
  });
491
757
 
492
758
  const { valid, errors } = validate(order, ORDER_SCHEMA);
@@ -501,6 +767,29 @@ if (isMainModule) {
501
767
  writeFileSync(outPath, JSON.stringify(order, null, 2) + "\n");
502
768
  console.log(outPath);
503
769
 
770
+ // POINT THE SANDBOX AT THIS ORDER, HERE, because this is the only place every lane passes
771
+ // through.
772
+ //
773
+ // `hooks/sandbox-guard.mjs` enforces the order's own `substrate` block — allowed/shared,
774
+ // append_only, frozen — and it finds the order through `.shapeup/active-order`. Until this
775
+ // write existed the pointer had exactly one author, the workflow script, so the guard fenced
776
+ // the workflow lane and DEFERRED everywhere else: `--tiny`, the prose round loop, and a
777
+ // standalone `/build` all compiled an order carrying a write contract that nothing enforced.
778
+ // A substrate that is only enforced on the lane that also happens to be the most supervised
779
+ // one is the wrong way round.
780
+ //
781
+ // Compiling an order is the moment the write contract comes into existence, so it is the
782
+ // correct moment to publish it. The workflow script still sets the pointer explicitly before
783
+ // dispatch (it interleaves phases and must be exact about which order is live); this write
784
+ // makes the SAME mechanism cover callers that never reach that code.
785
+ //
786
+ // Best-effort, on stderr, and never fatal: a compiled order that cannot publish its pointer is
787
+ // still a valid order, and stdout belongs to the order path the caller consumes. The guard
788
+ // fails open on a missing pointer by design, so the failure mode is "unfenced", which is
789
+ // exactly what a warning is for.
790
+ const ptr = writeActiveOrder(cwd, slug, outPath);
791
+ if (!ptr.ok) console.error(`compile-order: warning — ${ptr.reason} (this order's substrate will not be enforced)`);
792
+
504
793
  // The stagnation breaker reports on stderr, never on stdout: stdout is the order path the
505
794
  // orchestrator consumes, and a breaker that corrupts the pipeline's own output would be worse
506
795
  // than the flailing it detects. It advises; the orchestrator queues the GATE H proposal.