shapeup-sdlc 1.7.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/.claude/settings.local.example.json +5 -5
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/AGENTS.md +17 -3
  4. package/README.md +124 -103
  5. package/SECURITY.md +37 -30
  6. package/bin/init.mjs +77 -47
  7. package/bin/lib/grant.mjs +145 -0
  8. package/commands/build.md +7 -6
  9. package/commands/ship.md +29 -16
  10. package/commands/wire.md +1 -1
  11. package/hooks/dispatch-receipt.mjs +195 -0
  12. package/hooks/gate-intake.mjs +3 -3
  13. package/hooks/gate-zerowork.mjs +25 -24
  14. package/hooks/hooks.json +9 -48
  15. package/hooks/lib/decision.mjs +19 -8
  16. package/hooks/safety-spine.mjs +3 -3
  17. package/hooks/sandbox-guard.mjs +84 -40
  18. package/{skills/tech-lead/scripts/compile-order.mjs → kernel/compile.mjs} +283 -41
  19. package/{skills/tech-lead/scripts/gate-answers.mjs → kernel/gate.mjs} +58 -14
  20. package/kernel/harness.mjs +134 -0
  21. package/{skills/tech-lead/scripts/fit-check.mjs → kernel/init/fit.mjs} +15 -12
  22. package/{skills/tech-lead/scripts/init-run.mjs → kernel/init/run.mjs} +152 -31
  23. package/{skills/tech-lead/scripts → kernel}/lib/argv.mjs +52 -31
  24. package/{skills/tech-lead/scripts/lib/contract-md.mjs → kernel/lib/contract.mjs} +251 -7
  25. package/kernel/lib/paths.mjs +491 -0
  26. package/kernel/probe/concurrency.mjs +510 -0
  27. package/{skills/tech-lead/scripts/aegis-digest.mjs → kernel/probe/digest.mjs} +9 -10
  28. package/kernel/probe/eval.mjs +77 -0
  29. package/kernel/probe/leg.mjs +125 -0
  30. package/{skills/tech-lead/scripts/resume-state.mjs → kernel/probe/resume.mjs} +204 -63
  31. package/{skills/tech-lead/scripts → kernel/probe}/stats.mjs +93 -11
  32. package/kernel/probe/t0.mjs +66 -0
  33. package/{skills/ba-pitch-analyzer/scripts/board-derive.mjs → kernel/reduce/board.mjs} +99 -23
  34. package/kernel/reduce/graph.mjs +437 -0
  35. package/{skills/tech-lead/scripts/hill-derive.mjs → kernel/reduce/hill.mjs} +28 -9
  36. package/kernel/reduce/ingest.mjs +633 -0
  37. package/{hooks/slop-cleaner.mjs → kernel/reduce/leftovers.mjs} +40 -57
  38. package/{skills/tech-lead/scripts/ship-report.mjs → kernel/reduce/ship.mjs} +61 -13
  39. package/{skills/tech-lead/scripts/run-snapshot.mjs → kernel/reduce/snapshot.mjs} +21 -15
  40. package/{skills/spec-evaluator/scripts/verdict-ledger.mjs → kernel/reduce/verdict.mjs} +13 -7
  41. package/kernel/report/export.mjs +325 -0
  42. package/kernel/report/facts.mjs +347 -0
  43. package/{skills/tech-lead/scripts/budget-check.mjs → kernel/verify/budget.mjs} +15 -12
  44. package/kernel/verify/dispatch.mjs +114 -0
  45. package/{skills/tech-lead/scripts/validate-envelope.mjs → kernel/verify/envelope.mjs} +20 -15
  46. package/{skills/tech-lead/scripts/lib → kernel/verify}/ratchet-tree.mjs +36 -14
  47. package/kernel/verify/skills.mjs +125 -0
  48. package/kernel/verify/spec.mjs +559 -0
  49. package/{skills/tech-lead/scripts/t0-verify.mjs → kernel/verify/t0.mjs} +134 -24
  50. package/{skills/tech-lead/scripts/trace-lint.mjs → kernel/verify/trace.mjs} +26 -11
  51. package/oracles/http-oracle.mjs +1 -1
  52. package/oracles/process-oracle.mjs +1 -1
  53. package/oracles/snapshot-oracle.mjs +1 -1
  54. package/oracles/test-oracle.mjs +1 -1
  55. package/package.json +11 -9
  56. package/skills/ba-pitch-analyzer/SKILL.md +5 -5
  57. package/skills/ba-pitch-analyzer/assets/templates/_index.tmpl.md +1 -1
  58. package/skills/ba-pitch-analyzer/assets/templates/api-feasibility.tmpl.md +12 -7
  59. package/skills/ba-pitch-analyzer/assets/templates/contracts/third-party-api.contract.tmpl.md +6 -3
  60. package/skills/ba-pitch-analyzer/assets/templates/cross-context/migration-plan.tmpl.md +5 -4
  61. package/skills/ba-pitch-analyzer/assets/templates/cross-context/team-handoff.tmpl.md +23 -13
  62. package/skills/ba-pitch-analyzer/assets/templates/scope-summary.tmpl.md +17 -12
  63. package/skills/ba-pitch-analyzer/assets/templates/synthesis.tmpl.md +17 -38
  64. package/skills/ba-pitch-analyzer/assets/templates/task.tmpl.md +3 -0
  65. package/skills/ba-pitch-analyzer/references/contract-patterns.md +8 -4
  66. package/skills/ba-pitch-analyzer/references/doc-schemas.md +2 -0
  67. package/skills/ba-pitch-analyzer/references/task-generation.md +2 -2
  68. package/skills/qa-edge-hunter/SKILL.md +7 -3
  69. package/skills/scope-architect/SKILL.md +40 -6
  70. package/skills/solution-architect/SKILL.md +19 -4
  71. package/skills/spec-evaluator/SKILL.md +12 -2
  72. package/skills/task-executor/SKILL.md +7 -6
  73. package/skills/tech-lead/SKILL.md +50 -38
  74. package/skills/tech-lead/references/gates.md +69 -20
  75. package/skills/tech-lead/references/protocol.md +832 -0
  76. package/skills/tech-lead/schemas/domain.schema.json +341 -66
  77. package/skills/tech-lead/schemas/work-order.schema.json +11 -2
  78. package/skills/tech-lead/schemas/work-result.schema.json +1 -1
  79. package/skills/tech-lead/workflows/shapeup-run.js +1313 -750
  80. package/hooks/anti-rationalization.mjs +0 -238
  81. package/hooks/compact-snapshot.mjs +0 -47
  82. package/hooks/gate-deadline.mjs +0 -147
  83. package/hooks/gate-l2.mjs +0 -161
  84. package/hooks/session-rehydrate.mjs +0 -108
  85. package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +0 -239
  86. package/skills/spec-evaluator/README.md +0 -93
  87. package/skills/tech-lead/README.md +0 -71
  88. package/skills/tech-lead/references/delegation.md +0 -261
  89. package/skills/tech-lead/references/hard-rules.md +0 -34
  90. package/skills/tech-lead/references/invocation.md +0 -45
  91. package/skills/tech-lead/references/ledger-schema.md +0 -213
  92. package/skills/tech-lead/references/round-protocol.md +0 -234
  93. package/skills/tech-lead/references/state-model.md +0 -66
  94. package/skills/tech-lead/scripts/ingest-result.mjs +0 -258
  95. package/skills/tech-lead/scripts/lib/is-main.mjs +0 -81
  96. package/skills/tech-lead/scripts/lib/paths.mjs +0 -280
  97. package/skills/tech-lead/scripts/run-workflow.mjs +0 -381
  98. package/skills/translator/README.md +0 -66
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+ // harness — the kernel's single entry point.
3
+ //
4
+ // WHAT THIS IS. Everything in the pipeline that must be DETERMINISTIC — measured rather than
5
+ // claimed, single-writer rather than negotiated, an answer file rather than a vibe — is a
6
+ // subcommand of this one script:
7
+ //
8
+ // node ${CLAUDE_PLUGIN_ROOT}/kernel/harness.mjs <verb> [<action>] [flags]
9
+ //
10
+ // WHY ONE ENTRY POINT. A permission rule is matched against a command string. With one executable
11
+ // the entire grant is two lines a person can read (`bin/lib/grant.mjs`); with twenty it is forty
12
+ // rules that must be regenerated on every add, rename or removal — and a rule that silently
13
+ // matches nothing is indistinguishable from a rule that works until the first dispatch fails.
14
+ // Subcommands are free; entry points are not.
15
+ //
16
+ // WHO CALLS IT. Worker skills, from their own shells, and the hooks. The control plane — the
17
+ // Workflow script — calls no shell at all: it branches on the structured return of an `agent()`
18
+ // and never parses stdout.
19
+ //
20
+ // THE VERBS, and the invariant each one exists to keep:
21
+ //
22
+ // verify t0 · budget · envelope · Measured, not claimed. A model verifying itself is
23
+ // trace · spec · skills · claiming; these read artifacts and re-hash them.
24
+ // dispatch `skills` reads the roster off disk; `dispatch` reads
25
+ // the hook layer's evidence that a skill really resolved
26
+ // in this session — the half a file check cannot answer.
27
+ // reduce ingest · hill · snapshot · Single writer. Shared state has exactly one author.
28
+ // ship · board · verdict · graph
29
+ // gate An answer file with a source, not a vibe.
30
+ // probe resume · t0 · stats · digest · Read-only queries over run state. `concurrency`
31
+ // concurrency · leg · eval answers how many legs ran at once and what the
32
+ // fan-out bought, and refuses a figure the record set
33
+ // cannot support rather than printing a plausible one.
34
+ // `leg` answers whether a scope's work reached the
35
+ // board — a green T0 says its fixtures passed, not that
36
+ // its result was ever applied, and a leg that skipped
37
+ // its own ingest step is indistinguishable from one that
38
+ // did not until something reads the writer's own record.
39
+ // `eval` answers what an EVAL round's WorkResult
40
+ // actually said, mechanically — the round-loop branch
41
+ // reads this instead of trusting a dispatching agent's
42
+ // own end-of-turn summary of its own verdict.
43
+ // init run · fit Opens a run, or refuses it (exit 3).
44
+ // report export Projects the run's records as fact tables.
45
+ // compile The WorkOrder: schema-valid or nothing is dispatched.
46
+ //
47
+ // EXIT CODES are the subcommand's own and are part of its contract: 0 success · 1 ran, answer is
48
+ // no · 2 malformed input, nothing ran (`lib/argv.mjs`) · 3 `init run` refused to open · 4/5 gate
49
+ // stop/abort · 6 budget tripped. This dispatcher adds exactly one of its own: 2 for an unknown
50
+ // verb, which is the same "the input was malformed" case.
51
+
52
+ import { isMain } from "./lib/argv.mjs";
53
+
54
+ /**
55
+ * verb → action → module, the whole routing table.
56
+ *
57
+ * A verb whose value is a string takes no action word (`gate`, `compile`); a verb whose value is an
58
+ * object requires one, and `_default` names the action taken when it is omitted.
59
+ *
60
+ * Exported so the structural suite enumerates the subcommands from THIS table rather than from a
61
+ * hand-kept list beside it — a second copy of the routing is a second thing to forget.
62
+ */
63
+ export const ROUTES = {
64
+ verify: {
65
+ t0: "./verify/t0.mjs", budget: "./verify/budget.mjs", envelope: "./verify/envelope.mjs",
66
+ trace: "./verify/trace.mjs", spec: "./verify/spec.mjs", skills: "./verify/skills.mjs",
67
+ dispatch: "./verify/dispatch.mjs",
68
+ },
69
+ reduce: {
70
+ ingest: "./reduce/ingest.mjs", hill: "./reduce/hill.mjs", snapshot: "./reduce/snapshot.mjs",
71
+ ship: "./reduce/ship.mjs", board: "./reduce/board.mjs", verdict: "./reduce/verdict.mjs",
72
+ graph: "./reduce/graph.mjs",
73
+ },
74
+ probe: {
75
+ resume: "./probe/resume.mjs", t0: "./probe/t0.mjs", stats: "./probe/stats.mjs",
76
+ digest: "./probe/digest.mjs", concurrency: "./probe/concurrency.mjs",
77
+ leg: "./probe/leg.mjs", eval: "./probe/eval.mjs",
78
+ },
79
+ init: { run: "./init/run.mjs", fit: "./init/fit.mjs" },
80
+ report: { export: "./report/export.mjs", _default: "export" },
81
+ gate: "./gate.mjs",
82
+ compile: "./compile.mjs",
83
+ };
84
+
85
+ /** Every `<verb> <action>` pair, for the usage text and the unknown-verb rejection. */
86
+ function usage() {
87
+ const lines = [];
88
+ for (const [verb, target] of Object.entries(ROUTES)) {
89
+ if (typeof target === "string") lines.push(` ${verb}`);
90
+ else lines.push(` ${verb} ${Object.keys(target).filter((a) => a !== "_default").join(" | ")}`);
91
+ }
92
+ return `usage: harness.mjs <verb> [<action>] [flags]\n${lines.join("\n")}\n\nEvery subcommand accepts --help through its own argv spec.`;
93
+ }
94
+
95
+ /**
96
+ * Reject an invocation before anything runs, the same way `lib/argv.mjs` rejects a bad flag.
97
+ * @param {object} detail - Machine-readable rejection record.
98
+ * @returns {never} Exits 2.
99
+ */
100
+ function reject(detail) {
101
+ process.stderr.write(JSON.stringify(detail) + "\n");
102
+ process.stderr.write(usage() + "\n");
103
+ process.exit(2);
104
+ }
105
+
106
+ /**
107
+ * Resolve `<verb> [<action>]` to a module path and the argv the subcommand should see.
108
+ *
109
+ * @param {string[]} argv - `process.argv.slice(2)`.
110
+ * @returns {{module: string, rest: string[]}} The module to import and its own arguments.
111
+ */
112
+ export function route(argv) {
113
+ const [verb, ...rest] = argv;
114
+ if (!verb || verb === "--help" || verb === "-h") { process.stdout.write(usage() + "\n"); process.exit(0); }
115
+ const target = ROUTES[verb];
116
+ if (!target) reject({ error: "unknown_verb", verb, expected: Object.keys(ROUTES).join(" | ") });
117
+ if (typeof target === "string") return { module: target, rest };
118
+
119
+ const action = rest[0] && !rest[0].startsWith("-") ? rest[0] : target._default;
120
+ const module = action ? target[action] : null;
121
+ if (!module) {
122
+ reject({
123
+ error: action ? "unknown_action" : "missing_action", verb, action: action ?? null,
124
+ expected: Object.keys(target).filter((a) => a !== "_default").join(" | "),
125
+ });
126
+ }
127
+ return { module, rest: rest[0] === action ? rest.slice(1) : rest };
128
+ }
129
+
130
+ if (isMain(import.meta.url)) {
131
+ const { module, rest } = route(process.argv.slice(2));
132
+ const mod = await import(module);
133
+ await mod.cli(rest);
134
+ }
@@ -34,17 +34,16 @@
34
34
  // symmetric, and the tie goes to the gates.
35
35
  //
36
36
  // USAGE
37
- // node fit-check.mjs --intake-file <path> [--cwd <root>] [--json]
38
- // node fit-check.mjs --intake-text "<requirement>" [--cwd <root>]
37
+ // node `harness init fit` --intake-file <path> [--cwd <root>] [--json]
38
+ // node `harness init fit` --intake-text "<requirement>" [--cwd <root>]
39
39
  //
40
40
  // Exit 0 always — this informs a decision, it does not deny a tool call. The orchestrator reads
41
- // `lane` and acts on it; `init-run.mjs` records it in the receipt.
41
+ // `lane` and acts on it; ``harness init run`` records it in the receipt.
42
42
 
43
43
  import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
44
44
  import { join } from "node:path";
45
- import { isMain } from "./lib/is-main.mjs";
46
- import { LOCAL } from "./lib/paths.mjs";
47
- import { runArgs } from "./lib/argv.mjs";
45
+ import { LOCAL } from "../lib/paths.mjs";
46
+ import { runArgs } from "../lib/argv.mjs";
48
47
 
49
48
  /** Directories that are never part of "the tree being changed". */
50
49
  const IGNORE_DIRS = new Set([".git", "node_modules", LOCAL, "dist", "build", ".next", "coverage", ".claude"]);
@@ -160,15 +159,22 @@ export function decideLane({ intake, files }) {
160
159
 
161
160
  /** The typed argv contract (see `./lib/argv.mjs`). */
162
161
  export const ARGV_SPEC = {
163
- usage: 'fit-check.mjs (--intake-file <path> | --intake-text "<requirement>") [--cwd <dir>]',
162
+ usage: 'harness.mjs init fit (--intake-file <path> | --intake-text "<requirement>") [--cwd <dir>]',
164
163
  _: { arity: 0, max: 0, name: "(no positional operands)" },
165
164
  cwd: { type: "path" },
166
165
  "intake-text": { type: "str" },
167
166
  "intake-file": { type: "path" },
168
167
  };
169
168
 
170
- export function main() {
171
- const args = runArgs(ARGV_SPEC);
169
+ /**
170
+ * Decide the lane (full or tiny) for an intake and the tree it will build in.
171
+ *
172
+ * @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
173
+ * @returns {(Promise<void>|void)} Settles when the subcommand has written its output; most paths
174
+ * call `process.exit()` with the subcommand's documented code rather than returning.
175
+ */
176
+ export function cli(rawArgv) {
177
+ const args = runArgs(ARGV_SPEC, rawArgv);
172
178
  const cwd = args.cwd || process.cwd();
173
179
  let intake = args.intakeText ?? null;
174
180
  const f = args.intakeFile ?? null;
@@ -191,6 +197,3 @@ export function main() {
191
197
  }, null, 2));
192
198
  }
193
199
 
194
- if (isMain(import.meta.url)) {
195
- main();
196
- }
@@ -15,7 +15,7 @@
15
15
  //
16
16
  // Two guards existed and neither could see it:
17
17
  // • `gate-intake.mjs` (L0.0) fires on an EMPTY intake. Intake was valid here. Correct no-op.
18
- // • `anti-rationalization.mjs` fires when a completion claim contradicts run facts. It is
18
+ // • the ship report's census fires when a completion claim contradicts run facts. It is
19
19
  // scoped to an ACTIVE run — and a run that never started produces none of the files it
20
20
  // reads — and its claim detector matches past-tense completion ("done", "shipped"), while
21
21
  // narration is future-tense ("it will"). Two independent misses on the same transcript.
@@ -39,9 +39,9 @@
39
39
  // verbatim next to its digest, so "the spec was dropped" is checkable, not arguable.
40
40
  //
41
41
  // USAGE
42
- // node init-run.mjs --slug <slug> --intake-file <path> [options] <- prefer this
43
- // node init-run.mjs --slug <slug> --intake-text "<requirement>" [options]
44
- // cat spec.md | node init-run.mjs --slug <slug> --intake-stdin [options]
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
45
  //
46
46
  // PREFER --intake-file. A multi-line requirement inlined into a shell argument is where this step
47
47
  // goes wrong: quoting breaks, a `#` after a newline trips path validation, and the run spends six
@@ -53,8 +53,8 @@
53
53
  // --attempts N inner per-scope T0 budget (default: 5)
54
54
  // --spec-folder SHARED spec deliverable path (default: shapeup/<slug>/spec/)
55
55
  // --dimensions comma-separated eval dimensions (default: spec-conformance)
56
- // --gate-answers path | preset name (see gate-answers.mjs; recorded, not read)
57
- // --wall-clock-budget N deadline breaker, seconds (off by default; see budget-check.mjs)
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
58
  // --cwd project root (default: process.cwd())
59
59
  // --force re-init over an existing run receipt
60
60
  //
@@ -68,15 +68,16 @@
68
68
  // that takes a phase, not an init-run flag that takes a slug: the one instruction available at the
69
69
  // one moment it mattered named a mechanism that does not parse.
70
70
 
71
- import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
72
- import { join, dirname } from "node:path";
71
+ import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync } from "node:fs";
72
+ import { join, dirname, resolve } from "node:path";
73
73
  import { createHash } from "node:crypto";
74
- import { decideLane, treeSize } from "./fit-check.mjs";
75
- import { isMain } from "./lib/is-main.mjs";
76
- import { runArgs } from "./lib/argv.mjs";
77
- import { uncoerce } from "./lib/contract-md.mjs";
78
- import { deriveSnapshot } from "./run-snapshot.mjs";
79
- import { localRoot, activeScope, globLocal, globShared } from "./lib/paths.mjs";
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";
80
81
 
81
82
  export const RECEIPT_VERSION = 1;
82
83
 
@@ -129,24 +130,43 @@ export function digest(text) {
129
130
  * Build the receipt record. Pure — takes resolved inputs, returns the object that gets written.
130
131
  * Kept separate from I/O so the structural tests can assert its shape without a filesystem.
131
132
  */
132
- export function buildReceipt({ slug, intake, config, startedAt }) {
133
+ export function buildReceipt({ slug, intake, config, startedAt, plugin = null }) {
133
134
  const intakeText = String(intake ?? "");
135
+ const intakeSha256 = digest(intakeText);
134
136
  return {
135
137
  receipt_version: RECEIPT_VERSION,
136
138
  type: "harness-run-receipt",
137
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 }),
138
152
  started_at: startedAt,
139
- intake_sha256: digest(intakeText),
153
+ intake_sha256: intakeSha256,
140
154
  intake_chars: intakeText.length,
141
155
  intake_lines: intakeText ? intakeText.split("\n").length : 0,
142
156
  // The single fact that separates "the harness ran" from "the harness described itself".
143
157
  // Written before any gate, so its ABSENCE at Stop is unambiguous.
144
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,
145
165
  config,
146
166
  };
147
167
  }
148
168
 
149
- /** The `harness-run.md` frontmatter block, per references/ledger-schema.md. */
169
+ /** The `harness-run.md` frontmatter block, per references/protocol.md Part 4 — State. */
150
170
  export function runFrontmatter({ slug, config, startedAt }) {
151
171
  return [
152
172
  "---",
@@ -175,7 +195,7 @@ export function runFrontmatter({ slug, config, startedAt }) {
175
195
  "",
176
196
  `# Harness run — ${slug}`,
177
197
  "",
178
- "Opened by `init-run.mjs` (GATE L0.1). The tech lead is the sole writer from here on.",
198
+ "Opened by ``harness init run`` (GATE L0.1). The tech lead is the sole writer from here on.",
179
199
  "",
180
200
  "## Rounds",
181
201
  "",
@@ -191,15 +211,62 @@ export function runFrontmatter({ slug, config, startedAt }) {
191
211
  ].join("\n");
192
212
  }
193
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
+
194
260
  // ---- CLI -------------------------------------------------------------------
195
261
 
196
262
  /** The typed argv contract (see `./lib/argv.mjs`). */
197
263
  export const ARGV_SPEC = {
198
- usage: 'init-run.mjs (--intake-file <path> | --intake-text "<req>" | --intake-stdin) ' +
264
+ usage: 'harness.mjs init run (--intake-file <path> | --intake-text "<req>" | --intake-stdin) ' +
199
265
  "[--slug <slug>] [--auto-level interactive|auto|unattended] [--lens <lens>] " +
200
266
  "[--max-rounds N] [--attempts N] [--spec-folder <dir>] [--dimensions <a,b>] " +
201
267
  "[--gate-answers <preset|path>] " +
202
- "[--lane full|tiny] [--tiny] [--wall-clock-budget <seconds>] [--cwd <dir>] [--force]",
268
+ "[--lane full|tiny] [--tiny] [--wall-clock-budget <seconds>] [--cwd <dir>] " +
269
+ "[--plugin-root <dir>] [--force]",
203
270
  _: { arity: 0, max: 0, name: "(no positional operands)" },
204
271
  cwd: { type: "path" },
205
272
  "intake-text": { type: "str" },
@@ -216,6 +283,11 @@ export const ARGV_SPEC = {
216
283
  lane: { type: "str" },
217
284
  tiny: { type: "flag" },
218
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" },
219
291
  force: { type: "flag" },
220
292
  };
221
293
 
@@ -224,10 +296,45 @@ function fail(code, msg) {
224
296
  process.exit(code);
225
297
  }
226
298
 
227
- export function main() {
228
- const args = runArgs(ARGV_SPEC);
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);
229
308
  const cwd = args.cwd || process.cwd();
230
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
+
231
338
  let intake = args.intakeText ?? null;
232
339
  const intakeFile = args.intakeFile ?? null;
233
340
  // `--intake-file -` is the shape everyone reaches for; accept it rather than erroring on a
@@ -271,7 +378,7 @@ export function main() {
271
378
  spec_folder: args.specFolder ?? `${globShared(slug, "spec")}/`,
272
379
  gate_answers: args.gateAnswers ?? null,
273
380
  tiny_lane: !!args.tiny,
274
- // GATE L0.3 — the lane, computed rather than judged (see fit-check.mjs). Recorded with its
381
+ // GATE L0.3 — the lane, computed rather than judged (see `harness init fit`). Recorded with its
275
382
  // evidence so a heavy lane on a small change is visible instead of accidental. An explicit
276
383
  // --lane or --tiny is honoured and marked as an override, because a measured recommendation
277
384
  // fitted on three features must not outrank a human who knows the codebase.
@@ -282,7 +389,7 @@ export function main() {
282
389
  ? { ...auto, lane: forced, overridden_from: auto.lane, override_source: args.tiny ? "--tiny" : "--lane" }
283
390
  : auto;
284
391
  })(),
285
- // The third breaker (see scripts/budget-check.mjs). Null = off, which is the default and
392
+ // The third breaker (see `harness verify budget`). Null = off, which is the default and
286
393
  // keeps every existing run behaving exactly as before. Set it in any lane with a hard clock
287
394
  // — CI, an overnight run — so the harness trips its own breaker and ships what
288
395
  // is green, instead of being killed from outside and shipping nothing.
@@ -298,14 +405,14 @@ export function main() {
298
405
  // <slug>`)". `--from` is not an init-run flag at all; it is a `/tech-lead` flag, and it takes a
299
406
  // PHASE (`--from build`), not a slug. So at the one moment the orchestrator most needs a next
300
407
  // step, the runtime named a mechanism that does not exist, on a script whose failure mode was
301
- // already invisible (see lib/is-main.mjs — under a symlinked install this whole body did not run).
408
+ // already invisible — under a symlinked install an entry-point guard once skipped this whole body.
302
409
  //
303
410
  // Observed consequence on a handoff: a fresh session in a workspace with an open run burns most
304
411
  // of its budget before its first write, largely on forensics against this step, and closes none
305
412
  // of the gap.
306
413
  //
307
414
  // So the refusal now DOES the resume work instead of describing it. It emits the derived snapshot
308
- // — the same file-only derivation `hooks/session-rehydrate.mjs` injects — so the orchestrator gets
415
+ // — the same file-only derivation `harness reduce graph --subgraph run` injects — so the orchestrator gets
309
416
  // slug, status, round, attempt, board counts and pending orders in THIS tool call rather than
310
417
  // needing to discover that it needs another one. Exit 3 still means "do not proceed as if you
311
418
  // opened a run"; it now also means "here is the run you are actually in".
@@ -328,13 +435,28 @@ export function main() {
328
435
  ].join("\n"),
329
436
  "",
330
437
  "To re-derive this at any time:",
331
- " node <plugin>/skills/tech-lead/scripts/run-snapshot.mjs --cwd <dir>",
438
+ " node <plugin>/kernel/harness.mjs reduce snapshot --cwd <dir>",
332
439
  "To abandon the open run and start over, deliberately: --force",
333
440
  ].join("\n"));
334
441
  }
335
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
+
336
458
  const startedAt = new Date().toISOString();
337
- const receipt = buildReceipt({ slug, intake, config, startedAt });
459
+ const receipt = buildReceipt({ slug, intake, config, startedAt, plugin });
338
460
 
339
461
  mkdirSync(runRoot, { recursive: true });
340
462
  mkdirSync(join(runRoot, "orders"), { recursive: true });
@@ -354,6 +476,8 @@ export function main() {
354
476
  console.log(JSON.stringify({
355
477
  ok: true,
356
478
  slug,
479
+ // Echoed so the orchestrator can put it in RunArgs without re-reading the receipt.
480
+ run_id: receipt.run_id,
357
481
  run_root: globLocal(slug),
358
482
  receipt: globLocal(slug, "receipt.json"),
359
483
  intake_sha256: receipt.intake_sha256,
@@ -363,6 +487,3 @@ export function main() {
363
487
  }, null, 2));
364
488
  }
365
489
 
366
- if (isMain(import.meta.url)) {
367
- main();
368
- }
@@ -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
- // The structural suite 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
+ }