shapeup-sdlc 3.18.2 → 4.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 (141) hide show
  1. package/.claude/settings.local.example.json +2 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/AGENTS.md +115 -56
  4. package/README.md +101 -143
  5. package/SECURITY.md +16 -25
  6. package/agents/orient.md +14 -0
  7. package/agents/qa-edge-hunter.md +13 -0
  8. package/agents/spec-evaluator.md +14 -0
  9. package/agents/task-executor.md +14 -0
  10. package/bin/init.mjs +13 -16
  11. package/bin/lib/grant.mjs +8 -19
  12. package/commands/build.md +9 -14
  13. package/commands/eval.md +3 -3
  14. package/commands/hammer.md +9 -5
  15. package/commands/retro.md +12 -11
  16. package/commands/scopes.md +1 -12
  17. package/commands/ship.md +45 -88
  18. package/commands/wire.md +1 -8
  19. package/hooks/dispatch-receipt.mjs +94 -8
  20. package/hooks/hooks.json +0 -41
  21. package/hooks/lib/decision.mjs +6 -9
  22. package/hooks/lib/tier.mjs +50 -0
  23. package/hooks/sandbox-guard.mjs +140 -64
  24. package/kernel/gate.mjs +203 -22
  25. package/kernel/harness.mjs +20 -67
  26. package/kernel/init/run.mjs +175 -523
  27. package/kernel/lib/contract.mjs +9 -93
  28. package/kernel/lib/floor.mjs +64 -0
  29. package/kernel/lib/paths.mjs +39 -112
  30. package/kernel/order.mjs +157 -0
  31. package/kernel/probe/digest.mjs +2 -38
  32. package/kernel/schemas/domain.schema.json +146 -1125
  33. package/kernel/schemas/work-order.schema.json +8 -4
  34. package/kernel/schemas/work-result.schema.json +1 -1
  35. package/kernel/verify/envelope.mjs +2 -2
  36. package/kernel/verify/skills.mjs +27 -8
  37. package/kernel/verify/t0.mjs +2 -2
  38. package/package.json +4 -2
  39. package/skills/building/SKILL.md +144 -0
  40. package/skills/building/resources/gates.md +76 -0
  41. package/skills/building/resources/guidance.md +23 -0
  42. package/skills/{translator/references/preservation-rules.md → building/resources/intake-preservation.md} +1 -1
  43. package/skills/building/resources/intake.md +157 -0
  44. package/skills/building/resources/orders.md +69 -0
  45. package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/doc-schemas.md +1 -1
  46. package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/task-generation.md +13 -13
  47. package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/test-surface.md +6 -7
  48. package/skills/building/resources/plan-scopes.md +95 -0
  49. package/skills/building/resources/plan-spec.md +102 -0
  50. package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/_index.tmpl.md +1 -1
  51. package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/assess-report.tmpl.md +10 -11
  52. package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/run-state.tmpl.md +4 -4
  53. package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/scope-summary.tmpl.md +2 -2
  54. package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/synthesis.tmpl.md +4 -4
  55. package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/usecase.tmpl.md +2 -2
  56. package/skills/building/resources/plan-wire.md +82 -0
  57. package/skills/building/resources/plan.md +98 -0
  58. package/skills/building/resources/retro-research.md +60 -0
  59. package/skills/building/resources/retro-scan.md +49 -0
  60. package/skills/building/resources/retro.md +225 -0
  61. package/skills/building/resources/rounds.md +78 -0
  62. package/skills/building/resources/stop.md +127 -0
  63. package/skills/orient/SKILL.md +37 -37
  64. package/skills/qa-edge-hunter/SKILL.md +29 -14
  65. package/skills/shapeup/SKILL.md +5 -5
  66. package/skills/shapeup/resources/breadboarding.md +1 -1
  67. package/skills/shapeup/resources/context-compaction.md +5 -5
  68. package/skills/shapeup/resources/shaping.md +1 -1
  69. package/skills/spec-evaluator/SKILL.md +17 -21
  70. package/skills/spec-evaluator/references/dimension-contract.md +1 -1
  71. package/skills/spec-evaluator/references/dimensions/completeness.md +3 -3
  72. package/skills/spec-evaluator/references/dimensions/spec-conformance.md +1 -1
  73. package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +5 -5
  74. package/skills/task-executor/SKILL.md +11 -15
  75. package/commands/hill.md +0 -10
  76. package/hooks/gate-intake.mjs +0 -109
  77. package/hooks/gate-zerowork.mjs +0 -349
  78. package/hooks/tier-guard.mjs +0 -162
  79. package/kernel/compile.mjs +0 -1419
  80. package/kernel/init/fit.mjs +0 -199
  81. package/kernel/init/run-args.mjs +0 -206
  82. package/kernel/lib/breadboard.mjs +0 -165
  83. package/kernel/probe/attempts.mjs +0 -161
  84. package/kernel/probe/concurrency.mjs +0 -535
  85. package/kernel/probe/eval.mjs +0 -405
  86. package/kernel/probe/hunt.mjs +0 -104
  87. package/kernel/probe/leg.mjs +0 -229
  88. package/kernel/probe/owner.mjs +0 -142
  89. package/kernel/probe/requirements.mjs +0 -328
  90. package/kernel/probe/resume.mjs +0 -1110
  91. package/kernel/probe/rounds.mjs +0 -115
  92. package/kernel/probe/staged.mjs +0 -83
  93. package/kernel/probe/stats.mjs +0 -477
  94. package/kernel/probe/t0.mjs +0 -115
  95. package/kernel/reduce/board.mjs +0 -350
  96. package/kernel/reduce/graph.mjs +0 -482
  97. package/kernel/reduce/hill.mjs +0 -322
  98. package/kernel/reduce/ingest.mjs +0 -767
  99. package/kernel/reduce/leftovers.mjs +0 -159
  100. package/kernel/reduce/ship.mjs +0 -529
  101. package/kernel/reduce/snapshot.mjs +0 -291
  102. package/kernel/reduce/verdict.mjs +0 -172
  103. package/kernel/report/export.mjs +0 -384
  104. package/kernel/report/facts.mjs +0 -234
  105. package/kernel/verify/budget.mjs +0 -153
  106. package/kernel/verify/build.mjs +0 -428
  107. package/kernel/verify/dispatch.mjs +0 -114
  108. package/kernel/verify/spec.mjs +0 -975
  109. package/kernel/verify/trace.mjs +0 -604
  110. package/skills/ba-pitch-analyzer/SKILL.md +0 -204
  111. package/skills/coach/SKILL.md +0 -397
  112. package/skills/hill-chart/SKILL.md +0 -154
  113. package/skills/hill-chart/assets/dashboard.template.html +0 -1432
  114. package/skills/scope-architect/SKILL.md +0 -174
  115. package/skills/scope-hammer/SKILL.md +0 -231
  116. package/skills/solution-architect/SKILL.md +0 -158
  117. package/skills/tech-lead/SKILL.md +0 -151
  118. package/skills/tech-lead/references/gates.md +0 -605
  119. package/skills/tech-lead/references/protocol.md +0 -889
  120. package/skills/tech-lead/references/tiny-lane.md +0 -53
  121. package/skills/tech-lead/workflows/shapeup-run.js +0 -2187
  122. package/skills/translator/SKILL.md +0 -258
  123. /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/contract-patterns.md +0 -0
  124. /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/ddd-patterns.md +0 -0
  125. /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/integration-analysis.md +0 -0
  126. /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/ux-behavior-patterns.md +0 -0
  127. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/api-feasibility.tmpl.md +0 -0
  128. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/be-service.contract.tmpl.md +0 -0
  129. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/offline-storage.contract.tmpl.md +0 -0
  130. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/third-party-api.contract.tmpl.md +0 -0
  131. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/context-map.tmpl.md +0 -0
  132. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/event-choreography.tmpl.md +0 -0
  133. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/migration-plan.tmpl.md +0 -0
  134. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/team-handoff.tmpl.md +0 -0
  135. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/domain-model.tmpl.md +0 -0
  136. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/feedback.tmpl.md +0 -0
  137. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/integration.tmpl.md +0 -0
  138. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task-board.tmpl.md +0 -0
  139. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task-spike.tmpl.md +0 -0
  140. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task.tmpl.md +0 -0
  141. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/ux-behavior.tmpl.md +0 -0
@@ -1,126 +1,51 @@
1
1
  #!/usr/bin/env node
2
- // GATE L0.1 — START THE RUN. The orchestrator's first tool call, before any prose.
2
+ // harness init run — open a run, or refuse.
3
3
  //
4
- // WHY THIS EXISTS (reproduced, not theorized).
4
+ // WHAT IT IS FOR. Whether a run has started has to be a fact on disk, written at t=0, because the
5
+ // other evidence (artifacts that appear later) cannot tell a run that never started from one that
6
+ // described itself and stopped. Opening a run writes four things and stages nothing in the project:
5
7
  //
6
- // Dispatched with a valid spec, the orchestrator did this:
8
+ // receipt.json this run started, with THIS intake, at THIS time, under THIS plugin copy; carries
9
+ // the run id every later record (orders, trials, hook decisions) is keyed by
10
+ // run-args.json the launch record: the models, budgets and answer set the run was opened with
11
+ // intake.md the intake verbatim, so "the spec was dropped on the hand-off" is checkable
12
+ // active-scope the pointer every hook reads to answer "is a run in flight?"
7
13
  //
8
- // TOOL Skill(tech-lead, "--unattended --rounds 3\n\n# the requirement…")
9
- // TEXT "The tech-lead skill is orchestrating the full Shape Up harness. It will: 1. …"
10
- // FINAL (same text — session ends)
11
- //
12
- // It loaded an instruction file describing eleven gates and returned a description of eleven
13
- // gates. No code, no board, no gate artifacts — and prose that reads exactly like a successful
14
- // run, with every defect still in the deliverable.
15
- //
16
- // Two guards existed and neither could see it:
17
- // • `gate-intake.mjs` (L0.0) fires on an EMPTY intake. Intake was valid here. Correct no-op.
18
- // • the ship report's census fires when a completion claim contradicts run facts. It is
19
- // scoped to an ACTIVE run — and a run that never started produces none of the files it
20
- // reads — and its claim detector matches past-tense completion ("done", "shipped"), while
21
- // narration is future-tense ("it will"). Two independent misses on the same transcript.
22
- //
23
- // The root cause of BOTH misses is the same: **whether a run had started was not a fact on
24
- // disk.** It was an inference from artifacts that only appear later. So this script exists to
25
- // make starting a run a mechanical event with a receipt, at t=0:
26
- //
27
- // • It is the orchestrator's FIRST action, stated in the first screen of SKILL.md. Everything
28
- // emitted before a tool call is narration surface; this shrinks that surface to zero.
29
- // • It writes `receipt.json` — the fact "this run started, with THIS intake, at THIS time".
30
- // `gate-zerowork.mjs` (Stop) blocks a session that invoked tech-lead and produced no
31
- // receipt. Narration now has a detector that does not depend on what the narration says.
32
- // • It writes `active-scope`, which is the precondition every downstream guard already
33
- // assumed someone had established. Previously that someone was the model, deciding to.
34
- // An invariant that depends on the model choosing to establish it is a prompt, not a gate.
35
- //
36
- // It also HASHES the intake into the receipt. The first (wrong) diagnosis of the failure above was
37
- // that requirement text had been dropped on the hand-off. It had not — but nothing on disk could
38
- // settle that either way. Now it can: the intake that reached the orchestrator is recorded
39
- // verbatim next to its digest, so "the spec was dropped" is checkable, not arguable.
14
+ // It also refuses, before anything is written: a model below the floor for the roles that write code
15
+ // and grade it, and a plugin copy whose worker roster does not resolve. And it records GATE L0 — the
16
+ // intake conversation is that decision, and opening the run is the act that ledgers it.
40
17
  //
41
18
  // USAGE
42
- // node `harness init run` --slug <slug> --intake-file <path> [options] <- prefer this
43
- // node `harness init run` --slug <slug> --intake-text "<requirement>" [options]
44
- // cat spec.md | node `harness init run` --slug <slug> --intake-stdin [options]
19
+ // harness.mjs init run --slug <slug> --intake-file <path> [options]
45
20
  //
46
- // PREFER --intake-file. A multi-line requirement inlined into a shell argument is where this step
47
- // goes wrong: quoting breaks, a `#` after a newline trips path validation, and the run spends six
48
- // turns fighting its own command line instead of starting. Observed, repeatedly.
21
+ // --auto-level interactive|auto|unattended (default interactive)
22
+ // --max-rounds N outer breaker, rounds of build + evaluate (default 3)
23
+ // --attempts N inner breaker, T0 attempts per scope (default 5)
24
+ // --spec-folder <dir> where the committed spec lives (default shapeup/<slug>/spec/)
25
+ // --gate-answers <preset|path> the answer set the gates resolve from
26
+ // --exec-model/--eval-model/--qa-model <m> role -> model (flag, then SHAPEUP_<ROLE>_MODEL, then sonnet)
27
+ // --no-eval --no-qa --parallel-scopes N
28
+ // --force re-open over a run that is already open
49
29
  //
50
- // --auto-level interactive | auto | unattended (default: interactive)
51
- // --lens lite | standard | cross-context (default: standard)
52
- // --max-rounds N outer circuit breaker (default: 3)
53
- // --attempts N inner per-scope T0 budget (default: 5)
54
- // --spec-folder SHARED spec deliverable path (default: shapeup/<slug>/spec/)
55
- // --dimensions comma-separated eval dimensions (default: auto — resolved from the spec per EVAL order)
56
- // --gate-answers path | preset name (see `harness gate`; recorded, not read)
57
- // --breadboard path to the pitch's breadboard (default: found — see resolveBreadboard())
58
- // --wall-clock-budget N deadline breaker, seconds (off by default; see `harness verify budget`)
59
- // --cwd project root (default: process.cwd())
60
- // --force re-init over an existing run receipt
61
- //
62
- // Prints a JSON receipt on stdout. Exit 0 on success, 2 on a usage error, 3 when a live run
63
- // already exists and --force was not given.
64
- //
65
- // EXIT 3 IS THE RESUME PATH, not a dead end. It prints the file-derived RunSnapshot for the run that
66
- // is already open — slug, status, round, attempt, board counts, pending orders — so the orchestrator
67
- // continues from the phase the files report instead of re-opening the run or restarting the pipeline
68
- // from phase 1. It previously said "Resume it (`--from <slug>`)", and `--from` is a /tech-lead flag
69
- // that takes a phase, not an init-run flag that takes a slug: the one instruction available at the
70
- // one moment it mattered named a mechanism that does not parse.
30
+ // Exit 0 opened · 2 usage error · 3 refused (a run is already open, or the roster is missing).
31
+ // A run already open keeps its record: the refusal prints what is on disk and does not overwrite it.
71
32
 
72
- import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, copyFileSync, rmSync, statSync } from "node:fs";
73
- import { discover, resolve as resolveGate, appendGateLedger, PRESETS } from "../gate.mjs";
33
+ import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync } from "node:fs";
74
34
  import { join, dirname, resolve, relative, sep } from "node:path";
75
35
  import { createHash } from "node:crypto";
76
- import { decideLane, treeSize } from "./fit.mjs";
36
+ import { discover, resolve as resolveGate, appendGateLedger, PRESETS } from "../gate.mjs";
77
37
  import { runArgs } from "../lib/argv.mjs";
78
- import { uncoerce, splitFrontmatter } from "../lib/contract.mjs";
79
- import { deriveSnapshot } from "../reduce/snapshot.mjs";
80
- import { mintRunId } from "../lib/paths.mjs";
38
+ import { mintRunId, insideRunTrace } from "../lib/paths.mjs";
39
+ import { launchProblems, floorProblems, AUTO_LEVELS } from "../lib/floor.mjs";
81
40
  import {
82
- localRoot, activeScope, activeOrder, globLocal, globShared, ordersDir, resultsDir,
83
- workflowsStage, globWorkflowsStage, sharedRoot, shapingDir, breadboard as stagedBreadboard, harnessRun,
41
+ localRoot, activeScope, activeOrder, globLocal, globShared, ordersDir, resultsDir, runArgsPath,
84
42
  } from "../lib/paths.mjs";
85
- import { parseBreadboard, hasBreadboardTables, idCounts } from "../lib/breadboard.mjs";
86
43
  import { resolveWorkers } from "../verify/skills.mjs";
87
44
 
88
45
  export const RECEIPT_VERSION = 1;
89
46
 
90
- const AUTO_LEVELS = new Set(["interactive", "auto", "unattended"]);
91
- const LENSES = new Set(["lite", "standard", "cross-context"]);
92
-
93
- /**
94
- * The eval dimension set when the caller names none: `auto`. The spec does not exist yet when a run
95
- * opens, so the set cannot be resolved here; each evaluate order resolves it from the spec on disk
96
- * (`harness compile`), with the judge's own auto-enable rules. A fixed default in this place made
97
- * every orchestrated run grade `spec-conformance` alone, because an explicit list switches the
98
- * judge's auto-enable off.
99
- */
100
- export const DEFAULT_DIMENSIONS = "auto";
101
-
102
- /**
103
- * Parse `--dimensions` into the set written to the ledger. Shape-validated only, NOT checked against
104
- * the dimensions that ship: adding `references/dimensions/<id>.md` and naming it here is the
105
- * documented injection path, so a closed list here would make the evaluator's own extension point
106
- * unreachable. An id with no file behind it is skipped-with-a-warning at dimension resolution, which
107
- * is where that check belongs and where it can actually see the files.
108
- *
109
- * @param {(string|null|undefined)} raw - The comma-separated flag value; absent → `auto`.
110
- * @returns {(string[]|string)} Trimmed, de-duplicated ids in the caller's order, or `auto`.
111
- * @throws {Error} If the list is empty or an entry is not a kebab-case id.
112
- */
113
- export function parseDimensions(raw) {
114
- if (raw === null || raw === undefined) return DEFAULT_DIMENSIONS;
115
- const ids = String(raw).split(",").map((s) => s.trim()).filter(Boolean);
116
- if (!ids.length) throw new Error("--dimensions: empty list — omit the flag to resolve the set from the spec");
117
- for (const id of ids) {
118
- if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(id)) {
119
- throw new Error(`--dimensions: "${id}" is not a dimension id (kebab-case, e.g. spec-conformance, tdd-surface, integration)`);
120
- }
121
- }
122
- return [...new Set(ids)];
123
- }
47
+ /** The model a role runs on when neither a flag nor its environment knob names one. */
48
+ export const DEFAULT_MODEL = "sonnet";
124
49
 
125
50
  /** Slugify a free-text feature name into a filesystem-safe run id. */
126
51
  export function slugify(text) {
@@ -136,150 +61,97 @@ export function digest(text) {
136
61
  }
137
62
 
138
63
  /**
139
- * Build the receipt record. Pure — takes resolved inputs, returns the object that gets written.
140
- * Kept separate from I/O so the structural tests can assert its shape without a filesystem.
64
+ * Build the receipt. Pure: resolved inputs in, the object that gets written out.
141
65
  *
142
- * @param {object} o - Resolved inputs (destructured).
143
- * @param {string} o.slug - Feature slug.
144
- * @param {string} o.intake - The intake text, verbatim.
145
- * @param {object} o.config - The pinned run config.
146
- * @param {string} o.startedAt - ISO start time.
147
- * @param {(object|null)} [o.plugin] - The plugin copy that answered.
148
- * @param {(string|null)} [o.intakeSource] - Repo-relative `--intake-file` path, or "text" / "stdin".
149
- * @param {({source: string, path: (string|null), text: string, translated?: boolean}|null)} [o.breadboard] -
150
- * The resolved breadboard, `path` repo-relative (null when embedded in the intake), or null.
66
+ * The run id is derived, not drawn (`mintRunId`): a pure function of slug, start time and intake
67
+ * digest, so any writer holding the receipt recomputes the same id without being handed it.
68
+ *
69
+ * @param {object} o - Resolved inputs.
151
70
  * @returns {object} The receipt.
152
71
  */
153
- export function buildReceipt({ slug, intake, config, startedAt, plugin = null, intakeSource = null, breadboard = null }) {
72
+ export function buildReceipt({ slug, intake, config, startedAt, plugin = null, intakeSource = null }) {
154
73
  const intakeText = String(intake ?? "");
155
74
  const intakeSha256 = digest(intakeText);
156
75
  return {
157
76
  receipt_version: RECEIPT_VERSION,
158
77
  type: "harness-run-receipt",
159
78
  slug,
160
- // THE JOIN KEY, minted here because this is where a run acquires an identity at all.
161
- //
162
- // `order_id` was the nearest thing the harness had, and it is `<slug>/r<N>-a<M>`: unique
163
- // WITHIN a run and identical across every run of the same slug. So every record the pipeline
164
- // writes — orders, results, journal rows, trial rows, hook receipts — could be grouped by
165
- // feature and never by RUN, which makes "compare this run against the last one" and "what did
166
- // this run cost" both unanswerable from data that was otherwise all present.
167
- //
168
- // Derived, not drawn (see `mintRunId` in lib/paths.mjs): a pure function of the three fields below
169
- // it, so any writer holding this receipt recomputes the same id without being handed it, and a
170
- // receipt written before this field existed still yields the id it would have been given.
171
79
  run_id: mintRunId({ slug, startedAt, intakeSha256 }),
172
80
  started_at: startedAt,
173
81
  intake_sha256: intakeSha256,
174
82
  intake_chars: intakeText.length,
175
83
  intake_lines: intakeText ? intakeText.split("\n").length : 0,
176
- // WHERE THE INTAKE CAME FROM. The copy above is all the run reads, so without its origin nothing
177
- // on disk says which file the pitch was — or that it had a second half beside it.
178
84
  intake_source: intakeSource,
179
- // THE PITCH'S OTHER HALF. A `/shapeup` pitch is shaping.md + breadboard.md, and a Place that only
180
- // the breadboard names reaches no planning worker unless the run carries it. Recorded here, at
181
- // t=0, rather than left to a worker to note: the kernel knows whether a breadboard exists before
182
- // anything is dispatched, and a worker's prose rule for exactly this case did not hold.
183
- breadboard: breadboard
184
- ? {
185
- source: breadboard.source,
186
- path: breadboard.path ?? null,
187
- sha256: digest(breadboard.text),
188
- chars: String(breadboard.text ?? "").length,
189
- ids: idCounts(parseBreadboard(breadboard.text)),
190
- ...(typeof breadboard.translated === "boolean" ? { translated: breadboard.translated } : {}),
191
- }
192
- : null,
193
- // The single fact that separates "the harness ran" from "the harness described itself".
194
- // Written before any gate, so its ABSENCE at Stop is unambiguous.
195
85
  started: true,
196
- // WHICH COPY OF THE PLUGIN PRODUCED THIS RUN. Recorded while it is in hand, because afterwards
197
- // it is unrecoverable: every artifact a run leaves looks identical whether it came from this
198
- // version, a stale marketplace install, or a sub-agent improvising past a failed dispatch. A
199
- // run whose trace cannot name its own plugin cannot be compared with another run, and cannot be
200
- // cleared of the wrong-version failure the roster check exists to catch.
86
+ // Which copy of the plugin produced this run. A run whose trace cannot name its own plugin cannot
87
+ // be cleared of the wrong-version failure the roster check exists to catch.
201
88
  plugin: plugin ? { name: plugin.name, version: plugin.version, root: plugin.root } : null,
202
89
  config,
203
90
  };
204
91
  }
205
92
 
206
- /** The `harness-run.md` frontmatter block, per references/protocol.md Part 4 — State. */
207
- export function runFrontmatter({ slug, config, startedAt }) {
208
- return [
209
- "---",
210
- "type: harness-run",
211
- `feature: ${slug}`,
212
- `spec_folder: ${config.spec_folder}`,
213
- `lens: ${config.lens}`,
214
- // The ledger is the ONE place the dimension set lives: resume-state reads it back off this line
215
- // and the workflow hands it to every evaluate order. Written through the same list dialect the
216
- // parser reads (contract-md), never hand-joined.
217
- `eval_dimensions: ${uncoerce(config.eval_dimensions ?? DEFAULT_DIMENSIONS)}`,
218
- `max_rounds: ${config.max_rounds}`,
219
- `attempt_budget: ${config.attempt_budget}`,
220
- `wall_clock_budget_s: ${config.wall_clock_budget_s ?? "~"}`,
221
- `auto_level: ${config.auto_level}`,
222
- `gate_answers: ${config.gate_answers ?? "~"}`,
223
- `lane: ${config.fit?.lane ?? "full"}${config.fit?.overridden_from ? ` (overridden from ${config.fit.overridden_from})` : ""}`,
224
- "status: orienting",
225
- "final_verdict: ~",
226
- "rounds_used: 0",
227
- "discovered_rounds: 0",
228
- "deploy: ~",
229
- `started_at: ${startedAt}`,
230
- "closed_at: ~",
231
- // The cause a terminal status ended on, written alongside `closed_at` by
232
- // `probe resume --close` (kernel/probe/resume.mjs's closeRun) — the two land in one write, so a
233
- // closed run's ledger never carries a timestamp with no reason beside it.
234
- "close_cause: ~",
235
- // The once-only guard's OWN record of what closed this run — deliberately separate from
236
- // `status:` above, which every phase rewrites (`setRunStatus`) for the life of the run,
237
- // including the product's own ship path immediately before `probe resume --close` runs. Only
238
- // `closeRun` ever writes this line, so it is the one field an intervening `status:` rewrite
239
- // cannot move (kernel/probe/resume.mjs's closeRun docblock has the measured scenario).
240
- "closed_status: ~",
241
- "---",
242
- "",
243
- `# Harness run — ${slug}`,
244
- "",
245
- "Opened by ``harness init run`` (GATE L0.1). The tech lead is the sole writer from here on.",
246
- "",
247
- "## Rounds",
248
- "",
249
- "| Phase | Round | Result | Duration | Notes |",
250
- "|-------|-------|--------|----------|-------|",
251
- "| Init | — | run opened | — | intake recorded, receipt written |",
252
- "",
253
- "## Decisions log",
254
- "",
255
- "| Gate | Decision | Source | Note |",
256
- "|------|----------|--------|------|",
257
- "",
258
- ].join("\n");
93
+ /** Drop `undefined` leaves, one level deep, so the record never carries a literal `undefined`. */
94
+ function pruned(o) {
95
+ const out = {};
96
+ for (const [k, v] of Object.entries(o)) {
97
+ if (v === undefined) continue;
98
+ if (v && typeof v === "object" && !Array.isArray(v)) {
99
+ const inner = pruned(v);
100
+ if (Object.keys(inner).length) out[k] = inner;
101
+ continue;
102
+ }
103
+ out[k] = v;
104
+ }
105
+ return out;
259
106
  }
260
107
 
261
108
  /**
262
- * Unwedge `--force`: resolve every order left LIVE by a run nobody is continuing.
109
+ * Resolve the role -> model matrix: the flag, else the role's environment knob, else `sonnet`; `qa`
110
+ * follows `exec` when nothing names it. `eval` is left unset under `--no-eval`.
263
111
  *
264
- * `hooks/sandbox-guard.mjs`'s `liveOrders()` treats any file under `orders/` with no SAME-NAMED
265
- * file under `results/` as live, and constrains every later Edit/Write to what some live order's
266
- * substrate permits. `--force` used to `mkdirSync(..., {recursive:true})` over the same directories
267
- * and stop — a no-op on dirs that already exist and already hold the stale order, so the wedge
268
- * survived it exactly as before. This is the real unwedge path: for every order abandoned by the run
269
- * being forced over, write a same-named record under `results/` so `liveOrders()` no longer counts
270
- * it, without deleting the order file itself (`orders/` is this codebase's own audit trail of what
271
- * was dispatched, not a rolling buffer — losing the file loses the record that a dispatch happened).
112
+ * @param {object} a - Parsed argv.
113
+ * @param {object} [env=process.env] - Environment, injectable for a test.
114
+ * @returns {{exec: string, eval?: string, qa: string}} The matrix.
115
+ */
116
+ export function resolveModels(a, env = process.env) {
117
+ const pick = (flag, knob) => (flag ?? env[knob] ?? "").trim() || undefined;
118
+ const exec = pick(a.execModel, "SHAPEUP_EXEC_MODEL") ?? DEFAULT_MODEL;
119
+ const evalModel = a.noEval ? pick(a.evalModel, "SHAPEUP_EVAL_MODEL") : (pick(a.evalModel, "SHAPEUP_EVAL_MODEL") ?? DEFAULT_MODEL);
120
+ const qa = pick(a.qaModel, "SHAPEUP_QA_MODEL") ?? exec;
121
+ return pruned({ exec, eval: evalModel, qa });
122
+ }
123
+
124
+ /**
125
+ * Build the launch record. Pure, so a test asserts its shape without a filesystem. The receipt's
126
+ * config and the record come from the same parsed argv, so the two cannot disagree about the dials.
272
127
  *
273
- * Not a real WorkResult: `work-result.schema.json`'s `status` enum (done/partial/escalated/failed)
274
- * has no member that honestly means "no worker ever answered" — every one of those values would
275
- * misrepresent an abandoned dispatch as an attempt that actually ran. So this writes a plainly
276
- * self-labelled, intentionally non-conforming marker instead of forcing a lie into a schema-valid
277
- * shape. `liveOrders()` only checks filename presence under `results/`, never content, so this is
278
- * sufficient to unwedge on its own.
128
+ * @param {object} o - Resolved inputs.
129
+ * @returns {object} The record, pruned of unset optionals.
130
+ */
131
+ export function buildLaunchRecord({
132
+ slug, runId, autoLevel, answers, models, maxRounds, attemptBudget, pluginRoot, startedAt,
133
+ noEval, noQa, maxParallelScopes,
134
+ }) {
135
+ return pruned({
136
+ slug, runId, autoLevel, answers,
137
+ models, budgets: { maxRounds, attemptBudget },
138
+ pluginRoot, startedAt,
139
+ noEval: noEval || undefined, noQa: noQa || undefined,
140
+ maxParallelScopes,
141
+ });
142
+ }
143
+
144
+ /**
145
+ * Unwedge `--force`: resolve every order left LIVE by a run nobody is continuing.
146
+ *
147
+ * The substrate fence treats any order with no same-named result as live and constrains every later
148
+ * write to what it permits, so re-opening over a dispatch nobody answered would keep the stale fence.
149
+ * For each such order this writes a plainly self-labelled marker under `results/` (never a lie in a
150
+ * schema-valid shape), and keeps the order file: `orders/` is the audit trail of what was dispatched.
279
151
  *
280
152
  * @param {string} cwd - Project root.
281
153
  * @param {string} slug - The run being forced over.
282
- * @returns {string[]} Order-id suffixes (filenames minus `.json`) resolved as abandoned.
154
+ * @returns {string[]} Order ids resolved as abandoned.
283
155
  */
284
156
  export function resolveAbandonedOrders(cwd, slug) {
285
157
  const oDir = ordersDir(cwd, slug);
@@ -293,7 +165,7 @@ export function resolveAbandonedOrders(cwd, slug) {
293
165
  mkdirSync(rDir, { recursive: true });
294
166
  const marker = {
295
167
  synthetic: true,
296
- status: "abandoned", // not in work-result.schema.json's enum — deliberately: see banner above
168
+ status: "abandoned", // deliberately outside the WorkResult status enum: nobody answered
297
169
  order_id: f.slice(0, -".json".length),
298
170
  reason: "dispatched, never answered — resolved by `harness init run --force`",
299
171
  resolved_at: resolvedAt,
@@ -304,132 +176,16 @@ export function resolveAbandonedOrders(cwd, slug) {
304
176
  return resolved;
305
177
  }
306
178
 
307
- /**
308
- * Copy the plugin's run scripts into the project so the launch can actually read them.
309
- *
310
- * WHY A COPY, AND WHY HERE. `Workflow({scriptPath})` loads a script only from somewhere the session
311
- * is already allowed to read — the working directory, or a directory the operator added. The plugin
312
- * is installed OUTSIDE the project (a version-stamped cache directory on a marketplace install), so
313
- * naming the shipped path fails the launch outright, and no permission rule fixes it: the grant that
314
- * authorises the Workflow tool says nothing about where it may read from. The failure is invisible
315
- * in development, where the plugin root and the working directory are the same tree, and total for
316
- * everybody else. A project-local copy is inside the working directory by construction, so it loads
317
- * in every permission mode without widening what the session may read.
318
- *
319
- * Opening a run is the right moment: it is the one step every lane passes through before a launch,
320
- * and it already knows which copy of the plugin answered.
321
- *
322
- * `refresh` is what keeps an upgrade from arriving mid-round. Opening a run (or forcing over one)
323
- * overwrites the staged copies, so a plugin upgrade reaches the NEXT run. A call that finds a run
324
- * already open only fills in what is missing: that run should finish on the orchestrator it started
325
- * with, and swapping the script under a resumed round is a different build than the one the gates
326
- * were answered for — but a relaunch with no script at all is a dead end, so a copy that is gone
327
- * (a cleaned run workspace, a run opened by a version that never staged) is written back.
328
- *
329
- * Best-effort by design: a project that cannot be written to still opens its run and can still be
330
- * launched from the install path by an operator who adds that directory. Reporting beats refusing.
331
- *
332
- * @param {string} cwd - Project root.
333
- * @param {string} pluginRoot - The plugin copy that answered this call.
334
- * @param {object} [opts] - Options.
335
- * @param {boolean} [opts.refresh] - Overwrite an existing copy (true) or only fill gaps (false).
336
- * @returns {{ok: boolean, dir: string, staged: string[], reason?: string}} Outcome, for the caller to report.
337
- */
338
- export function stageWorkflows(cwd, pluginRoot, { refresh = true } = {}) {
339
- const src = join(pluginRoot, "skills", "tech-lead", "workflows");
340
- const dir = workflowsStage(cwd);
341
- let names;
342
- try {
343
- names = readdirSync(src).filter((f) => f.endsWith(".js")).sort();
344
- } catch (e) {
345
- return { ok: false, dir, staged: [], reason: `no workflow scripts at ${src}: ${e.message}` };
346
- }
347
- try {
348
- mkdirSync(dir, { recursive: true });
349
- for (const f of names) {
350
- const dst = join(dir, f);
351
- if (refresh || !existsSync(dst)) copyFileSync(join(src, f), dst);
352
- }
353
- } catch (e) {
354
- return { ok: false, dir, staged: [], reason: `could not stage into ${dir}: ${e.message}` };
355
- }
356
- return { ok: true, dir, staged: names };
357
- }
358
-
359
- /**
360
- * Find the breadboard that belongs to this run's pitch. First hit wins:
361
- *
362
- * 1. `flag` — `--breadboard <path>`, resolved against cwd; a missing file is an error.
363
- * 2. `sibling` — `breadboard.md` in the folder of `--intake-file` (not for text or stdin).
364
- * 3. `shaping-dir` — `shapeup/<slug>/shaping/breadboard.md`, the documented location.
365
- * 4. `shared-root` — `shapeup/<slug>/breadboard.md`, the flat layout real projects use.
366
- * 5. `embedded` — the intake itself, when it carries Places and UI tables; nothing is staged.
367
- *
368
- * THE INTAKE'S OWN FOLDER COMES FIRST, not the documented location, because the only consumer that
369
- * hit this keeps its pitch flat in `shapeup/<slug>/`: a lookup that reads only the shaping folder
370
- * passes every fixture built to the documented layout and misses the real one.
371
- *
372
- * A discovered candidate that is the intake file itself, or the run's own staged copy, is skipped:
373
- * the staged copy is this function's OUTPUT, and re-opening a run from its staged intake must not
374
- * inherit the breadboard of the run it replaces.
375
- *
376
- * A TRANSLATED PITCH GETS ITS TRANSLATED BREADBOARD. When the intake is a translator output
377
- * (`<name>.en.md`), each folder is tried for `breadboard.en.md` before `breadboard.md`, and the
378
- * result says whether it is translated — an English spec planned from a source-language breadboard
379
- * is a mismatch the caller reports, not one it may silently stage.
380
- *
381
- * @param {object} o - Inputs (destructured).
382
- * @param {string} o.cwd - Project root.
383
- * @param {string} o.slug - Feature slug.
384
- * @param {(string|null)} [o.intakeFile] - The `--intake-file` value as given ("-" = stdin).
385
- * @param {(string|null)} [o.flag] - The `--breadboard` value as given.
386
- * @param {string} [o.intake] - The intake text, for the embedded case.
387
- * @returns {({source: string, path: (string|null), text: string, translated?: boolean}|null)} The
388
- * breadboard, its absolute path (null when embedded), and its text — plus `translated` when the
389
- * intake is a `.en.md` translation; or null when the pitch has none.
390
- * @throws {Error} If `--breadboard` names a file that does not exist.
391
- */
392
- export function resolveBreadboard({ cwd, slug, intakeFile = null, flag = null, intake = "" }) {
393
- const self = intakeFile && intakeFile !== "-" ? resolve(cwd, intakeFile) : null;
394
- const english = Boolean(self && self.endsWith(".en.md"));
395
- /** Tag a hit with whether it is translated — only meaningful when the intake is. */
396
- const hit = (source, p, text) => ({ source, path: p, text, ...(english ? { translated: p === null || p.endsWith(".en.md") } : {}) });
397
- if (flag) {
398
- const p = resolve(cwd, flag);
399
- if (!existsSync(p) || !statSync(p).isFile()) throw new Error(`--breadboard not found: ${p}`);
400
- return hit("flag", p, readFileSync(p, "utf8"));
401
- }
402
- const staged = stagedBreadboard(cwd, slug);
403
- const names = english ? ["breadboard.en.md", "breadboard.md"] : ["breadboard.md"];
404
- const folders = [
405
- ...(self ? [["sibling", dirname(self)]] : []),
406
- ["shaping-dir", shapingDir(cwd, slug)],
407
- ["shared-root", sharedRoot(cwd, slug)],
408
- ];
409
- for (const [source, dir] of folders) {
410
- for (const n of names) {
411
- const p = join(dir, n);
412
- if (p === self || p === staged) continue;
413
- if (existsSync(p) && statSync(p).isFile()) return hit(source, p, readFileSync(p, "utf8"));
414
- }
415
- }
416
- if (hasBreadboardTables(intake)) return hit("embedded", null, String(intake));
417
- return null;
418
- }
419
-
420
- /** A path relative to the project root, `/`-joined on every platform — the form a receipt records. */
179
+ /** A path relative to the project root, `/`-joined on every platform. */
421
180
  const repoRel = (cwd, p) => relative(cwd, p).split(sep).join("/");
422
181
 
423
- // ---- CLI -------------------------------------------------------------------
424
-
425
- /** The typed argv contract (see `./lib/argv.mjs`). */
182
+ /** The typed argv contract (see `../lib/argv.mjs`). */
426
183
  export const ARGV_SPEC = {
427
184
  usage: 'harness.mjs init run (--intake-file <path> | --intake-text "<req>" | --intake-stdin) ' +
428
- "[--slug <slug>] [--auto-level interactive|auto|unattended] [--lens <lens>] " +
429
- "[--max-rounds N] [--attempts N] [--spec-folder <dir>] [--dimensions <a,b>] " +
430
- "[--gate-answers <preset|path>] [--breadboard <path>] " +
431
- "[--lane full|tiny] [--tiny] [--wall-clock-budget <seconds>] [--cwd <dir>] " +
432
- "[--plugin-root <dir>] [--force]",
185
+ "[--slug <slug>] [--auto-level interactive|auto|unattended] " +
186
+ "[--max-rounds N] [--attempts N] [--spec-folder <dir>] [--gate-answers <preset|path>] " +
187
+ "[--exec-model <m>] [--eval-model <m>] [--qa-model <m>] [--no-eval] [--no-qa] " +
188
+ "[--parallel-scopes N] [--cwd <dir>] [--plugin-root <dir>] [--force]",
433
189
  _: { arity: 0, max: 0, name: "(no positional operands)" },
434
190
  cwd: { type: "path" },
435
191
  "intake-text": { type: "str" },
@@ -437,20 +193,18 @@ export const ARGV_SPEC = {
437
193
  "intake-stdin": { type: "flag" },
438
194
  slug: { type: "str" },
439
195
  "auto-level": { type: "str" },
440
- lens: { type: "str" },
441
196
  "max-rounds": { type: "int", min: 1 },
442
197
  attempts: { type: "int", min: 1 },
443
198
  "spec-folder": { type: "path" },
444
- dimensions: { type: "str" },
445
199
  "gate-answers": { type: "str" },
446
- breadboard: { type: "path" },
447
- lane: { type: "str" },
448
- tiny: { type: "flag" },
449
- "wall-clock-budget": { type: "int", min: 1 },
450
- // Which copy of the plugin this run is opened against. Defaults to the one this kernel is part
451
- // of, which is right for every ordinary invocation; it is nameable because a machine can carry
452
- // several installs, and because the roster refusal below is otherwise unreachable for a test —
453
- // a check whose failure path cannot be exercised is a check nobody has seen work.
200
+ "exec-model": { type: "str" },
201
+ "eval-model": { type: "str" },
202
+ "qa-model": { type: "str" },
203
+ "no-eval": { type: "flag" },
204
+ "no-qa": { type: "flag" },
205
+ "parallel-scopes": { type: "int", min: 1 },
206
+ // Which copy of the plugin this run is opened against. Nameable because a machine can carry several
207
+ // installs, and because the roster refusal is otherwise unreachable for a test.
454
208
  "plugin-root": { type: "path" },
455
209
  force: { type: "flag" },
456
210
  };
@@ -461,48 +215,57 @@ function fail(code, msg) {
461
215
  }
462
216
 
463
217
  /**
464
- * Open a run: mint the receipt, or refuse (exit 3) when one is already live.
218
+ * Open a run: mint the receipt, or refuse when one is already live.
465
219
  *
466
- * @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
467
- * @returns {(Promise<void>|void)} Settles when the subcommand has written its output; most paths
468
- * call `process.exit()` with the subcommand's documented code rather than returning.
220
+ * @param {string[]} rawArgv - The subcommand's own arguments.
221
+ * @returns {void} Calls `process.exit()` with the documented code on every refusal.
469
222
  */
470
223
  export function cli(rawArgv) {
471
224
  const args = runArgs(ARGV_SPEC, rawArgv);
472
225
  const cwd = args.cwd || process.cwd();
226
+ if (insideRunTrace(cwd)) {
227
+ fail(2, `✋ init-run: ${resolve(cwd)} is inside a run trace (.shapeup/). Run it from the project root: from here it would open a second run nested in the first.`);
228
+ }
473
229
 
474
- // GATE L0 — the worker roster, before any spend.
475
- //
476
- // HERE AND NOT IN THE WORKFLOW SCRIPT, for two reasons. This is what GATE L0 actually executes,
477
- // and it covers both lanes rather than only the orchestrated one — the prose/`--tiny` lane opens
478
- // its run through exactly this call. And it is genuinely before any spend: the workflow script's
479
- // own helpers reach the kernel by spawning a sub-agent first, so a check placed there has already
480
- // paid for a model call before it can refuse.
481
- //
482
- // What it can and cannot prove is stated in verify/skills.mjs and is worth repeating where it is
483
- // enforced: this says the files exist at this root at this version. It does not say the SESSION
484
- // will resolve that copy. The orchestrator's canary dispatch answers that, and its receipt is the
485
- // evidence; this refusal is the cheap half that costs nothing to run on every single run.
486
- const plugin = resolveWorkers(args.pluginRoot ? resolve(args.pluginRoot) : undefined);
230
+ // THE MODEL FLOOR, before anything is written and before the already-open refusal.
231
+ const models = resolveModels(args);
232
+ const belowFloor = floorProblems(models, { noEval: Boolean(args.noEval) });
233
+ if (belowFloor.length) {
234
+ fail(2, [
235
+ `✋ init-run: ${belowFloor.join("; ")}.`,
236
+ "",
237
+ "The launch record's models for the roles that write code (exec) and grade it (eval) must be sonnet or",
238
+ "above; a cheaper tier is refused here, before a run is opened. (A worker agent inherits the session's",
239
+ "model, so the record states intent and the dispatch receipts name the model that ran.) Name a model with --exec-model / --eval-model, or set",
240
+ "SHAPEUP_EXEC_MODEL / SHAPEUP_EVAL_MODEL. `qa` is not checked, and `eval` is not under --no-eval.",
241
+ ].join("\n"));
242
+ }
243
+
244
+ // GATE L0 — the worker roster, before any spend. It says the files exist at this root at this
245
+ // version; whether the SESSION resolves that copy is answered by the first dispatch's receipt.
246
+ let plugin;
247
+ try { plugin = resolveWorkers(args.pluginRoot ? resolve(args.pluginRoot) : undefined); }
248
+ catch (e) {
249
+ // A root with no domain registry cannot name its own roster: a broken installation, refused the
250
+ // same way as a missing worker rather than thrown as a stack trace.
251
+ fail(3, `✋ init-run: refusing to open a run — the plugin copy cannot name its worker roster (${e.message}).`);
252
+ }
487
253
  if (plugin.missing.length) {
488
254
  fail(3, [
489
- `✋ init-run: refusing to open a run — ${plugin.missing.length} of ${plugin.workers.length} worker skills are missing.`,
255
+ `✋ init-run: refusing to open a run — ${plugin.missing.length} of ${plugin.workers.length} workers are missing.`,
490
256
  "",
491
257
  ` plugin: ${plugin.name ?? "unknown"} ${plugin.version ?? "unknown version"}`,
492
258
  ` root: ${plugin.root}`,
493
259
  ` missing: ${plugin.missing.join(", ")}`,
494
260
  "",
495
261
  "A run against this copy would dispatch workers that cannot resolve, and a failed dispatch is",
496
- "answered by the sub-agent improvising the craft itself — phases reporting complete with none",
497
- "of the shipped craft applied. Load the working copy (`claude --plugin-dir <repo>`) or install",
498
- "and enable the plugin, then retry. `harness verify skills` reports the same thing on demand.",
262
+ "answered by the sub-agent improvising the craft itself. Load the working copy",
263
+ "(`claude --plugin-dir <repo>`) or install and enable the plugin, then retry.",
499
264
  ].join("\n"));
500
265
  }
501
266
 
502
267
  let intake = args.intakeText ?? null;
503
268
  const intakeFile = args.intakeFile ?? null;
504
- // `--intake-file -` is the shape everyone reaches for; accept it rather than erroring on a
505
- // file literally named "-". (Measured: an agent tried exactly this on its second attempt.)
506
269
  if (args.intakeStdin || intakeFile === "-") {
507
270
  try { intake = readFileSync(0, "utf8"); } catch { fail(2, "--intake-stdin: nothing on stdin"); }
508
271
  } else if (intakeFile) {
@@ -512,196 +275,91 @@ export function cli(rawArgv) {
512
275
  }
513
276
  if (!intake || !intake.trim()) {
514
277
  fail(2, [
515
- "✋ init-run: no intake. Pass --intake-text \"<the requirement>\" or --intake-file <path>.",
278
+ "✋ init-run: no intake. Pass --intake-file <path> or --intake-text \"<the requirement>\".",
516
279
  "",
517
- "An orchestrator with no spec has nothing to orchestrate — this is the same precondition",
518
- "GATE L0.0 (hooks/gate-intake.mjs) enforces at dispatch, re-checked here where the run is",
519
- "actually opened.",
280
+ "A run opened over no requirement would plan nothing and read like a success.",
520
281
  ].join("\n"));
521
282
  }
522
283
 
523
284
  const slug = (args.slug ?? null) || slugify(intake.split("\n").find((l) => l.trim()) || "run");
524
-
525
285
  const auto_level = args.autoLevel ?? "interactive";
526
- if (!AUTO_LEVELS.has(auto_level)) fail(2, `--auto-level must be one of: ${[...AUTO_LEVELS].join(", ")}`);
527
- const lens = args.lens ?? "standard";
528
- if (!LENSES.has(lens)) fail(2, `--lens must be one of: ${[...LENSES].join(", ")}`);
529
- // GATE L0.5 — the eval dimension set, resolved ONCE here and carried by the ledger. Before this
530
- // flag the line was a constant, so a dimension the PO asked for at L0.5 had nowhere to be
531
- // recorded and the run graded spec-conformance whatever the answer had been.
532
- let eval_dimensions;
533
- try { eval_dimensions = parseDimensions(args.dimensions ?? null); }
534
- catch (e) { fail(2, e.message); }
535
- // The pitch's second half, resolved before anything is written so a bad `--breadboard` is a usage
536
- // error that ran nothing. Read-only here; staged below, once the run is actually opened.
537
- let bb = null;
538
- try { bb = resolveBreadboard({ cwd, slug, intakeFile, flag: args.breadboard ?? null, intake }); }
539
- catch (e) { fail(2, e.message); }
540
- if (bb?.translated === false) {
541
- console.error(`⚠ init-run: the intake is a translation but its breadboard is not — staging ${repoRel(cwd, bb.path)} as found.`);
542
- console.error(" Translate the breadboard too (translator), and name the .en.md with --breadboard, or the spec is planned from two languages.");
543
- }
286
+ if (!AUTO_LEVELS.includes(auto_level)) fail(2, `--auto-level must be one of: ${AUTO_LEVELS.join(", ")}`);
544
287
  const intakeSource = args.intakeStdin || intakeFile === "-" ? "stdin" : intakeFile ? repoRel(cwd, resolve(cwd, intakeFile)) : "text";
545
288
 
546
289
  const config = {
547
290
  auto_level,
548
- lens,
549
- eval_dimensions,
550
291
  max_rounds: args.maxRounds ?? 3,
551
292
  attempt_budget: args.attempts ?? 5,
552
293
  spec_folder: args.specFolder ?? `${globShared(slug, "spec")}/`,
553
294
  gate_answers: args.gateAnswers ?? null,
554
- tiny_lane: !!args.tiny,
555
- // GATE L0.3 — the lane, computed rather than judged (see `harness init fit`). Recorded with its
556
- // evidence so a heavy lane on a small change is visible instead of accidental. An explicit
557
- // --lane or --tiny is honoured and marked as an override, because a measured recommendation
558
- // fitted on three features must not outrank a human who knows the codebase.
559
- fit: (() => {
560
- const auto = decideLane({ intake, files: treeSize(cwd) });
561
- const forced = (args.lane ?? null) || (args.tiny ? "tiny" : null);
562
- return forced && forced !== auto.lane
563
- ? { ...auto, lane: forced, overridden_from: auto.lane, override_source: args.tiny ? "--tiny" : "--lane" }
564
- : auto;
565
- })(),
566
- // The third breaker (see `harness verify budget`). Null = off, which is the default and
567
- // keeps every existing run behaving exactly as before. Set it in any lane with a hard clock
568
- // — CI, an overnight run — so the harness trips its own breaker and ships what
569
- // is green, instead of being killed from outside and shipping nothing.
570
- wall_clock_budget_s: args.wallClockBudget ?? null,
571
295
  };
572
296
 
573
297
  const runRoot = localRoot(cwd, slug);
574
298
  const receiptPath = join(runRoot, "receipt.json");
575
299
 
576
- // STAGE THE RUN SCRIPTS BEFORE THE ALREADY-OPEN REFUSAL, not after: a session resuming a paused
577
- // run reaches that refusal and nothing else, and it still needs a `scriptPath` it can name. What
578
- // it does NOT get is a swapped orchestrator — see stageWorkflows() for why `refresh` is false
579
- // exactly when a run is already open and is not being forced over.
580
- // `Boolean(...)`, not the bare flag: an absent `--force` is `undefined`, and `false || undefined`
581
- // is `undefined`, which a destructured default reads as "not passed" and turns back into `true`.
582
- const staged = stageWorkflows(cwd, plugin.root, { refresh: !existsSync(receiptPath) || Boolean(args.force) });
583
- if (!staged.ok) {
584
- console.error(`⚠ init-run: could not stage the run scripts — ${staged.reason}`);
585
- console.error(` Launch from the install path instead, and add ${plugin.root} to the session's`);
586
- console.error(" readable directories (/add-dir) if the Workflow tool refuses to load it.");
587
- }
588
- // A RUN IS ALREADY OPEN. This is the resume path, and it used to be a dead end.
589
- //
590
- // The refusal is right: silently re-initialising would discard the round history the circuit
591
- // breaker counts against. What was wrong was the instruction it gave — "Resume it (`--from
592
- // <slug>`)". `--from` is not an init-run flag at all; it is a `/tech-lead` flag, and it takes a
593
- // PHASE (`--from build`), not a slug. So at the one moment the orchestrator most needs a next
594
- // step, the runtime named a mechanism that does not exist, on a script whose failure mode was
595
- // already invisible — under a symlinked install an entry-point guard once skipped this whole body.
596
- //
597
- // Observed consequence on a handoff: a fresh session in a workspace with an open run burns most
598
- // of its budget before its first write, largely on forensics against this step, and closes none
599
- // of the gap.
600
- //
601
- // So the refusal now DOES the resume work instead of describing it. It emits the derived snapshot
602
- // — the same file-only derivation `harness reduce graph --subgraph run` injects — so the orchestrator gets
603
- // slug, status, round, attempt, board counts and pending orders in THIS tool call rather than
604
- // needing to discover that it needs another one. Exit 3 still means "do not proceed as if you
605
- // opened a run"; it now also means "here is the run you are actually in".
300
+ // A RUN IS ALREADY OPEN: the refusal does the resume work, printing what is on disk. Re-opening
301
+ // would discard the round history the circuit breaker counts against.
606
302
  if (existsSync(receiptPath) && !args.force) {
607
- let resume = null;
608
- try { resume = deriveSnapshot(cwd); } catch { /* a broken run must still produce the refusal */ }
609
- // A receipt says a run EXISTS, not that it is open: a terminal close leaves the receipt in place.
610
- // Calling a closed run "open" hid the one fact that changes what resuming it means.
303
+ let kept = null;
304
+ try { kept = JSON.parse(readFileSync(runArgsPath(cwd, slug), "utf8")); } catch { /* no record on disk */ }
611
305
  let closed = null;
612
- try {
613
- const fm = splitFrontmatter(readFileSync(harnessRun(cwd, slug), "utf8")).meta || {};
614
- if (fm.closed_status && fm.closed_status !== "~") closed = { status: fm.closed_status, at: fm.closed_at, cause: fm.close_cause };
615
- } catch { /* no ledger — the receipt alone decides, as before */ }
306
+ try { closed = JSON.parse(readFileSync(join(runRoot, "close.json"), "utf8")); } catch { /* not closed */ }
616
307
  fail(3, [
617
308
  closed
618
- ? `✋ init-run: this run is CLOSED as "${closed.status}" at ${closed.at ?? "?"} — receipt exists at ${receiptPath}.`
309
+ ? `✋ init-run: this run is CLOSED as "${closed.status}" at ${closed.at ?? "?"} (${closed.cause ?? "no cause recorded"}) — receipt exists at ${receiptPath}.`
619
310
  : `✋ init-run: a run is ALREADY OPEN — receipt exists at ${receiptPath}.`,
620
- ...(closed ? [
621
- ` Its close: ${closed.cause && closed.cause !== "~" ? closed.cause : "no cause recorded"}`,
622
- " Resuming it REOPENS it: the first phase that runs moves the ledger off the closed state, keeps",
623
- " this close under prior_closes, and the run's next terminal close is recorded as its own.",
624
- ] : []),
625
311
  "",
626
- "Do NOT re-initialise and do NOT restart the pipeline from phase 1. Re-opening would discard",
627
- "the round history the circuit breaker counts against, and the board, ledger and receipt below",
628
- "already hold the run's real state. RESUME from the phase these files report.",
312
+ closed
313
+ ? "Reopening a closed run is the PO's decision: --force answers every order nobody answered and writes a fresh record."
314
+ : "Do NOT re-initialise and do NOT restart from phase 1. The orders, results, gate ledger and board",
315
+ closed ? "" : "under the run root already hold the run's real state; resume from the phase they report.",
629
316
  "",
630
- resume
631
- ? `RESUME STATE (derived from files, never from memory):\n${JSON.stringify(resume, null, 2)}`
632
- : [
633
- "The receipt exists but no run state could be derived, which means the run root is",
634
- "incomplete. Inspect it before deciding:",
635
- ` ls -R ${runRoot}`,
636
- ].join("\n"),
317
+ kept
318
+ ? `LAUNCH RECORD (kept — the switches typed to this call were NOT applied; --force re-opens with new ones):\n${JSON.stringify(kept, null, 2)}`
319
+ : "No launch record is on disk for this run — re-open it with --force to write one.",
637
320
  "",
638
- "To re-derive this at any time:",
639
- " node <plugin>/kernel/harness.mjs reduce snapshot --cwd <dir>",
640
321
  "To abandon the open run and start over, deliberately: --force",
641
- "",
642
- staged.ok
643
- ? `Relaunch the same run with: Workflow({scriptPath: "${globWorkflowsStage("shapeup-run.js")}", args: <the same RunArgs>})`
644
- : "The run scripts are NOT staged in this project — see the warning above before relaunching.",
645
322
  ].join("\n"));
646
323
  }
647
324
 
648
- // THE REAL UNWEDGE PATH. `--force` used to `mkdirSync(..., {recursive:true})` over orders/,
649
- // results/ and discovery/ and stop — a no-op on directories that already exist and already hold a
650
- // dispatched-but-unanswered order, so `sandbox-guard.mjs`'s `liveOrders()` kept constraining every
651
- // later write to that stale order's substrate regardless of `--force`. Resolve every such order
652
- // BEFORE the fresh run starts writing (see `resolveAbandonedOrders()` above for why this writes a
653
- // marker under `results/` rather than deleting the order file).
325
+ // The real unwedge path: resolve every dispatched-but-unanswered order BEFORE the fresh run writes,
326
+ // and retire the pointer the abandoned run left, so the fence stops constraining writes to it.
654
327
  if (args.force) {
655
328
  const abandoned = resolveAbandonedOrders(cwd, slug);
656
329
  if (abandoned.length) {
657
- console.error(
658
- `⚠ init-run --force: resolved ${abandoned.length} dispatched-but-unanswered order(s) as abandoned — ${abandoned.join(", ")}`,
659
- );
330
+ console.error(`⚠ init-run --force: resolved ${abandoned.length} dispatched-but-unanswered order(s) as abandoned — ${abandoned.join(", ")}`);
660
331
  }
661
- // And the pointer the abandoned run left behind. It names a run that is being forced over, so
662
- // the next compile republishes it within the second; retiring it here means a `--force` that
663
- // resolves nothing still leaves no stale claim about which run is open.
664
332
  rmSync(activeOrder(cwd), { force: true });
665
333
  }
666
334
 
667
335
  const startedAt = new Date().toISOString();
668
- const receipt = buildReceipt({
669
- slug, intake, config, startedAt, plugin, intakeSource,
670
- breadboard: bb ? { ...bb, path: bb.path ? repoRel(cwd, bb.path) : null } : null,
336
+ const receipt = buildReceipt({ slug, intake, config, startedAt, plugin, intakeSource });
337
+ const launch = buildLaunchRecord({
338
+ slug, runId: receipt.run_id, autoLevel: auto_level, answers: config.gate_answers ?? undefined, models,
339
+ maxRounds: config.max_rounds, attemptBudget: config.attempt_budget,
340
+ pluginRoot: plugin.root, startedAt,
341
+ noEval: args.noEval, noQa: args.noQa, maxParallelScopes: args.parallelScopes ?? undefined,
671
342
  });
343
+ const unlaunchable = launchProblems(launch);
344
+ if (unlaunchable.length) fail(2, `✋ init-run: the launch record is not launchable — ${unlaunchable.join("; ")}`);
672
345
 
673
- mkdirSync(runRoot, { recursive: true });
674
- mkdirSync(join(runRoot, "orders"), { recursive: true });
675
- mkdirSync(join(runRoot, "results"), { recursive: true });
676
- mkdirSync(join(runRoot, "discovery"), { recursive: true });
677
-
678
- // The intake, verbatim. So "the spec was dropped on the hand-off" is a checkable claim.
346
+ for (const d of ["", "orders", "results", "discovery"]) mkdirSync(join(runRoot, d), { recursive: true });
679
347
  writeFileSync(join(runRoot, "intake.md"), intake.endsWith("\n") ? intake : intake + "\n", "utf8");
680
- // The breadboard, verbatim — byte for byte, so the receipt's digest is the file's. A copy left by a
681
- // run this one replaces goes first: a re-open without a breadboard must not inherit the last one.
682
- rmSync(stagedBreadboard(cwd, slug), { force: true });
683
- if (bb?.path) writeFileSync(stagedBreadboard(cwd, slug), bb.text, "utf8");
684
348
  writeFileSync(receiptPath, JSON.stringify(receipt, null, 2) + "\n", "utf8");
685
- writeFileSync(join(runRoot, "harness-run.md"), runFrontmatter({ slug, config, startedAt }), "utf8");
349
+ // The launch record, in the same act as the receipt and from the same parsed argv.
350
+ writeFileSync(runArgsPath(cwd, slug), JSON.stringify(launch, null, 2) + "\n", "utf8");
686
351
 
687
- // The pointer every downstream guard reads to answer "is a run active?".
688
352
  const pointer = activeScope(cwd);
689
353
  mkdirSync(dirname(pointer), { recursive: true });
690
354
  writeFileSync(pointer, JSON.stringify({ slug, started_at: startedAt }, null, 2) + "\n", "utf8");
691
355
 
692
- // GATE L0 HAS A DETERMINISTIC CALL SITE: THE RUN'S OPENING. It used to be a line of prose the
693
- // tech lead was asked to act on, and the same consumer ledgered L0 on one run and not the next.
694
- // The intake conversation is the L0 decision; opening the run is the act that records it, with
695
- // the answer set the run was configured with (a preset name or a file), else the interactive
696
- // defaults. Best-effort: a row that cannot be written must not fail the opening.
356
+ // GATE L0 has a deterministic call site: the run's opening. The intake conversation is the L0
357
+ // decision; opening the run records it, with the answer set the run was configured with. Best
358
+ // effort: a row that cannot be written must not fail the opening.
697
359
  try {
698
360
  const ga = config.gate_answers ?? null;
699
361
  const presetName = ga && PRESETS[ga] ? ga : null;
700
362
  const found = discover({ cwd, slug, preset: presetName, file: ga && !presetName ? ga : null });
701
- // A preset or file answers L0 for the run; a run with neither — the interactive lane, where the
702
- // tech lead held the intake conversation before opening it — has that conversation as its L0
703
- // decision, and the opening records it as such. An answer set that says `ask` is recorded as
704
- // `ask`: the row states what the set said, and the intake note says what happened.
705
363
  const r = found.error
706
364
  ? { status: "ok", decision: "proceed", source: "intake (no answer set on disk)", note: "the intake conversation is the L0 decision" }
707
365
  : resolveGate(found.set, "L0", found.source);
@@ -715,20 +373,14 @@ export function cli(rawArgv) {
715
373
  console.log(JSON.stringify({
716
374
  ok: true,
717
375
  slug,
718
- // Echoed so the orchestrator can put it in RunArgs without re-reading the receipt.
719
376
  run_id: receipt.run_id,
720
377
  run_root: globLocal(slug),
721
378
  receipt: globLocal(slug, "receipt.json"),
722
379
  intake_sha256: receipt.intake_sha256,
723
380
  intake_chars: receipt.intake_chars,
724
- // The staged breadboard the planning dispatches are handed, or null when the pitch has none
725
- // separate (`breadboard_source` then says "embedded" or null).
726
- breadboard: bb?.path ? globLocal(slug, "breadboard.md") : null,
727
- breadboard_source: bb?.source ?? null,
728
381
  config,
729
- // What the launch names. Project-local by necessity, not by preference — see stageWorkflows().
730
- workflow_script: staged.ok ? globWorkflowsStage("shapeup-run.js") : null,
731
- next: "GATE L0 — pin the run config, emit the gate block, then ORIENT.",
382
+ launch_record: globLocal(slug, "run-args.json"),
383
+ launch,
384
+ next: "GATE L0 — confirm the run config with the PO, then orient.",
732
385
  }, null, 2));
733
386
  }
734
-