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
@@ -0,0 +1,489 @@
1
+ #!/usr/bin/env node
2
+ // GATE L0.1 — START THE RUN. The orchestrator's first tool call, before any prose.
3
+ //
4
+ // WHY THIS EXISTS (reproduced, not theorized).
5
+ //
6
+ // Dispatched with a valid spec, the orchestrator did this:
7
+ //
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.
40
+ //
41
+ // 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]
45
+ //
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.
49
+ //
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: spec-conformance)
56
+ // --gate-answers path | preset name (see `harness gate`; recorded, not read)
57
+ // --wall-clock-budget N deadline breaker, seconds (off by default; see `harness verify budget`)
58
+ // --cwd project root (default: process.cwd())
59
+ // --force re-init over an existing run receipt
60
+ //
61
+ // Prints a JSON receipt on stdout. Exit 0 on success, 2 on a usage error, 3 when a live run
62
+ // already exists and --force was not given.
63
+ //
64
+ // EXIT 3 IS THE RESUME PATH, not a dead end. It prints the file-derived RunSnapshot for the run that
65
+ // is already open — slug, status, round, attempt, board counts, pending orders — so the orchestrator
66
+ // continues from the phase the files report instead of re-opening the run or restarting the pipeline
67
+ // from phase 1. It previously said "Resume it (`--from <slug>`)", and `--from` is a /tech-lead flag
68
+ // that takes a phase, not an init-run flag that takes a slug: the one instruction available at the
69
+ // one moment it mattered named a mechanism that does not parse.
70
+
71
+ import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync } from "node:fs";
72
+ import { join, dirname, resolve } from "node:path";
73
+ import { createHash } from "node:crypto";
74
+ import { decideLane, treeSize } from "./fit.mjs";
75
+ import { runArgs } from "../lib/argv.mjs";
76
+ import { uncoerce } from "../lib/contract.mjs";
77
+ import { deriveSnapshot } from "../reduce/snapshot.mjs";
78
+ import { mintRunId } from "../lib/paths.mjs";
79
+ import { localRoot, activeScope, globLocal, globShared, ordersDir, resultsDir } from "../lib/paths.mjs";
80
+ import { resolveWorkers } from "../verify/skills.mjs";
81
+
82
+ export const RECEIPT_VERSION = 1;
83
+
84
+ const AUTO_LEVELS = new Set(["interactive", "auto", "unattended"]);
85
+ const LENSES = new Set(["lite", "standard", "cross-context"]);
86
+
87
+ /**
88
+ * The eval dimension set when the caller names none. Kept to the base correctness dimension so an
89
+ * unconfigured run behaves exactly as it did before this flag existed.
90
+ */
91
+ export const DEFAULT_DIMENSIONS = ["spec-conformance"];
92
+
93
+ /**
94
+ * Parse `--dimensions` into the set written to the ledger. Shape-validated only, NOT checked against
95
+ * the dimensions that ship: adding `references/dimensions/<id>.md` and naming it here is the
96
+ * documented injection path, so a closed list here would make the evaluator's own extension point
97
+ * unreachable. An id with no file behind it is skipped-with-a-warning at dimension resolution, which
98
+ * is where that check belongs and where it can actually see the files.
99
+ *
100
+ * @param {(string|null|undefined)} raw - The comma-separated flag value; absent → the default set.
101
+ * @returns {string[]} Trimmed, de-duplicated ids in the caller's order.
102
+ * @throws {Error} If the list is empty or an entry is not a kebab-case id.
103
+ */
104
+ export function parseDimensions(raw) {
105
+ if (raw === null || raw === undefined) return [...DEFAULT_DIMENSIONS];
106
+ const ids = String(raw).split(",").map((s) => s.trim()).filter(Boolean);
107
+ if (!ids.length) throw new Error("--dimensions: empty list — omit the flag to use the default [spec-conformance]");
108
+ for (const id of ids) {
109
+ if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(id)) {
110
+ throw new Error(`--dimensions: "${id}" is not a dimension id (kebab-case, e.g. spec-conformance, tdd-surface, integration)`);
111
+ }
112
+ }
113
+ return [...new Set(ids)];
114
+ }
115
+
116
+ /** Slugify a free-text feature name into a filesystem-safe run id. */
117
+ export function slugify(text) {
118
+ return String(text || "")
119
+ .toLowerCase()
120
+ .replace(/[^a-z0-9]+/g, "-")
121
+ .replace(/^-+|-+$/g, "")
122
+ .slice(0, 48) || "run";
123
+ }
124
+
125
+ export function digest(text) {
126
+ return createHash("sha256").update(String(text ?? ""), "utf8").digest("hex");
127
+ }
128
+
129
+ /**
130
+ * Build the receipt record. Pure — takes resolved inputs, returns the object that gets written.
131
+ * Kept separate from I/O so the structural tests can assert its shape without a filesystem.
132
+ */
133
+ export function buildReceipt({ slug, intake, config, startedAt, plugin = null }) {
134
+ const intakeText = String(intake ?? "");
135
+ const intakeSha256 = digest(intakeText);
136
+ return {
137
+ receipt_version: RECEIPT_VERSION,
138
+ type: "harness-run-receipt",
139
+ slug,
140
+ // THE JOIN KEY, minted here because this is where a run acquires an identity at all.
141
+ //
142
+ // `order_id` was the nearest thing the harness had, and it is `<slug>/r<N>-a<M>`: unique
143
+ // WITHIN a run and identical across every run of the same slug. So every record the pipeline
144
+ // writes — orders, results, journal rows, trial rows, hook receipts — could be grouped by
145
+ // feature and never by RUN, which makes "compare this run against the last one" and "what did
146
+ // this run cost" both unanswerable from data that was otherwise all present.
147
+ //
148
+ // Derived, not drawn (see `mintRunId` in lib/paths.mjs): a pure function of the three fields below
149
+ // it, so any writer holding this receipt recomputes the same id without being handed it, and a
150
+ // receipt written before this field existed still yields the id it would have been given.
151
+ run_id: mintRunId({ slug, startedAt, intakeSha256 }),
152
+ started_at: startedAt,
153
+ intake_sha256: intakeSha256,
154
+ intake_chars: intakeText.length,
155
+ intake_lines: intakeText ? intakeText.split("\n").length : 0,
156
+ // The single fact that separates "the harness ran" from "the harness described itself".
157
+ // Written before any gate, so its ABSENCE at Stop is unambiguous.
158
+ started: true,
159
+ // WHICH COPY OF THE PLUGIN PRODUCED THIS RUN. Recorded while it is in hand, because afterwards
160
+ // it is unrecoverable: every artifact a run leaves looks identical whether it came from this
161
+ // version, a stale marketplace install, or a sub-agent improvising past a failed dispatch. A
162
+ // run whose trace cannot name its own plugin cannot be compared with another run, and cannot be
163
+ // cleared of the wrong-version failure the roster check exists to catch.
164
+ plugin: plugin ? { name: plugin.name, version: plugin.version, root: plugin.root } : null,
165
+ config,
166
+ };
167
+ }
168
+
169
+ /** The `harness-run.md` frontmatter block, per references/protocol.md Part 4 — State. */
170
+ export function runFrontmatter({ slug, config, startedAt }) {
171
+ return [
172
+ "---",
173
+ "type: harness-run",
174
+ `feature: ${slug}`,
175
+ `spec_folder: ${config.spec_folder}`,
176
+ `lens: ${config.lens}`,
177
+ // The ledger is the ONE place the dimension set lives: resume-state reads it back off this line
178
+ // and the workflow hands it to every evaluate order. Written through the same list dialect the
179
+ // parser reads (contract-md), never hand-joined.
180
+ `eval_dimensions: ${uncoerce(config.eval_dimensions ?? DEFAULT_DIMENSIONS)}`,
181
+ `max_rounds: ${config.max_rounds}`,
182
+ `attempt_budget: ${config.attempt_budget}`,
183
+ `wall_clock_budget_s: ${config.wall_clock_budget_s ?? "~"}`,
184
+ `auto_level: ${config.auto_level}`,
185
+ `gate_answers: ${config.gate_answers ?? "~"}`,
186
+ `lane: ${config.fit?.lane ?? "full"}${config.fit?.overridden_from ? ` (overridden from ${config.fit.overridden_from})` : ""}`,
187
+ "status: orienting",
188
+ "final_verdict: ~",
189
+ "rounds_used: 0",
190
+ "discovered_rounds: 0",
191
+ "deploy: ~",
192
+ `started_at: ${startedAt}`,
193
+ "closed_at: ~",
194
+ "---",
195
+ "",
196
+ `# Harness run — ${slug}`,
197
+ "",
198
+ "Opened by ``harness init run`` (GATE L0.1). The tech lead is the sole writer from here on.",
199
+ "",
200
+ "## Rounds",
201
+ "",
202
+ "| Phase | Round | Result | Duration | Notes |",
203
+ "|-------|-------|--------|----------|-------|",
204
+ "| Init | — | run opened | — | intake recorded, receipt written |",
205
+ "",
206
+ "## Decisions log",
207
+ "",
208
+ "| Gate | Decision | Source | Note |",
209
+ "|------|----------|--------|------|",
210
+ "",
211
+ ].join("\n");
212
+ }
213
+
214
+ /**
215
+ * Unwedge `--force`: resolve every order left LIVE by a run nobody is continuing.
216
+ *
217
+ * `hooks/sandbox-guard.mjs`'s `liveOrders()` treats any file under `orders/` with no SAME-NAMED
218
+ * file under `results/` as live, and constrains every later Edit/Write to what some live order's
219
+ * substrate permits. `--force` used to `mkdirSync(..., {recursive:true})` over the same directories
220
+ * and stop — a no-op on dirs that already exist and already hold the stale order, so the wedge
221
+ * survived it exactly as before. This is the real unwedge path: for every order abandoned by the run
222
+ * being forced over, write a same-named record under `results/` so `liveOrders()` no longer counts
223
+ * it, without deleting the order file itself (`orders/` is this codebase's own audit trail of what
224
+ * was dispatched, not a rolling buffer — losing the file loses the record that a dispatch happened).
225
+ *
226
+ * Not a real WorkResult: `work-result.schema.json`'s `status` enum (done/partial/escalated/failed)
227
+ * has no member that honestly means "no worker ever answered" — every one of those values would
228
+ * misrepresent an abandoned dispatch as an attempt that actually ran. So this writes a plainly
229
+ * self-labelled, intentionally non-conforming marker instead of forcing a lie into a schema-valid
230
+ * shape. `liveOrders()` only checks filename presence under `results/`, never content, so this is
231
+ * sufficient to unwedge on its own.
232
+ *
233
+ * @param {string} cwd - Project root.
234
+ * @param {string} slug - The run being forced over.
235
+ * @returns {string[]} Order-id suffixes (filenames minus `.json`) resolved as abandoned.
236
+ */
237
+ export function resolveAbandonedOrders(cwd, slug) {
238
+ const oDir = ordersDir(cwd, slug);
239
+ const rDir = resultsDir(cwd, slug);
240
+ if (!existsSync(oDir)) return [];
241
+ const done = new Set(existsSync(rDir) ? readdirSync(rDir) : []);
242
+ const resolvedAt = new Date().toISOString();
243
+ const resolved = [];
244
+ for (const f of readdirSync(oDir)) {
245
+ if (!f.endsWith(".json") || done.has(f)) continue;
246
+ mkdirSync(rDir, { recursive: true });
247
+ const marker = {
248
+ synthetic: true,
249
+ status: "abandoned", // not in work-result.schema.json's enum — deliberately: see banner above
250
+ order_id: f.slice(0, -".json".length),
251
+ reason: "dispatched, never answered — resolved by `harness init run --force`",
252
+ resolved_at: resolvedAt,
253
+ };
254
+ writeFileSync(join(rDir, f), JSON.stringify(marker, null, 2) + "\n", "utf8");
255
+ resolved.push(marker.order_id);
256
+ }
257
+ return resolved;
258
+ }
259
+
260
+ // ---- CLI -------------------------------------------------------------------
261
+
262
+ /** The typed argv contract (see `./lib/argv.mjs`). */
263
+ export const ARGV_SPEC = {
264
+ usage: 'harness.mjs init run (--intake-file <path> | --intake-text "<req>" | --intake-stdin) ' +
265
+ "[--slug <slug>] [--auto-level interactive|auto|unattended] [--lens <lens>] " +
266
+ "[--max-rounds N] [--attempts N] [--spec-folder <dir>] [--dimensions <a,b>] " +
267
+ "[--gate-answers <preset|path>] " +
268
+ "[--lane full|tiny] [--tiny] [--wall-clock-budget <seconds>] [--cwd <dir>] " +
269
+ "[--plugin-root <dir>] [--force]",
270
+ _: { arity: 0, max: 0, name: "(no positional operands)" },
271
+ cwd: { type: "path" },
272
+ "intake-text": { type: "str" },
273
+ "intake-file": { type: "str" }, // "-" is a legitimate value here (stdin), so not type "path"
274
+ "intake-stdin": { type: "flag" },
275
+ slug: { type: "str" },
276
+ "auto-level": { type: "str" },
277
+ lens: { type: "str" },
278
+ "max-rounds": { type: "int", min: 1 },
279
+ attempts: { type: "int", min: 1 },
280
+ "spec-folder": { type: "path" },
281
+ dimensions: { type: "str" },
282
+ "gate-answers": { type: "str" },
283
+ lane: { type: "str" },
284
+ tiny: { type: "flag" },
285
+ "wall-clock-budget": { type: "int", min: 1 },
286
+ // Which copy of the plugin this run is opened against. Defaults to the one this kernel is part
287
+ // of, which is right for every ordinary invocation; it is nameable because a machine can carry
288
+ // several installs, and because the roster refusal below is otherwise unreachable for a test —
289
+ // a check whose failure path cannot be exercised is a check nobody has seen work.
290
+ "plugin-root": { type: "path" },
291
+ force: { type: "flag" },
292
+ };
293
+
294
+ function fail(code, msg) {
295
+ console.error(msg);
296
+ process.exit(code);
297
+ }
298
+
299
+ /**
300
+ * Open a run: mint the receipt, or refuse (exit 3) when one is already live.
301
+ *
302
+ * @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
303
+ * @returns {(Promise<void>|void)} Settles when the subcommand has written its output; most paths
304
+ * call `process.exit()` with the subcommand's documented code rather than returning.
305
+ */
306
+ export function cli(rawArgv) {
307
+ const args = runArgs(ARGV_SPEC, rawArgv);
308
+ const cwd = args.cwd || process.cwd();
309
+
310
+ // GATE L0 — the worker roster, before any spend.
311
+ //
312
+ // HERE AND NOT IN THE WORKFLOW SCRIPT, for two reasons. This is what GATE L0 actually executes,
313
+ // and it covers both lanes rather than only the orchestrated one — the prose/`--tiny` lane opens
314
+ // its run through exactly this call. And it is genuinely before any spend: the workflow script's
315
+ // own helpers reach the kernel by spawning a sub-agent first, so a check placed there has already
316
+ // paid for a model call before it can refuse.
317
+ //
318
+ // What it can and cannot prove is stated in verify/skills.mjs and is worth repeating where it is
319
+ // enforced: this says the files exist at this root at this version. It does not say the SESSION
320
+ // will resolve that copy. The orchestrator's canary dispatch answers that, and its receipt is the
321
+ // evidence; this refusal is the cheap half that costs nothing to run on every single run.
322
+ const plugin = resolveWorkers(args.pluginRoot ? resolve(args.pluginRoot) : undefined);
323
+ if (plugin.missing.length) {
324
+ fail(3, [
325
+ `✋ init-run: refusing to open a run — ${plugin.missing.length} of ${plugin.workers.length} worker skills are missing.`,
326
+ "",
327
+ ` plugin: ${plugin.name ?? "unknown"} ${plugin.version ?? "unknown version"}`,
328
+ ` root: ${plugin.root}`,
329
+ ` missing: ${plugin.missing.join(", ")}`,
330
+ "",
331
+ "A run against this copy would dispatch workers that cannot resolve, and a failed dispatch is",
332
+ "answered by the sub-agent improvising the craft itself — phases reporting complete with none",
333
+ "of the shipped craft applied. Load the working copy (`claude --plugin-dir <repo>`) or install",
334
+ "and enable the plugin, then retry. `harness verify skills` reports the same thing on demand.",
335
+ ].join("\n"));
336
+ }
337
+
338
+ let intake = args.intakeText ?? null;
339
+ const intakeFile = args.intakeFile ?? null;
340
+ // `--intake-file -` is the shape everyone reaches for; accept it rather than erroring on a
341
+ // file literally named "-". (Measured: an agent tried exactly this on its second attempt.)
342
+ if (args.intakeStdin || intakeFile === "-") {
343
+ try { intake = readFileSync(0, "utf8"); } catch { fail(2, "--intake-stdin: nothing on stdin"); }
344
+ } else if (intakeFile) {
345
+ const p = intakeFile.startsWith("/") ? intakeFile : join(cwd, intakeFile);
346
+ if (!existsSync(p)) fail(2, `--intake-file not found: ${p}`);
347
+ intake = readFileSync(p, "utf8");
348
+ }
349
+ if (!intake || !intake.trim()) {
350
+ fail(2, [
351
+ "✋ init-run: no intake. Pass --intake-text \"<the requirement>\" or --intake-file <path>.",
352
+ "",
353
+ "An orchestrator with no spec has nothing to orchestrate — this is the same precondition",
354
+ "GATE L0.0 (hooks/gate-intake.mjs) enforces at dispatch, re-checked here where the run is",
355
+ "actually opened.",
356
+ ].join("\n"));
357
+ }
358
+
359
+ const slug = (args.slug ?? null) || slugify(intake.split("\n").find((l) => l.trim()) || "run");
360
+
361
+ const auto_level = args.autoLevel ?? "interactive";
362
+ if (!AUTO_LEVELS.has(auto_level)) fail(2, `--auto-level must be one of: ${[...AUTO_LEVELS].join(", ")}`);
363
+ const lens = args.lens ?? "standard";
364
+ if (!LENSES.has(lens)) fail(2, `--lens must be one of: ${[...LENSES].join(", ")}`);
365
+ // GATE L0.5 — the eval dimension set, resolved ONCE here and carried by the ledger. Before this
366
+ // flag the line was a constant, so a dimension the PO asked for at L0.5 had nowhere to be
367
+ // recorded and the run graded spec-conformance whatever the answer had been.
368
+ let eval_dimensions;
369
+ try { eval_dimensions = parseDimensions(args.dimensions ?? null); }
370
+ catch (e) { fail(2, e.message); }
371
+
372
+ const config = {
373
+ auto_level,
374
+ lens,
375
+ eval_dimensions,
376
+ max_rounds: args.maxRounds ?? 3,
377
+ attempt_budget: args.attempts ?? 5,
378
+ spec_folder: args.specFolder ?? `${globShared(slug, "spec")}/`,
379
+ gate_answers: args.gateAnswers ?? null,
380
+ tiny_lane: !!args.tiny,
381
+ // GATE L0.3 — the lane, computed rather than judged (see `harness init fit`). Recorded with its
382
+ // evidence so a heavy lane on a small change is visible instead of accidental. An explicit
383
+ // --lane or --tiny is honoured and marked as an override, because a measured recommendation
384
+ // fitted on three features must not outrank a human who knows the codebase.
385
+ fit: (() => {
386
+ const auto = decideLane({ intake, files: treeSize(cwd) });
387
+ const forced = (args.lane ?? null) || (args.tiny ? "tiny" : null);
388
+ return forced && forced !== auto.lane
389
+ ? { ...auto, lane: forced, overridden_from: auto.lane, override_source: args.tiny ? "--tiny" : "--lane" }
390
+ : auto;
391
+ })(),
392
+ // The third breaker (see `harness verify budget`). Null = off, which is the default and
393
+ // keeps every existing run behaving exactly as before. Set it in any lane with a hard clock
394
+ // — CI, an overnight run — so the harness trips its own breaker and ships what
395
+ // is green, instead of being killed from outside and shipping nothing.
396
+ wall_clock_budget_s: args.wallClockBudget ?? null,
397
+ };
398
+
399
+ const runRoot = localRoot(cwd, slug);
400
+ const receiptPath = join(runRoot, "receipt.json");
401
+ // A RUN IS ALREADY OPEN. This is the resume path, and it used to be a dead end.
402
+ //
403
+ // The refusal is right: silently re-initialising would discard the round history the circuit
404
+ // breaker counts against. What was wrong was the instruction it gave — "Resume it (`--from
405
+ // <slug>`)". `--from` is not an init-run flag at all; it is a `/tech-lead` flag, and it takes a
406
+ // PHASE (`--from build`), not a slug. So at the one moment the orchestrator most needs a next
407
+ // step, the runtime named a mechanism that does not exist, on a script whose failure mode was
408
+ // already invisible — under a symlinked install an entry-point guard once skipped this whole body.
409
+ //
410
+ // Observed consequence on a handoff: a fresh session in a workspace with an open run burns most
411
+ // of its budget before its first write, largely on forensics against this step, and closes none
412
+ // of the gap.
413
+ //
414
+ // So the refusal now DOES the resume work instead of describing it. It emits the derived snapshot
415
+ // — the same file-only derivation `harness reduce graph --subgraph run` injects — so the orchestrator gets
416
+ // slug, status, round, attempt, board counts and pending orders in THIS tool call rather than
417
+ // needing to discover that it needs another one. Exit 3 still means "do not proceed as if you
418
+ // opened a run"; it now also means "here is the run you are actually in".
419
+ if (existsSync(receiptPath) && !args.force) {
420
+ let resume = null;
421
+ try { resume = deriveSnapshot(cwd); } catch { /* a broken run must still produce the refusal */ }
422
+ fail(3, [
423
+ `✋ init-run: a run is ALREADY OPEN — receipt exists at ${receiptPath}.`,
424
+ "",
425
+ "Do NOT re-initialise and do NOT restart the pipeline from phase 1. Re-opening would discard",
426
+ "the round history the circuit breaker counts against, and the board, ledger and receipt below",
427
+ "already hold the run's real state. RESUME from the phase these files report.",
428
+ "",
429
+ resume
430
+ ? `RESUME STATE (derived from files, never from memory):\n${JSON.stringify(resume, null, 2)}`
431
+ : [
432
+ "The receipt exists but no run state could be derived, which means the run root is",
433
+ "incomplete. Inspect it before deciding:",
434
+ ` ls -R ${runRoot}`,
435
+ ].join("\n"),
436
+ "",
437
+ "To re-derive this at any time:",
438
+ " node <plugin>/kernel/harness.mjs reduce snapshot --cwd <dir>",
439
+ "To abandon the open run and start over, deliberately: --force",
440
+ ].join("\n"));
441
+ }
442
+
443
+ // THE REAL UNWEDGE PATH. `--force` used to `mkdirSync(..., {recursive:true})` over orders/,
444
+ // results/ and discovery/ and stop — a no-op on directories that already exist and already hold a
445
+ // dispatched-but-unanswered order, so `sandbox-guard.mjs`'s `liveOrders()` kept constraining every
446
+ // later write to that stale order's substrate regardless of `--force`. Resolve every such order
447
+ // BEFORE the fresh run starts writing (see `resolveAbandonedOrders()` above for why this writes a
448
+ // marker under `results/` rather than deleting the order file).
449
+ if (args.force) {
450
+ const abandoned = resolveAbandonedOrders(cwd, slug);
451
+ if (abandoned.length) {
452
+ console.error(
453
+ `⚠ init-run --force: resolved ${abandoned.length} dispatched-but-unanswered order(s) as abandoned — ${abandoned.join(", ")}`,
454
+ );
455
+ }
456
+ }
457
+
458
+ const startedAt = new Date().toISOString();
459
+ const receipt = buildReceipt({ slug, intake, config, startedAt, plugin });
460
+
461
+ mkdirSync(runRoot, { recursive: true });
462
+ mkdirSync(join(runRoot, "orders"), { recursive: true });
463
+ mkdirSync(join(runRoot, "results"), { recursive: true });
464
+ mkdirSync(join(runRoot, "discovery"), { recursive: true });
465
+
466
+ // The intake, verbatim. So "the spec was dropped on the hand-off" is a checkable claim.
467
+ writeFileSync(join(runRoot, "intake.md"), intake.endsWith("\n") ? intake : intake + "\n", "utf8");
468
+ writeFileSync(receiptPath, JSON.stringify(receipt, null, 2) + "\n", "utf8");
469
+ writeFileSync(join(runRoot, "harness-run.md"), runFrontmatter({ slug, config, startedAt }), "utf8");
470
+
471
+ // The pointer every downstream guard reads to answer "is a run active?".
472
+ const pointer = activeScope(cwd);
473
+ mkdirSync(dirname(pointer), { recursive: true });
474
+ writeFileSync(pointer, JSON.stringify({ slug, started_at: startedAt }, null, 2) + "\n", "utf8");
475
+
476
+ console.log(JSON.stringify({
477
+ ok: true,
478
+ slug,
479
+ // Echoed so the orchestrator can put it in RunArgs without re-reading the receipt.
480
+ run_id: receipt.run_id,
481
+ run_root: globLocal(slug),
482
+ receipt: globLocal(slug, "receipt.json"),
483
+ intake_sha256: receipt.intake_sha256,
484
+ intake_chars: receipt.intake_chars,
485
+ config,
486
+ next: "GATE L0 — pin the run config, emit the gate block, then ORIENT.",
487
+ }, null, 2));
488
+ }
489
+
@@ -1,44 +1,27 @@
1
- // parseArgs — the typed argv boundary.
1
+ // argv — the typed CLI boundary: who is running, and with what.
2
2
  //
3
- // WHY THIS FILE EXISTS (measured by executing the shipped scripts, not theorized).
4
- //
5
- // This project's envelope boundary is rigorously typed: 38 `$defs` in `domain.schema.json`,
6
- // both directions validated, and a `PreToolUse` hook that DENIES a malformed WorkOrder before a
7
- // worker ever sees it. That discipline stopped dead at `process.argv` — which is where the
8
- // pipeline actually executes.
9
- //
10
- // The reproduced defect. `t0-verify.mjs` parsed its own flags with
11
- //
12
- // if (a === "--round") out.round = Number(argv[++i]); // then: args.round ?? 1
13
- //
14
- // `Number(undefined)` is `NaN`, and `??` does not catch `NaN`. So passing a flag WITHOUT a value
15
- // wrote a real verdict artifact to `t0/verdicts/rNaN-a1.json` and **exited 0**:
16
- //
17
- // $ t0-verify.mjs contract.json --round --attempt 1
18
- // { "path": "t0/verdicts/rNaN-a1.json", "overall": "green", ... } exit=0
19
- //
20
- // The orchestrator then looks for `r1-a1.json`, finds nothing, and the evaluator's MANDATORY T0
21
- // citation cannot resolve — on the single artifact the judge is structurally required to cite.
22
- // A green verdict at an address nobody will look up is the same failure class as a silent no-op:
23
- // indistinguishable from working.
24
- //
25
- // THE CONTRACT, mirroring `validate-envelope`'s: reject BEFORE anything runs, exit 2, and put a
3
+ // CONTRACT, mirroring the envelope's: reject BEFORE anything runs, exit 2, and put a
26
4
  // machine-readable reason on stderr. Nothing here touches the filesystem, so a rejected parse
27
- // cannot leave a half-written artifact behind.
5
+ // cannot leave a half-written artifact behind. Exit 2 is this plugin's "the input was malformed,
6
+ // nothing ran" code — deliberately NOT 1, which every oracle uses for "ran, and the answer is no".
28
7
  //
29
8
  // const SPEC = {
30
9
  // _: { arity: 1, name: "scope-contract.json" },
31
10
  // round: { type: "int", min: 1, required: true },
32
11
  // "no-seesaw": { type: "flag" },
33
12
  // };
34
- // const args = runArgs(SPEC, process.argv.slice(2)); // args.round, args.noSeesaw, args._
13
+ // const args = runArgs(SPEC, argv); // args.round, args.noSeesaw, args._
35
14
  //
36
- // Flag names reach the caller camelCased (`--no-seesaw` → `noSeesaw`), so adoption does not
37
- // rewrite call sites. Unknown flags are rejected rather than silently swallowed as positionals:
38
- // a typo'd `--rounds 2` that lands in `_` is the same defect wearing a different hat.
15
+ // Flag names reach the caller camelCased (`--no-seesaw` → `noSeesaw`). Unknown flags are rejected
16
+ // rather than swallowed as positionals: a typo'd `--rounds 2` landing in `_` is the same defect
17
+ // wearing a different hat. Untyped coercion is the failure this guards `Number(undefined)` is
18
+ // `NaN`, `??` does not catch `NaN`, and a verdict written to `r NaN-a1.json` with exit 0 is
19
+ // indistinguishable from working.
39
20
  //
40
- // `tests/structural/13-argv-contract.mjs` executes every entry point with each declared int flag
41
- // both empty and non-numeric, and asserts exit 2 with a parseable reason and no artifact on disk.
21
+ // {@link isMain} answers the other half of the boundary question: was this module executed, or
22
+ // imported? Comparing `import.meta.url` to a raw `file://${process.argv[1]}` is false under a
23
+ // symlinked install path (macOS `/var`, nvm, pnpm, Homebrew) and under any path containing a space,
24
+ // which silently turns an entry point into a no-op that still exits 0.
42
25
 
43
26
  /** Thrown by {@link parseArgs} when argv does not satisfy the spec. `detail` is the wire shape. */
44
27
  export class ArgvError extends Error {
@@ -222,3 +205,41 @@ export function runArgs(spec, argv = process.argv.slice(2)) {
222
205
  process.exit(2);
223
206
  }
224
207
  }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // isMain — "was this module executed directly, or imported?"
211
+ // ---------------------------------------------------------------------------
212
+
213
+ import { realpathSync } from "node:fs";
214
+ import { pathToFileURL } from "node:url";
215
+
216
+ /**
217
+ * True when `moduleUrl` belongs to the module Node was asked to execute.
218
+ *
219
+ * Resolves both sides through `pathToFileURL` (so percent-encoding matches) and `realpathSync` (so
220
+ * symlinks match), which is what makes the guard hold where the naive string compare does not.
221
+ *
222
+ * @param {string} moduleUrl - The caller's `import.meta.url`.
223
+ * @returns {boolean} true if executed directly, false if imported (or if there is no entry point,
224
+ * e.g. `node --eval`, where nothing should auto-run).
225
+ */
226
+ export function isMain(moduleUrl) {
227
+ const entry = process.argv[1];
228
+ if (!entry || !moduleUrl) return false;
229
+
230
+ // Cheap path first: correct encoding, no filesystem access. Handles spaces and unicode.
231
+ let entryUrl;
232
+ try { entryUrl = pathToFileURL(entry).href; } catch { return false; }
233
+ if (entryUrl === moduleUrl) return true;
234
+
235
+ try {
236
+ const realEntry = pathToFileURL(realpathSync(entry)).href;
237
+ if (realEntry === moduleUrl) return true;
238
+ return realEntry === pathToFileURL(realpathSync(new URL(moduleUrl))).href;
239
+ } catch {
240
+ // An unreadable or deleted entry point is not this function's problem to report. Returning
241
+ // false means "do not auto-run", which is the safe direction for an imported module and is
242
+ // never the direction that silently skips a gate.
243
+ return false;
244
+ }
245
+ }