shapeup-sdlc 1.7.0 → 3.0.1

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 +14 -9
  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 +321 -0
  42. package/kernel/report/facts.mjs +209 -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 +71 -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 +1327 -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,321 @@
1
+ #!/usr/bin/env node
2
+ // export-run — freeze a run's records into fact tables before the tier that holds them is wiped.
3
+ //
4
+ // WHY THIS EXISTS (from the storage design, not from a preference for tooling).
5
+ //
6
+ // Every record this exports already exists. Orders, results, trial rows, T0 verdicts, criterion
7
+ // verdicts and hook decisions are all written during a normal run, all as JSON, all
8
+ // schema-registered. They live in `.shapeup/<slug>/` — the LOCAL tier, which ADR-0001
9
+ // defines as gitignored, machine-local and REGENERABLE. That definition is correct for run state
10
+ // and fatal for measurement: the trial-row contract says it out loud — a measurement left there
11
+ // "answers the question exactly once and then deletes itself".
12
+ //
13
+ // So the harness has been producing a complete dataset and discarding it. Not losing it to a bug;
14
+ // discarding it by design, because nothing ever read it before the directory was cleaned. This
15
+ // script is the read that was missing, and it is deliberately a SEPARATE STEP rather than a write
16
+ // added to the pipeline: the run's writers stay exactly as they are, ``harness reduce ingest`` remains
17
+ // the sole writer of shared state, and an export can be re-run against a trace at any point
18
+ // without touching it.
19
+ //
20
+ // WHAT IT DOES NOT DO, said plainly rather than left to be discovered. It does not make the
21
+ // records cross a machine boundary on its own. The default destination is `.shapeup/exports/`,
22
+ // which survives a per-slug wipe but is still gitignored — because committing per-run structured
23
+ // data and a machine id is precisely what ADR-0001 moved the metrics shards out of git to prevent.
24
+ // A cross-machine warehouse is `--out <dir>` to a destination the operator owns and chooses. The
25
+ // export makes the evidence DURABLE and PORTABLE; where it travels stays a human decision.
26
+ //
27
+ // READ-ONLY, AND FAIL-OPEN PER RECORD. Nothing here writes into the run trace. A file that will
28
+ // not parse is skipped and COUNTED in the manifest rather than aborting the export — a warehouse
29
+ // loader that dies on one bad line loses the other nine thousand, and the count is what tells an
30
+ // analyst their table is short. It never grades: every column is an id, a count, a duration or a
31
+ // copied enum, per the rule ``harness probe stats`` states in its own header.
32
+ //
33
+ // USAGE
34
+ // node `harness report export` [--slug <slug>] [--cwd <dir>] [--out <dir>] [--all] [--format jsonl|json]
35
+ //
36
+ // --slug the run to export; defaults to the active-scope pointer's slug
37
+ // --all export every run under the LOCAL root that has a readable receipt
38
+ // --out destination root (default: .shapeup/exports/); each run lands in <out>/<run_id>/
39
+ // --format jsonl (default — one object per line, what a warehouse reads natively) or json
40
+ //
41
+ // Prints the manifest(s) on stdout. Exit 0 on success, 2 on a usage error, 3 when there is
42
+ // nothing to export.
43
+
44
+ import { readFileSync, writeFileSync, readdirSync, mkdirSync, existsSync, statSync } from "node:fs";
45
+ import { join, resolve } from "node:path";
46
+ import { runArgs } from "../lib/argv.mjs";
47
+ import { splitFrontmatter } from "../lib/contract.mjs";
48
+ import { runIdFromReceipt, readReceipt } from "../lib/paths.mjs";
49
+ import { TABLES, runRow, dispatchFacts } from "./facts.mjs";
50
+ import {
51
+ localDir, activeScope, receipt as receiptPath, harnessRun, ordersDir, resultsDir,
52
+ trials as trialsPath, verdictsDir, evaluationDir, decisions as decisionsPath,
53
+ exportsDir, exportRunDir,
54
+ } from "../lib/paths.mjs";
55
+
56
+ export const EXPORT_SCHEMA_VERSION = 1;
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Readers — every one of them returns data plus a defect count, never throws
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /** A read tally threaded through every reader so the manifest can report what did not parse. */
63
+ const tally = () => ({ skipped: 0 });
64
+
65
+ /**
66
+ * Parse a JSON file, counting rather than throwing on failure.
67
+ * @param {string} path - File to read.
68
+ * @param {{skipped:number}} t - Defect tally, incremented on a parse or read failure.
69
+ * @returns {(*|null)} The parsed value, or null.
70
+ */
71
+ function readJson(path, t) {
72
+ try { return JSON.parse(readFileSync(path, "utf8")); } catch { t.skipped++; return null; }
73
+ }
74
+
75
+ /**
76
+ * Parse a JSONL file into rows, skipping (and counting) lines that do not parse.
77
+ * @param {string} path - File to read; a missing file is not a defect, it is an empty table.
78
+ * @param {{skipped:number}} t - Defect tally.
79
+ * @returns {Array<object>} The parsed rows, in file order.
80
+ */
81
+ function readJsonl(path, t) {
82
+ if (!existsSync(path)) return [];
83
+ let text;
84
+ try { text = readFileSync(path, "utf8"); } catch { t.skipped++; return []; }
85
+ const out = [];
86
+ for (const line of text.split("\n")) {
87
+ if (!line.trim()) continue;
88
+ try { out.push(JSON.parse(line)); } catch { t.skipped++; }
89
+ }
90
+ return out;
91
+ }
92
+
93
+ /**
94
+ * Read every `*.json` in a directory, sorted by name so an export is byte-stable across runs.
95
+ * @param {string} dir - Directory to scan; absent → empty.
96
+ * @param {{skipped:number}} t - Defect tally.
97
+ * @returns {Array<object>} Parsed documents, name-sorted.
98
+ */
99
+ function readJsonDir(dir, t) {
100
+ if (!existsSync(dir)) return [];
101
+ const out = [];
102
+ for (const f of readdirSync(dir).filter((x) => x.endsWith(".json")).sort()) {
103
+ const doc = readJson(join(dir, f), t);
104
+ if (doc) out.push(doc);
105
+ }
106
+ return out;
107
+ }
108
+
109
+ /**
110
+ * Flatten a T0 verdict artifact into one fact row, dropping the nested arms (they are the
111
+ * `CommandResult` detail, and belong to the artifact, not to a column).
112
+ * @param {object} a - A parsed T0Artifact.
113
+ * @param {(string|null)} runId - Run key for artifacts written before the field existed.
114
+ * @returns {object} A flat `t0_verdict` row.
115
+ */
116
+ function t0Row(a, runId) {
117
+ const fixtures = Array.isArray(a?.fixtures) ? a.fixtures : [];
118
+ return {
119
+ run_id: a?.run_id ?? runId ?? null,
120
+ scope_id: a?.scope_id ?? null,
121
+ round: a?.round ?? null,
122
+ attempt: a?.attempt ?? null,
123
+ trial: a?.trial ?? null,
124
+ at: a?.at ?? null,
125
+ overall: a?.overall ?? null,
126
+ regression: a?.regression ?? null,
127
+ fixtures_green: a?.fixtures_green ?? null,
128
+ db_probe_green: a?.db_probe_green ?? null,
129
+ seesaw_green: a?.seesaw_green ?? null,
130
+ fixtures_total: fixtures.length,
131
+ fixtures_passed: fixtures.filter((f) => f?.pass === true).length,
132
+ seesaw_ran: a?.seesaw?.ran ?? null,
133
+ seesaw_failing: Array.isArray(a?.seesaw?.failing) ? a.seesaw.failing.length : null,
134
+ discovered_tasks: Array.isArray(a?.discovered_tasks) ? a.discovered_tasks.length : 0,
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Collect every criterion-verdict row across the evaluator's per-target ledgers.
140
+ * @param {string} dir - The run's `evaluation/` directory.
141
+ * @param {(string|null)} runId - Run key to stamp (the ledger line carries none).
142
+ * @param {{skipped:number}} t - Defect tally.
143
+ * @returns {Array<object>} Flat `criterion_verdict` rows, each tagged with its target ledger.
144
+ */
145
+ function criterionRows(dir, runId, t) {
146
+ if (!existsSync(dir)) return [];
147
+ const out = [];
148
+ for (const f of readdirSync(dir).filter((x) => x.startsWith(".verdicts-") && x.endsWith(".jsonl")).sort()) {
149
+ const target = f.replace(/^\.verdicts-/, "").replace(/\.jsonl$/, "");
150
+ for (const row of readJsonl(join(dir, f), t)) {
151
+ out.push({
152
+ run_id: runId ?? null, target,
153
+ run: row?.run ?? null,
154
+ dimension: row?.dimension ?? null,
155
+ criterion: row?.criterion ?? null,
156
+ verdict: row?.verdict ?? null,
157
+ confidence: row?.confidence ?? null,
158
+ reprobed: row?.reprobed ?? null,
159
+ has_evidence: !!(row?.evidence && String(row.evidence).trim()),
160
+ at: row?.at ?? null,
161
+ });
162
+ }
163
+ }
164
+ return out;
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // The export itself
169
+ // ---------------------------------------------------------------------------
170
+
171
+ /**
172
+ * Read one run's trace and project it into fact tables. Pure-ish: reads the run trace, writes
173
+ * nothing.
174
+ *
175
+ * @param {string} cwd - Project root.
176
+ * @param {string} slug - The feature slug whose run to export.
177
+ * @returns {(object|null)} `{run_id, slug, tables:{…}, defects}` — or null when the slug has no
178
+ * readable receipt, which is the definition of "not a run".
179
+ */
180
+ export function collectRun(cwd, slug) {
181
+ const t = tally();
182
+ const rec = readReceipt(receiptPath(cwd, slug));
183
+ if (!rec) return null;
184
+ const runId = runIdFromReceipt(rec);
185
+
186
+ let ledger = {};
187
+ try { ledger = splitFrontmatter(readFileSync(harnessRun(cwd, slug), "utf8")).meta || {}; } catch { /* no ledger yet */ }
188
+
189
+ const orders = readJsonDir(ordersDir(cwd, slug), t);
190
+ const results = readJsonDir(resultsDir(cwd, slug), t);
191
+
192
+ const { dispatch, ac_result, discovery, file_touched } = dispatchFacts({ orders, results, runId });
193
+ const run = runRow({ receipt: rec, ledger, runId });
194
+
195
+ // Hook decisions are checkout-wide, so they are FILTERED to this run rather than read from a
196
+ // per-run file. Rows with a null key belong to no run (a hook that fired outside one) and are
197
+ // correctly excluded here — they are not this run's, and claiming them would inflate its
198
+ // enforcement counts with ambient activity.
199
+ const hook_decision = readJsonl(decisionsPath(cwd), t).filter((d) => runId && d?.run_id === runId);
200
+
201
+ return {
202
+ run_id: runId,
203
+ slug,
204
+ tables: {
205
+ run: run ? [run] : [],
206
+ dispatch, ac_result, discovery, file_touched,
207
+ trial: readJsonl(trialsPath(cwd, slug), t).map((r) => ({ ...r, run_id: r.run_id ?? runId ?? null })),
208
+ t0_verdict: readJsonDir(verdictsDir(cwd, slug), t).map((a) => t0Row(a, runId)),
209
+ criterion_verdict: criterionRows(evaluationDir(cwd, slug), runId, t),
210
+ hook_decision,
211
+ },
212
+ defects: { records_skipped: t.skipped },
213
+ };
214
+ }
215
+
216
+ /**
217
+ * Write one collected run's tables to disk and return its manifest.
218
+ * @param {object} collected - Output of {@link collectRun}.
219
+ * @param {string} outDir - Destination directory for this run's tables.
220
+ * @param {string} format - `jsonl` (one object per line) or `json` (one array per file).
221
+ * @returns {object} The manifest, which is also written to `<outDir>/manifest.json`.
222
+ */
223
+ export function writeRun(collected, outDir, format = "jsonl") {
224
+ mkdirSync(outDir, { recursive: true });
225
+ const ext = format === "json" ? "json" : "jsonl";
226
+ const rows = {};
227
+ for (const name of TABLES) {
228
+ const table = collected.tables[name] || [];
229
+ rows[name] = table.length;
230
+ const body = format === "json"
231
+ ? JSON.stringify(table, null, 2) + "\n"
232
+ : table.map((r) => JSON.stringify(r)).join("\n") + (table.length ? "\n" : "");
233
+ writeFileSync(join(outDir, `${name}.${ext}`), body, "utf8");
234
+ }
235
+ const manifest = {
236
+ schema_version: EXPORT_SCHEMA_VERSION,
237
+ run_id: collected.run_id,
238
+ slug: collected.slug,
239
+ generated_at: new Date().toISOString(),
240
+ format: ext,
241
+ tables: TABLES.map((name) => ({ name, file: `${name}.${ext}`, rows: rows[name] })),
242
+ rows_total: Object.values(rows).reduce((a, b) => a + b, 0),
243
+ // The defect count is a first-class manifest field, not a log line. A short table with no
244
+ // record of why is indistinguishable from a short run.
245
+ records_skipped: collected.defects.records_skipped,
246
+ };
247
+ writeFileSync(join(outDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n", "utf8");
248
+ return manifest;
249
+ }
250
+
251
+ /**
252
+ * Every slug under the LOCAL root that has a readable receipt — i.e. every run, discovered from
253
+ * the filesystem rather than from a list someone has to maintain.
254
+ * @param {string} cwd - Project root.
255
+ * @returns {string[]} Slugs, sorted.
256
+ */
257
+ export function discoverRuns(cwd) {
258
+ const root = localDir(cwd);
259
+ if (!existsSync(root)) return [];
260
+ const out = [];
261
+ for (const e of readdirSync(root)) {
262
+ try { if (!statSync(join(root, e)).isDirectory()) continue; } catch { continue; }
263
+ if (existsSync(receiptPath(cwd, e))) out.push(e);
264
+ }
265
+ return out.sort();
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // CLI
270
+ // ---------------------------------------------------------------------------
271
+
272
+ /** The typed argv contract (see `./lib/argv.mjs`). */
273
+ export const ARGV_SPEC = {
274
+ usage: "harness.mjs report export [--slug <slug>] [--all] [--cwd <dir>] [--out <dir>] [--format jsonl|json]",
275
+ _: { arity: 0, max: 0, name: "(no positional operands)" },
276
+ cwd: { type: "path" },
277
+ slug: { type: "str" },
278
+ out: { type: "path" },
279
+ all: { type: "flag" },
280
+ format: { type: "enum", values: ["jsonl", "json"], default: "jsonl" },
281
+ };
282
+
283
+ /**
284
+ * Project a run's records as fact tables under the exports tier.
285
+ *
286
+ * @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
287
+ * @returns {(Promise<void>|void)} Settles when the subcommand has written its output; most paths
288
+ * call `process.exit()` with the subcommand's documented code rather than returning.
289
+ */
290
+ export function cli(rawArgv) {
291
+ const args = runArgs(ARGV_SPEC, rawArgv);
292
+ const cwd = resolve(args.cwd || process.cwd());
293
+
294
+ let slugs = [];
295
+ if (args.all) slugs = discoverRuns(cwd);
296
+ else if (args.slug) slugs = [args.slug];
297
+ else {
298
+ // The active-scope pointer, same as every other run-scoped tool resolves through.
299
+ const active = readJson(activeScope(cwd), tally());
300
+ if (active?.slug) slugs = [active.slug];
301
+ }
302
+ if (!slugs.length) {
303
+ console.error("✋ export-run: no run to export — pass --slug <slug>, or --all, or open a run first.");
304
+ process.exit(3);
305
+ }
306
+
307
+ const outRoot = args.out ? resolve(cwd, args.out) : exportsDir(cwd);
308
+ const manifests = [];
309
+ for (const slug of slugs) {
310
+ const collected = collectRun(cwd, slug);
311
+ if (!collected) { console.error(`export-run: ${slug} has no readable receipt — skipped (not a run)`); continue; }
312
+ // Keyed by RUN ID, not by slug: two runs of the same feature are two datasets, and filing them
313
+ // under the slug would make the second silently overwrite the first — the exact collision the
314
+ // run key exists to end.
315
+ const dir = args.out ? join(outRoot, String(collected.run_id ?? slug)) : exportRunDir(cwd, collected.run_id ?? slug);
316
+ manifests.push(writeRun(collected, dir, args.format));
317
+ }
318
+ if (!manifests.length) process.exit(3);
319
+ console.log(JSON.stringify(manifests.length === 1 ? manifests[0] : manifests, null, 2));
320
+ }
321
+
@@ -0,0 +1,209 @@
1
+ // facts — the harness's records, projected into fact tables. Pure: no I/O, no clock, no grading.
2
+ //
3
+ // WHY THIS FILE EXISTS.
4
+ //
5
+ // The pipeline already writes JSON at every boundary — an order in, a result out, a decision row
6
+ // per hook evaluation, a trial row per T0 run. What it never had was a way to READ them together.
7
+ // Each record answers a question about itself; none of them answers "what did this run do",
8
+ // because that question needs a join and nothing on disk was joinable (see `mintRunId` in
9
+ // `lib/paths.mjs` for why).
10
+ //
11
+ // This module is the projection half. It takes parsed records and returns flat rows — a star
12
+ // schema whose grain is the DISPATCH, which is the finest unit the harness actually plans in:
13
+ // one compiled order, one worker, one result. Everything else hangs off it as a child table
14
+ // (`ac_result`, `discovery`, `file_touched`).
15
+ //
16
+ // FACTS ONLY — the rule ``harness probe stats`` states in its own header. Every field below is a
17
+ // count, a duration, a copied enum or an id. No field here is a score, a rate of quality, or a
18
+ // judgement, because a computed grade in the read plane is a second judge behind spec-evaluator
19
+ // and the architecture forbids one. `n_ac_fail` is a fact; "AC health" is not.
20
+ //
21
+ // A dispatch with no matching result is not dropped: `answered: false` says so, because an absent
22
+ // value and a zero value must not share a signature. This module carries no cost or wall-clock
23
+ // instrumentation — there is no run-scoped record of either to project.
24
+
25
+ /** Every fact table this module can produce, in dependency order. Exported so the writer, the
26
+ * manifest and the tests enumerate one list instead of three. */
27
+ export const TABLES = [
28
+ "run", "dispatch", "ac_result", "discovery", "file_touched",
29
+ "trial", "t0_verdict", "criterion_verdict", "hook_decision",
30
+ ];
31
+
32
+ /** Coerce anything to a finite number, or null. Keeps `0` and rejects `NaN`/`""`/undefined. */
33
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
34
+
35
+ /** Sum a list of numbers, returning null when NOTHING in it was a number — an absent total and a
36
+ * zero total are different facts and must not share a representation. */
37
+ function sumOrNull(values) {
38
+ let seen = false, total = 0;
39
+ for (const v of values) { const n = num(v); if (n !== null) { seen = true; total += n; } }
40
+ return seen ? total : null;
41
+ }
42
+
43
+ /**
44
+ * The order id's file stem — the name its order and result files share.
45
+ * @param {string} orderId - e.g. `checkout/sc-01-r1-a2`.
46
+ * @returns {(string|null)} e.g. `sc-01-r1-a2`, or null when the id has no `/`.
47
+ */
48
+ export function orderStem(orderId) {
49
+ const s = String(orderId ?? "");
50
+ const i = s.indexOf("/");
51
+ return i === -1 ? null : s.slice(i + 1) || null;
52
+ }
53
+
54
+ /**
55
+ * The round/attempt/scope an order's stem encodes, parsed back out.
56
+ *
57
+ * Read from the id rather than from the payload deliberately: the id is what every other record
58
+ * references, so a fact table keyed on it must agree with it even if a payload disagrees.
59
+ *
60
+ * @param {string} orderId - The order id.
61
+ * @returns {{scope_id:(string|null), round:(number|null), attempt:(number|null)}} Nulls where the
62
+ * stem carries no such term (a non-build operation has no round or attempt).
63
+ */
64
+ export function parseOrderStem(orderId) {
65
+ const stem = orderStem(orderId) || "";
66
+ const m = stem.match(/^(?:(.*)-)?r(\d+)-a(\d+)$/);
67
+ if (m) return { scope_id: m[1] || null, round: Number(m[2]), attempt: Number(m[3]) };
68
+ const opRound = stem.match(/^(.*)-r(\d+)$/);
69
+ if (opRound) return { scope_id: null, round: Number(opRound[2]), attempt: null };
70
+ return { scope_id: null, round: null, attempt: null };
71
+ }
72
+
73
+ /**
74
+ * Project the run dimension — one row, the thing every fact table's `run_id` points at.
75
+ * @param {object} o - Sources (destructured):
76
+ * @param {(object|null)} o.receipt - Parsed `receipt.json`.
77
+ * @param {(object|null)} [o.ledger] - Parsed `harness-run.md` frontmatter (a flat scalar map).
78
+ * @param {(string|null)} [o.runId] - The run key, when already resolved.
79
+ * @returns {(object|null)} The run row, or null when there is no receipt to describe.
80
+ */
81
+ export function runRow({ receipt, ledger = null, runId = null }) {
82
+ if (!receipt) return null;
83
+ const c = receipt.config || {};
84
+ const fm = ledger || {};
85
+ return {
86
+ run_id: runId ?? receipt.run_id ?? null,
87
+ slug: receipt.slug ?? null,
88
+ started_at: receipt.started_at ?? null,
89
+ closed_at: fm.closed_at && fm.closed_at !== "~" ? fm.closed_at : null,
90
+ intake_sha256: receipt.intake_sha256 ?? null,
91
+ intake_chars: num(receipt.intake_chars),
92
+ intake_lines: num(receipt.intake_lines),
93
+ auto_level: c.auto_level ?? null,
94
+ lens: c.lens ?? null,
95
+ lane: c.fit?.lane ?? null,
96
+ lane_overridden_from: c.fit?.overridden_from ?? null,
97
+ max_rounds: num(c.max_rounds),
98
+ attempt_budget: num(c.attempt_budget),
99
+ wall_clock_budget_s: num(c.wall_clock_budget_s),
100
+ eval_dimensions: Array.isArray(c.eval_dimensions) ? c.eval_dimensions.join(" ") : null,
101
+ // Copied from the ledger, never re-derived: the run's own status line is the harness's answer,
102
+ // and a read plane that recomputed it would be asserting a second one.
103
+ status: fm.status ?? null,
104
+ final_verdict: fm.final_verdict && fm.final_verdict !== "~" ? fm.final_verdict : null,
105
+ rounds_used: num(Number(fm.rounds_used)),
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Project the dispatch fact table and its three child tables.
111
+ *
112
+ * One row per ORDER — orders are the spine, because an order with no result is the fact you most
113
+ * need (a dispatch that never came back), and a result with no order cannot exist by construction.
114
+ *
115
+ * @param {object} o - Sources (destructured):
116
+ * @param {Array<object>} o.orders - Parsed WorkOrders.
117
+ * @param {Array<object>} [o.results] - Parsed WorkResults; joined on `order_id`.
118
+ * @param {(string|null)} [o.runId] - Run key for orders that carry none (pre-v1.8 traces).
119
+ * @returns {{dispatch:Array<object>, ac_result:Array<object>, discovery:Array<object>,
120
+ * file_touched:Array<object>}} The fact table and its children, each row already carrying
121
+ * `run_id` + `order_id` so every table stands alone in the warehouse.
122
+ */
123
+ export function dispatchFacts({ orders, results = [], runId = null }) {
124
+ const byOrderId = new Map();
125
+ for (const r of results) if (r?.order_id) byOrderId.set(r.order_id, r);
126
+
127
+ const dispatch = [], ac_result = [], discovery = [], file_touched = [];
128
+
129
+ for (const order of orders) {
130
+ if (!order?.order_id) continue;
131
+ const id = order.order_id;
132
+ const rid = order.run_id ?? runId ?? null;
133
+ const stem = orderStem(id);
134
+ const { scope_id, round, attempt } = parseOrderStem(id);
135
+ const result = byOrderId.get(id) || null;
136
+ const taskResults = Array.isArray(result?.task_results) ? result.task_results : [];
137
+ const discoveries = Array.isArray(result?.discoveries) ? result.discoveries : [];
138
+ const filesTouched = Array.isArray(result?.files_touched) ? result.files_touched : [];
139
+
140
+ let acPass = 0, acFail = 0, acSkip = 0;
141
+ for (const tr of taskResults) {
142
+ for (const ac of Array.isArray(tr?.ac_results) ? tr.ac_results : []) {
143
+ if (ac?.result === "pass") acPass++;
144
+ else if (ac?.result === "fail") acFail++;
145
+ else acSkip++;
146
+ ac_result.push({
147
+ run_id: rid, order_id: id,
148
+ task_id: tr?.task_id ?? null,
149
+ ac: ac?.ac ?? null,
150
+ result: ac?.result ?? null,
151
+ // The evidence TEXT is the worker's prose and belongs in the result file, not in a fact
152
+ // table. Whether it exists at all is the fact — "no evidence = fail by the worker's own
153
+ // hand" is a contract the warehouse can then check without re-reading every envelope.
154
+ has_evidence: !!(ac?.evidence && String(ac.evidence).trim()),
155
+ });
156
+ }
157
+ }
158
+ for (const d of discoveries) {
159
+ discovery.push({
160
+ run_id: rid, order_id: id,
161
+ marker: d?.marker ?? null,
162
+ lens: d?.lens ?? null,
163
+ severity_hint: d?.severity_hint ?? null,
164
+ test_gap: d?.test_gap ?? null,
165
+ contradicts: d?.contradicts ?? null,
166
+ has_repro: !!(d?.repro && String(d.repro).trim()),
167
+ line: d?.line ?? null,
168
+ });
169
+ }
170
+ for (const f of filesTouched) {
171
+ file_touched.push({
172
+ run_id: rid, order_id: id,
173
+ path: f?.path ?? null,
174
+ change: f?.change ?? null,
175
+ lines: num(f?.lines),
176
+ });
177
+ }
178
+
179
+ dispatch.push({
180
+ run_id: rid,
181
+ order_id: id,
182
+ slug: id.split("/")[0] || null,
183
+ stem,
184
+ worker: order.worker ?? null,
185
+ operation: order.operation ?? null,
186
+ mode: order.mode ?? null,
187
+ scope_id: scope_id ?? order.payload?.scope_contract?.scope_id ?? null,
188
+ round, attempt,
189
+ compiled_at: order.compiled_at ?? null,
190
+ tasks_ordered: Array.isArray(order.payload?.tasks) ? order.payload.tasks.length : 0,
191
+ digested_errors: Array.isArray(order.payload?.digested_errors) ? order.payload.digested_errors.length : 0,
192
+ // `null`, not `"missing"`: a dispatch with no result file is the single most important row
193
+ // in this table, and it must be filterable as an absence rather than as a status value that
194
+ // sorts alongside real ones.
195
+ result_status: result?.status ?? null,
196
+ answered: !!result,
197
+ task_results: taskResults.length,
198
+ ac_pass: acPass, ac_fail: acFail, ac_skipped: acSkip,
199
+ discoveries: discoveries.length,
200
+ files_touched: filesTouched.length,
201
+ lines_touched: sumOrNull(filesTouched.map((f) => f?.lines)),
202
+ has_verdict: !!result?.verdict,
203
+ verdict_overall: result?.verdict?.overall ?? null,
204
+ assumptions: Array.isArray(result?.assumptions) ? result.assumptions.length : 0,
205
+ deviations: Array.isArray(result?.deviations) ? result.deviations.length : 0,
206
+ });
207
+ }
208
+ return { dispatch, ac_result, discovery, file_touched };
209
+ }
@@ -33,8 +33,8 @@
33
33
  // `off` and nothing changes. Existing runs behave exactly as before.
34
34
  //
35
35
  // USAGE
36
- // node budget-check.mjs [--slug <slug>] [--cwd <root>] [--at <ISO>] # status, exit 0
37
- // node budget-check.mjs --strict … # exit 6 when the breaker has tripped
36
+ // node `harness verify budget` [--slug <slug>] [--cwd <root>] [--at <ISO>] # status, exit 0
37
+ // node `harness verify budget` --strict … # exit 6 when the breaker has tripped
38
38
  //
39
39
  // Output: { status, elapsed_s, budget_s, remaining_s, used_fraction, action }
40
40
  // status ∈ off | ok | warn | trip
@@ -42,9 +42,8 @@
42
42
 
43
43
  import { readFileSync, readdirSync, existsSync } from "node:fs";
44
44
  import { join } from "node:path";
45
- import { isMain } from "./lib/is-main.mjs";
46
- import { runArgs } from "./lib/argv.mjs";
47
- import { localDir } from "./lib/paths.mjs";
45
+ import { runArgs } from "../lib/argv.mjs";
46
+ import { localDir } from "../lib/paths.mjs";
48
47
 
49
48
  /** Fraction of the budget at which the run should stop STARTING work it cannot finish. */
50
49
  export const WARN_AT = 0.75;
@@ -116,7 +115,7 @@ export function findRun(cwd, slug = null) {
116
115
 
117
116
  /** The typed argv contract (see `./lib/argv.mjs`). */
118
117
  export const ARGV_SPEC = {
119
- usage: "budget-check.mjs [--slug <slug>] [--cwd <dir>] [--at <iso8601>] [--strict]",
118
+ usage: "harness.mjs verify budget [--slug <slug>] [--cwd <dir>] [--at <iso8601>] [--strict]",
120
119
  _: { arity: 0, max: 0, name: "(no positional operands)" },
121
120
  slug: { type: "str" },
122
121
  cwd: { type: "path" },
@@ -124,12 +123,19 @@ export const ARGV_SPEC = {
124
123
  strict: { type: "flag" },
125
124
  };
126
125
 
127
- export function main() {
128
- const args = runArgs(ARGV_SPEC);
126
+ /**
127
+ * Check the wall-clock budget for the open run and report the breaker's verdict.
128
+ *
129
+ * @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
130
+ * @returns {(Promise<void>|void)} Settles when the subcommand has written its output; most paths
131
+ * call `process.exit()` with the subcommand's documented code rather than returning.
132
+ */
133
+ export function cli(rawArgv) {
134
+ const args = runArgs(ARGV_SPEC, rawArgv);
129
135
  const cwd = args.cwd || process.cwd();
130
136
  const run = findRun(cwd, args.slug ?? null);
131
137
  if (!run) {
132
- console.error("no run receipt found — open the run with init-run.mjs first (GATE L0.1).");
138
+ console.error("no run receipt found — open the run with `harness init run` first (GATE L0.1).");
133
139
  process.exit(2);
134
140
  }
135
141
  const startedAt = Date.parse(run.receipt.started_at || "");
@@ -145,6 +151,3 @@ export function main() {
145
151
  process.exit(args.strict && result.status === "trip" ? 6 : 0);
146
152
  }
147
153
 
148
- if (isMain(import.meta.url)) {
149
- main();
150
- }