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.
- package/.claude/settings.local.example.json +5 -5
- package/.claude-plugin/plugin.json +1 -1
- package/AGENTS.md +17 -3
- package/README.md +124 -103
- package/SECURITY.md +37 -30
- package/bin/init.mjs +77 -47
- package/bin/lib/grant.mjs +145 -0
- package/commands/build.md +7 -6
- package/commands/ship.md +29 -16
- package/commands/wire.md +1 -1
- package/hooks/dispatch-receipt.mjs +195 -0
- package/hooks/gate-intake.mjs +3 -3
- package/hooks/gate-zerowork.mjs +25 -24
- package/hooks/hooks.json +9 -48
- package/hooks/lib/decision.mjs +19 -8
- package/hooks/safety-spine.mjs +3 -3
- package/hooks/sandbox-guard.mjs +84 -40
- package/{skills/tech-lead/scripts/compile-order.mjs → kernel/compile.mjs} +283 -41
- package/{skills/tech-lead/scripts/gate-answers.mjs → kernel/gate.mjs} +58 -14
- package/kernel/harness.mjs +134 -0
- package/{skills/tech-lead/scripts/fit-check.mjs → kernel/init/fit.mjs} +15 -12
- package/{skills/tech-lead/scripts/init-run.mjs → kernel/init/run.mjs} +152 -31
- package/{skills/tech-lead/scripts → kernel}/lib/argv.mjs +52 -31
- package/{skills/tech-lead/scripts/lib/contract-md.mjs → kernel/lib/contract.mjs} +251 -7
- package/kernel/lib/paths.mjs +491 -0
- package/kernel/probe/concurrency.mjs +510 -0
- package/{skills/tech-lead/scripts/aegis-digest.mjs → kernel/probe/digest.mjs} +9 -10
- package/kernel/probe/eval.mjs +77 -0
- package/kernel/probe/leg.mjs +125 -0
- package/{skills/tech-lead/scripts/resume-state.mjs → kernel/probe/resume.mjs} +204 -63
- package/{skills/tech-lead/scripts → kernel/probe}/stats.mjs +14 -9
- package/kernel/probe/t0.mjs +66 -0
- package/{skills/ba-pitch-analyzer/scripts/board-derive.mjs → kernel/reduce/board.mjs} +99 -23
- package/kernel/reduce/graph.mjs +437 -0
- package/{skills/tech-lead/scripts/hill-derive.mjs → kernel/reduce/hill.mjs} +28 -9
- package/kernel/reduce/ingest.mjs +633 -0
- package/{hooks/slop-cleaner.mjs → kernel/reduce/leftovers.mjs} +40 -57
- package/{skills/tech-lead/scripts/ship-report.mjs → kernel/reduce/ship.mjs} +61 -13
- package/{skills/tech-lead/scripts/run-snapshot.mjs → kernel/reduce/snapshot.mjs} +21 -15
- package/{skills/spec-evaluator/scripts/verdict-ledger.mjs → kernel/reduce/verdict.mjs} +13 -7
- package/kernel/report/export.mjs +321 -0
- package/kernel/report/facts.mjs +209 -0
- package/{skills/tech-lead/scripts/budget-check.mjs → kernel/verify/budget.mjs} +15 -12
- package/kernel/verify/dispatch.mjs +114 -0
- package/{skills/tech-lead/scripts/validate-envelope.mjs → kernel/verify/envelope.mjs} +20 -15
- package/{skills/tech-lead/scripts/lib → kernel/verify}/ratchet-tree.mjs +36 -14
- package/kernel/verify/skills.mjs +125 -0
- package/kernel/verify/spec.mjs +559 -0
- package/{skills/tech-lead/scripts/t0-verify.mjs → kernel/verify/t0.mjs} +134 -24
- package/{skills/tech-lead/scripts/trace-lint.mjs → kernel/verify/trace.mjs} +26 -11
- package/oracles/http-oracle.mjs +1 -1
- package/oracles/process-oracle.mjs +1 -1
- package/oracles/snapshot-oracle.mjs +1 -1
- package/oracles/test-oracle.mjs +1 -1
- package/package.json +11 -9
- package/skills/ba-pitch-analyzer/SKILL.md +5 -5
- package/skills/ba-pitch-analyzer/assets/templates/_index.tmpl.md +1 -1
- package/skills/ba-pitch-analyzer/assets/templates/api-feasibility.tmpl.md +12 -7
- package/skills/ba-pitch-analyzer/assets/templates/contracts/third-party-api.contract.tmpl.md +6 -3
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/migration-plan.tmpl.md +5 -4
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/team-handoff.tmpl.md +23 -13
- package/skills/ba-pitch-analyzer/assets/templates/scope-summary.tmpl.md +17 -12
- package/skills/ba-pitch-analyzer/assets/templates/synthesis.tmpl.md +17 -38
- package/skills/ba-pitch-analyzer/assets/templates/task.tmpl.md +3 -0
- package/skills/ba-pitch-analyzer/references/contract-patterns.md +8 -4
- package/skills/ba-pitch-analyzer/references/doc-schemas.md +2 -0
- package/skills/ba-pitch-analyzer/references/task-generation.md +2 -2
- package/skills/qa-edge-hunter/SKILL.md +7 -3
- package/skills/scope-architect/SKILL.md +40 -6
- package/skills/solution-architect/SKILL.md +19 -4
- package/skills/spec-evaluator/SKILL.md +12 -2
- package/skills/task-executor/SKILL.md +7 -6
- package/skills/tech-lead/SKILL.md +50 -38
- package/skills/tech-lead/references/gates.md +71 -20
- package/skills/tech-lead/references/protocol.md +832 -0
- package/skills/tech-lead/schemas/domain.schema.json +341 -66
- package/skills/tech-lead/schemas/work-order.schema.json +11 -2
- package/skills/tech-lead/schemas/work-result.schema.json +1 -1
- package/skills/tech-lead/workflows/shapeup-run.js +1327 -750
- package/hooks/anti-rationalization.mjs +0 -238
- package/hooks/compact-snapshot.mjs +0 -47
- package/hooks/gate-deadline.mjs +0 -147
- package/hooks/gate-l2.mjs +0 -161
- package/hooks/session-rehydrate.mjs +0 -108
- package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +0 -239
- package/skills/spec-evaluator/README.md +0 -93
- package/skills/tech-lead/README.md +0 -71
- package/skills/tech-lead/references/delegation.md +0 -261
- package/skills/tech-lead/references/hard-rules.md +0 -34
- package/skills/tech-lead/references/invocation.md +0 -45
- package/skills/tech-lead/references/ledger-schema.md +0 -213
- package/skills/tech-lead/references/round-protocol.md +0 -234
- package/skills/tech-lead/references/state-model.md +0 -66
- package/skills/tech-lead/scripts/ingest-result.mjs +0 -258
- package/skills/tech-lead/scripts/lib/is-main.mjs +0 -81
- package/skills/tech-lead/scripts/lib/paths.mjs +0 -280
- package/skills/tech-lead/scripts/run-workflow.mjs +0 -381
- 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
|
|
37
|
-
// node budget
|
|
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 {
|
|
46
|
-
import {
|
|
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: "
|
|
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
|
-
|
|
128
|
-
|
|
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
|
|
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
|
-
}
|