shapeup-sdlc 1.6.3 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.example.json +5 -5
- package/.claude-plugin/plugin.json +1 -1
- package/AGENTS.md +54 -107
- package/README.md +165 -151
- package/SECURITY.md +49 -27
- package/bin/init.mjs +93 -108
- package/bin/lib/grant.mjs +145 -0
- package/commands/build.md +20 -0
- package/commands/eval.md +5 -4
- package/commands/scopes.md +5 -4
- package/commands/shape.md +1 -1
- package/commands/ship.md +53 -7
- package/commands/wire.md +1 -1
- package/hooks/dispatch-receipt.mjs +195 -0
- package/hooks/gate-intake.mjs +16 -17
- package/hooks/gate-zerowork.mjs +107 -25
- package/hooks/hooks.json +9 -48
- package/hooks/lib/decision.mjs +38 -19
- package/hooks/safety-spine.mjs +4 -4
- package/hooks/sandbox-guard.mjs +130 -50
- package/{skills/tech-lead/scripts/compile-order.mjs → kernel/compile.mjs} +344 -55
- package/{skills/tech-lead/scripts/gate-answers.mjs → kernel/gate.mjs} +69 -26
- package/kernel/harness.mjs +134 -0
- package/{skills/tech-lead/scripts/fit-check.mjs → kernel/init/fit.mjs} +25 -22
- package/kernel/init/run.mjs +489 -0
- package/{skills/tech-lead/scripts → kernel}/lib/argv.mjs +52 -31
- package/{skills/tech-lead/scripts/lib/contract-md.mjs → kernel/lib/contract.mjs} +261 -17
- 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} +10 -11
- package/kernel/probe/eval.mjs +77 -0
- package/kernel/probe/leg.mjs +125 -0
- package/kernel/probe/resume.mjs +528 -0
- package/{skills/tech-lead/scripts → kernel/probe}/stats.mjs +102 -21
- package/kernel/probe/t0.mjs +66 -0
- package/{skills/ba-pitch-analyzer/scripts/board-derive.mjs → kernel/reduce/board.mjs} +104 -28
- package/kernel/reduce/graph.mjs +437 -0
- package/kernel/reduce/hill.mjs +152 -0
- 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} +93 -14
- package/{skills/tech-lead/scripts/run-snapshot.mjs → kernel/reduce/snapshot.mjs} +22 -21
- package/{skills/spec-evaluator/scripts/verdict-ledger.mjs → kernel/reduce/verdict.mjs} +14 -8
- package/kernel/report/export.mjs +325 -0
- package/kernel/report/facts.mjs +347 -0
- package/{skills/tech-lead/scripts/budget-check.mjs → kernel/verify/budget.mjs} +22 -25
- 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} +138 -28
- package/{skills/tech-lead/scripts/trace-lint.mjs → kernel/verify/trace.mjs} +28 -13
- package/oracles/_shared.mjs +1 -1
- package/oracles/http-oracle.mjs +2 -2
- package/oracles/index.mjs +1 -1
- package/oracles/process-oracle.mjs +2 -2
- package/oracles/snapshot-oracle.mjs +2 -2
- package/oracles/test-oracle.mjs +2 -2
- package/package.json +11 -13
- package/skills/ba-pitch-analyzer/SKILL.md +24 -19
- 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 +3 -1
- package/skills/ba-pitch-analyzer/references/task-generation.md +8 -8
- package/skills/ba-pitch-analyzer/references/test-surface.md +1 -1
- package/skills/coach/SKILL.md +3 -3
- package/skills/orient/SKILL.md +2 -1
- package/skills/qa-edge-hunter/SKILL.md +15 -41
- package/skills/scope-architect/SKILL.md +57 -18
- package/skills/scope-hammer/SKILL.md +5 -5
- package/skills/shapeup/SKILL.md +3 -3
- package/skills/shapeup/resources/context-compaction.md +4 -3
- package/skills/solution-architect/SKILL.md +37 -15
- package/skills/spec-evaluator/SKILL.md +24 -7
- package/skills/spec-evaluator/references/dimensions/_registry.md +2 -2
- package/skills/spec-evaluator/references/dimensions/completeness.md +1 -1
- package/skills/spec-evaluator/references/dimensions/integration.md +98 -67
- package/skills/spec-evaluator/references/dimensions/spec-conformance.md +4 -4
- package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +1 -1
- package/skills/spec-evaluator/references/probing.md +42 -23
- package/skills/spec-evaluator/references/report-schema.md +2 -2
- package/skills/task-executor/SKILL.md +32 -18
- package/skills/tech-lead/SKILL.md +127 -438
- package/skills/tech-lead/references/gates.md +140 -49
- package/skills/tech-lead/references/protocol.md +832 -0
- package/skills/tech-lead/schemas/domain.schema.json +645 -198
- package/skills/tech-lead/schemas/gate-answers.schema.json +2 -2
- package/skills/tech-lead/schemas/work-order.schema.json +11 -2
- package/skills/tech-lead/schemas/work-result.schema.json +56 -18
- package/skills/tech-lead/workflows/shapeup-run.js +1474 -0
- package/skills/translator/SKILL.md +2 -2
- package/hooks/anti-rationalization.mjs +0 -244
- package/hooks/compact-snapshot.mjs +0 -47
- package/hooks/gate-deadline.mjs +0 -151
- package/hooks/gate-l2.mjs +0 -161
- package/hooks/session-rehydrate.mjs +0 -109
- package/skills/advisor-protocol/SKILL.md +0 -171
- 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 -254
- package/skills/tech-lead/references/invocation.md +0 -45
- package/skills/tech-lead/references/ledger-schema.md +0 -214
- package/skills/tech-lead/references/round-protocol.md +0 -184
- package/skills/tech-lead/references/state-model.md +0 -66
- package/skills/tech-lead/scripts/ingest-result.mjs +0 -270
- package/skills/tech-lead/scripts/init-run.mjs +0 -326
- package/skills/tech-lead/scripts/lib/is-main.mjs +0 -82
- package/skills/tech-lead/scripts/lib/paths.mjs +0 -278
- package/skills/translator/README.md +0 -66
|
@@ -0,0 +1,325 @@
|
|
|
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, journal rows, trial rows, T0
|
|
7
|
+
// verdicts, criterion verdicts and hook decisions are all written during a normal run, all as
|
|
8
|
+
// JSON, all 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, agentCallRow, dispatchFacts, economics } from "./facts.mjs";
|
|
50
|
+
import {
|
|
51
|
+
localDir, activeScope, receipt as receiptPath, harnessRun, ordersDir, resultsDir,
|
|
52
|
+
workflowRunDir, 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:{…}, economics, defects}` — or null when the
|
|
178
|
+
* slug has no 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
|
+
const journal = readJsonl(join(workflowRunDir(cwd, slug), "journal.jsonl"), t);
|
|
192
|
+
|
|
193
|
+
const { dispatch, ac_result, discovery, file_touched } = dispatchFacts({ orders, results, journal, runId });
|
|
194
|
+
const agent_call = journal.map((j) => agentCallRow(j, runId));
|
|
195
|
+
const run = runRow({ receipt: rec, ledger, runId });
|
|
196
|
+
|
|
197
|
+
// Hook decisions are checkout-wide, so they are FILTERED to this run rather than read from a
|
|
198
|
+
// per-run file. Rows with a null key belong to no run (a hook that fired outside one) and are
|
|
199
|
+
// correctly excluded here — they are not this run's, and claiming them would inflate its
|
|
200
|
+
// enforcement counts with ambient activity.
|
|
201
|
+
const hook_decision = readJsonl(decisionsPath(cwd), t).filter((d) => runId && d?.run_id === runId);
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
run_id: runId,
|
|
205
|
+
slug,
|
|
206
|
+
tables: {
|
|
207
|
+
run: run ? [run] : [],
|
|
208
|
+
dispatch, ac_result, discovery, file_touched, agent_call,
|
|
209
|
+
trial: readJsonl(trialsPath(cwd, slug), t).map((r) => ({ ...r, run_id: r.run_id ?? runId ?? null })),
|
|
210
|
+
t0_verdict: readJsonDir(verdictsDir(cwd, slug), t).map((a) => t0Row(a, runId)),
|
|
211
|
+
criterion_verdict: criterionRows(evaluationDir(cwd, slug), runId, t),
|
|
212
|
+
hook_decision,
|
|
213
|
+
},
|
|
214
|
+
economics: economics({ agent_call, dispatch, run }),
|
|
215
|
+
defects: { records_skipped: t.skipped },
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Write one collected run's tables to disk and return its manifest.
|
|
221
|
+
* @param {object} collected - Output of {@link collectRun}.
|
|
222
|
+
* @param {string} outDir - Destination directory for this run's tables.
|
|
223
|
+
* @param {string} format - `jsonl` (one object per line) or `json` (one array per file).
|
|
224
|
+
* @returns {object} The manifest, which is also written to `<outDir>/manifest.json`.
|
|
225
|
+
*/
|
|
226
|
+
export function writeRun(collected, outDir, format = "jsonl") {
|
|
227
|
+
mkdirSync(outDir, { recursive: true });
|
|
228
|
+
const ext = format === "json" ? "json" : "jsonl";
|
|
229
|
+
const rows = {};
|
|
230
|
+
for (const name of TABLES) {
|
|
231
|
+
const table = collected.tables[name] || [];
|
|
232
|
+
rows[name] = table.length;
|
|
233
|
+
const body = format === "json"
|
|
234
|
+
? JSON.stringify(table, null, 2) + "\n"
|
|
235
|
+
: table.map((r) => JSON.stringify(r)).join("\n") + (table.length ? "\n" : "");
|
|
236
|
+
writeFileSync(join(outDir, `${name}.${ext}`), body, "utf8");
|
|
237
|
+
}
|
|
238
|
+
const manifest = {
|
|
239
|
+
schema_version: EXPORT_SCHEMA_VERSION,
|
|
240
|
+
run_id: collected.run_id,
|
|
241
|
+
slug: collected.slug,
|
|
242
|
+
generated_at: new Date().toISOString(),
|
|
243
|
+
format: ext,
|
|
244
|
+
tables: TABLES.map((name) => ({ name, file: `${name}.${ext}`, rows: rows[name] })),
|
|
245
|
+
rows_total: Object.values(rows).reduce((a, b) => a + b, 0),
|
|
246
|
+
// The defect count is a first-class manifest field, not a log line. A short table with no
|
|
247
|
+
// record of why is indistinguishable from a short run.
|
|
248
|
+
records_skipped: collected.defects.records_skipped,
|
|
249
|
+
economics: collected.economics,
|
|
250
|
+
};
|
|
251
|
+
writeFileSync(join(outDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
|
252
|
+
return manifest;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Every slug under the LOCAL root that has a readable receipt — i.e. every run, discovered from
|
|
257
|
+
* the filesystem rather than from a list someone has to maintain.
|
|
258
|
+
* @param {string} cwd - Project root.
|
|
259
|
+
* @returns {string[]} Slugs, sorted.
|
|
260
|
+
*/
|
|
261
|
+
export function discoverRuns(cwd) {
|
|
262
|
+
const root = localDir(cwd);
|
|
263
|
+
if (!existsSync(root)) return [];
|
|
264
|
+
const out = [];
|
|
265
|
+
for (const e of readdirSync(root)) {
|
|
266
|
+
try { if (!statSync(join(root, e)).isDirectory()) continue; } catch { continue; }
|
|
267
|
+
if (existsSync(receiptPath(cwd, e))) out.push(e);
|
|
268
|
+
}
|
|
269
|
+
return out.sort();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// CLI
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
/** The typed argv contract (see `./lib/argv.mjs`). */
|
|
277
|
+
export const ARGV_SPEC = {
|
|
278
|
+
usage: "harness.mjs report export [--slug <slug>] [--all] [--cwd <dir>] [--out <dir>] [--format jsonl|json]",
|
|
279
|
+
_: { arity: 0, max: 0, name: "(no positional operands)" },
|
|
280
|
+
cwd: { type: "path" },
|
|
281
|
+
slug: { type: "str" },
|
|
282
|
+
out: { type: "path" },
|
|
283
|
+
all: { type: "flag" },
|
|
284
|
+
format: { type: "enum", values: ["jsonl", "json"], default: "jsonl" },
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Project a run's records as fact tables under the exports tier.
|
|
289
|
+
*
|
|
290
|
+
* @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
|
|
291
|
+
* @returns {(Promise<void>|void)} Settles when the subcommand has written its output; most paths
|
|
292
|
+
* call `process.exit()` with the subcommand's documented code rather than returning.
|
|
293
|
+
*/
|
|
294
|
+
export function cli(rawArgv) {
|
|
295
|
+
const args = runArgs(ARGV_SPEC, rawArgv);
|
|
296
|
+
const cwd = resolve(args.cwd || process.cwd());
|
|
297
|
+
|
|
298
|
+
let slugs = [];
|
|
299
|
+
if (args.all) slugs = discoverRuns(cwd);
|
|
300
|
+
else if (args.slug) slugs = [args.slug];
|
|
301
|
+
else {
|
|
302
|
+
// The active-scope pointer, same as every other run-scoped tool resolves through.
|
|
303
|
+
const active = readJson(activeScope(cwd), tally());
|
|
304
|
+
if (active?.slug) slugs = [active.slug];
|
|
305
|
+
}
|
|
306
|
+
if (!slugs.length) {
|
|
307
|
+
console.error("✋ export-run: no run to export — pass --slug <slug>, or --all, or open a run first.");
|
|
308
|
+
process.exit(3);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const outRoot = args.out ? resolve(cwd, args.out) : exportsDir(cwd);
|
|
312
|
+
const manifests = [];
|
|
313
|
+
for (const slug of slugs) {
|
|
314
|
+
const collected = collectRun(cwd, slug);
|
|
315
|
+
if (!collected) { console.error(`export-run: ${slug} has no readable receipt — skipped (not a run)`); continue; }
|
|
316
|
+
// Keyed by RUN ID, not by slug: two runs of the same feature are two datasets, and filing them
|
|
317
|
+
// under the slug would make the second silently overwrite the first — the exact collision the
|
|
318
|
+
// run key exists to end.
|
|
319
|
+
const dir = args.out ? join(outRoot, String(collected.run_id ?? slug)) : exportRunDir(cwd, collected.run_id ?? slug);
|
|
320
|
+
manifests.push(writeRun(collected, dir, args.format));
|
|
321
|
+
}
|
|
322
|
+
if (!manifests.length) process.exit(3);
|
|
323
|
+
console.log(JSON.stringify(manifests.length === 1 ? manifests[0] : manifests, null, 2));
|
|
324
|
+
}
|
|
325
|
+
|
|
@@ -0,0 +1,347 @@
|
|
|
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 journal row
|
|
6
|
+
// per agent call, a decision row per hook evaluation, a trial row per T0 run. What it never had
|
|
7
|
+
// was a way to READ them together. Each record answers a question about itself; none of them
|
|
8
|
+
// answers "what did this run do, and what did it cost", because that question needs a join and
|
|
9
|
+
// nothing on disk was joinable (see `mintRunId` in `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 either rolls up to it
|
|
14
|
+
// (`agent_call`, via the join below) or hangs off it as a child table (`ac_result`,
|
|
15
|
+
// `discovery`, `file_touched`).
|
|
16
|
+
//
|
|
17
|
+
// TWO RULES, INHERITED, AND NEITHER IS STYLISTIC.
|
|
18
|
+
//
|
|
19
|
+
// 1. FACTS ONLY — the rule ``harness probe stats`` states in its own header. Every field below is a count,
|
|
20
|
+
// a duration, a copied enum or an id. No field here is a score, a rate of quality, or a
|
|
21
|
+
// judgement, because a computed grade in the read plane is a second judge behind
|
|
22
|
+
// spec-evaluator and the architecture forbids one. `n_ac_fail` is a fact; "AC health" is not.
|
|
23
|
+
//
|
|
24
|
+
// 2. NEVER FABRICATE A JOIN. The agent-call join (below) is real but partial, so every dispatch
|
|
25
|
+
// row carries `agent_join` naming HOW it was joined — or `null` when it wasn't. An analyst
|
|
26
|
+
// summing `cost_usd` must be able to see what share of dispatches had no cost row at all,
|
|
27
|
+
// because the alternative is a total that silently under-reports and looks authoritative.
|
|
28
|
+
// This is the same defect shape as `allow` with no receipt: an absent value and a zero value
|
|
29
|
+
// must not share a signature.
|
|
30
|
+
//
|
|
31
|
+
// THE AGENT-CALL JOIN, stated exactly. `journal.jsonl` is the only record carrying `cost_usd` and
|
|
32
|
+
// wall-clock, and its rows name no order — the launcher is generic and never parsed the prompt it
|
|
33
|
+
// was given. But the workflow's dispatch prompt asks the worker for a `result_path`, and that path
|
|
34
|
+
// is `<run root>/results/<stem>.json`, whose stem is the order id's suffix. So a journal row whose
|
|
35
|
+
// returned object carries `result_path` joins to exactly one order, deterministically, with no
|
|
36
|
+
// heuristics. Rows without it — the mechanical `set-active-order` couriers, a failed dispatch that
|
|
37
|
+
// returned nothing — join to no order and are counted as unattributed rather than dropped.
|
|
38
|
+
|
|
39
|
+
/** Every fact table this module can produce, in dependency order. Exported so the writer, the
|
|
40
|
+
* manifest and the tests enumerate one list instead of three. */
|
|
41
|
+
export const TABLES = [
|
|
42
|
+
"run", "dispatch", "ac_result", "discovery", "file_touched",
|
|
43
|
+
"agent_call", "trial", "t0_verdict", "criterion_verdict", "hook_decision",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/** Coerce anything to a finite number, or null. Keeps `0` and rejects `NaN`/`""`/undefined. */
|
|
47
|
+
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
|
48
|
+
|
|
49
|
+
/** Sum a list of numbers, returning null when NOTHING in it was a number — an absent total and a
|
|
50
|
+
* zero total are different facts and must not share a representation. */
|
|
51
|
+
function sumOrNull(values) {
|
|
52
|
+
let seen = false, total = 0;
|
|
53
|
+
for (const v of values) { const n = num(v); if (n !== null) { seen = true; total += n; } }
|
|
54
|
+
return seen ? total : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The order id's file stem — the name its order and result files share.
|
|
59
|
+
* @param {string} orderId - e.g. `checkout/sc-01-r1-a2`.
|
|
60
|
+
* @returns {(string|null)} e.g. `sc-01-r1-a2`, or null when the id has no `/`.
|
|
61
|
+
*/
|
|
62
|
+
export function orderStem(orderId) {
|
|
63
|
+
const s = String(orderId ?? "");
|
|
64
|
+
const i = s.indexOf("/");
|
|
65
|
+
return i === -1 ? null : s.slice(i + 1) || null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The round/attempt/scope an order's stem encodes, parsed back out.
|
|
70
|
+
*
|
|
71
|
+
* Read from the id rather than from the payload deliberately: the id is what every other record
|
|
72
|
+
* references, so a fact table keyed on it must agree with it even if a payload disagrees.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} orderId - The order id.
|
|
75
|
+
* @returns {{scope_id:(string|null), round:(number|null), attempt:(number|null)}} Nulls where the
|
|
76
|
+
* stem carries no such term (a non-build operation has no round or attempt).
|
|
77
|
+
*/
|
|
78
|
+
export function parseOrderStem(orderId) {
|
|
79
|
+
const stem = orderStem(orderId) || "";
|
|
80
|
+
const m = stem.match(/^(?:(.*)-)?r(\d+)-a(\d+)$/);
|
|
81
|
+
if (m) return { scope_id: m[1] || null, round: Number(m[2]), attempt: Number(m[3]) };
|
|
82
|
+
const opRound = stem.match(/^(.*)-r(\d+)$/);
|
|
83
|
+
if (opRound) return { scope_id: null, round: Number(opRound[2]), attempt: null };
|
|
84
|
+
return { scope_id: null, round: null, attempt: null };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The order stem a journal row refers to, via the `result_path` its schema'd reply carries.
|
|
89
|
+
* @param {object} row - One `journal.jsonl` row.
|
|
90
|
+
* @returns {(string|null)} The stem, or null when the row named no result path.
|
|
91
|
+
*/
|
|
92
|
+
export function journalOrderStem(row) {
|
|
93
|
+
const p = row?.result?.result_path;
|
|
94
|
+
if (typeof p !== "string" || !p) return null;
|
|
95
|
+
const base = p.split(/[/\\]/).pop() || "";
|
|
96
|
+
return base.replace(/\.json$/i, "") || null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Project the run dimension — one row, the thing every fact table's `run_id` points at.
|
|
101
|
+
* @param {object} o - Sources (destructured):
|
|
102
|
+
* @param {(object|null)} o.receipt - Parsed `receipt.json`.
|
|
103
|
+
* @param {(object|null)} [o.ledger] - Parsed `harness-run.md` frontmatter (a flat scalar map).
|
|
104
|
+
* @param {(string|null)} [o.runId] - The run key, when already resolved.
|
|
105
|
+
* @returns {(object|null)} The run row, or null when there is no receipt to describe.
|
|
106
|
+
*/
|
|
107
|
+
export function runRow({ receipt, ledger = null, runId = null }) {
|
|
108
|
+
if (!receipt) return null;
|
|
109
|
+
const c = receipt.config || {};
|
|
110
|
+
const fm = ledger || {};
|
|
111
|
+
return {
|
|
112
|
+
run_id: runId ?? receipt.run_id ?? null,
|
|
113
|
+
slug: receipt.slug ?? null,
|
|
114
|
+
started_at: receipt.started_at ?? null,
|
|
115
|
+
closed_at: fm.closed_at && fm.closed_at !== "~" ? fm.closed_at : null,
|
|
116
|
+
intake_sha256: receipt.intake_sha256 ?? null,
|
|
117
|
+
intake_chars: num(receipt.intake_chars),
|
|
118
|
+
intake_lines: num(receipt.intake_lines),
|
|
119
|
+
auto_level: c.auto_level ?? null,
|
|
120
|
+
lens: c.lens ?? null,
|
|
121
|
+
lane: c.fit?.lane ?? null,
|
|
122
|
+
lane_overridden_from: c.fit?.overridden_from ?? null,
|
|
123
|
+
max_rounds: num(c.max_rounds),
|
|
124
|
+
attempt_budget: num(c.attempt_budget),
|
|
125
|
+
wall_clock_budget_s: num(c.wall_clock_budget_s),
|
|
126
|
+
eval_dimensions: Array.isArray(c.eval_dimensions) ? c.eval_dimensions.join(" ") : null,
|
|
127
|
+
// Copied from the ledger, never re-derived: the run's own status line is the harness's answer,
|
|
128
|
+
// and a read plane that recomputed it would be asserting a second one.
|
|
129
|
+
status: fm.status ?? null,
|
|
130
|
+
final_verdict: fm.final_verdict && fm.final_verdict !== "~" ? fm.final_verdict : null,
|
|
131
|
+
rounds_used: num(Number(fm.rounds_used)),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Project one agent-call row from a journal row.
|
|
137
|
+
* @param {object} row - One `journal.jsonl` row.
|
|
138
|
+
* @param {(string|null)} runId - The run key to stamp when the row itself carries none.
|
|
139
|
+
* @returns {object} A flat `agent_call` fact row.
|
|
140
|
+
*/
|
|
141
|
+
export function agentCallRow(row, runId = null) {
|
|
142
|
+
const sessions = Array.isArray(row?.sessions) ? row.sessions : [];
|
|
143
|
+
return {
|
|
144
|
+
run_id: row?.run_id ?? runId ?? null,
|
|
145
|
+
seq: num(row?.seq),
|
|
146
|
+
phase: row?.phase ?? null,
|
|
147
|
+
label: row?.label ?? null,
|
|
148
|
+
model: row?.model ?? null,
|
|
149
|
+
permission_mode: row?.permission_mode ?? null,
|
|
150
|
+
started_at: row?.started_at ?? null,
|
|
151
|
+
wall_ms: num(row?.wall_ms),
|
|
152
|
+
// The launcher retries a schema-invalid reply once, so `attempts` > 1 is a fact about the
|
|
153
|
+
// WORKER's compliance and is kept distinct from `ok`, which is about the final outcome.
|
|
154
|
+
attempts: num(row?.attempts),
|
|
155
|
+
ok: typeof row?.ok === "boolean" ? row.ok : null,
|
|
156
|
+
cost_usd: sumOrNull(sessions.map((s) => s?.cost_usd)),
|
|
157
|
+
sessions: sessions.length,
|
|
158
|
+
errored: sessions.some((s) => s?.is_error === true),
|
|
159
|
+
killed: sessions.some((s) => s?.killed === true),
|
|
160
|
+
order_stem: journalOrderStem(row),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Project the dispatch fact table and its three child tables.
|
|
166
|
+
*
|
|
167
|
+
* One row per ORDER — orders are the spine, because an order with no result is the fact you most
|
|
168
|
+
* need (a dispatch that never came back), and a result with no order cannot exist by construction.
|
|
169
|
+
*
|
|
170
|
+
* @param {object} o - Sources (destructured):
|
|
171
|
+
* @param {Array<object>} o.orders - Parsed WorkOrders.
|
|
172
|
+
* @param {Array<object>} [o.results] - Parsed WorkResults; joined on `order_id`.
|
|
173
|
+
* @param {Array<object>} [o.journal] - Parsed journal rows; joined on the result-path stem.
|
|
174
|
+
* @param {(string|null)} [o.runId] - Run key for orders that carry none (pre-v1.8 traces).
|
|
175
|
+
* @returns {{dispatch:Array<object>, ac_result:Array<object>, discovery:Array<object>,
|
|
176
|
+
* file_touched:Array<object>}} The fact table and its children, each row already carrying
|
|
177
|
+
* `run_id` + `order_id` so every table stands alone in the warehouse.
|
|
178
|
+
*/
|
|
179
|
+
export function dispatchFacts({ orders, results = [], journal = [], runId = null }) {
|
|
180
|
+
const byOrderId = new Map();
|
|
181
|
+
for (const r of results) if (r?.order_id) byOrderId.set(r.order_id, r);
|
|
182
|
+
const byStem = new Map();
|
|
183
|
+
for (const j of journal) {
|
|
184
|
+
const stem = journalOrderStem(j);
|
|
185
|
+
// Last write wins: a dispatch retried after a failure legitimately produces two journal rows
|
|
186
|
+
// for one order, and the one that finished it is the one whose cost the dispatch carries. The
|
|
187
|
+
// discarded row is NOT lost — it remains its own `agent_call` row, so the retry is still
|
|
188
|
+
// visible and the two totals differ by exactly the retries.
|
|
189
|
+
if (stem) byStem.set(stem, j);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const dispatch = [], ac_result = [], discovery = [], file_touched = [];
|
|
193
|
+
|
|
194
|
+
for (const order of orders) {
|
|
195
|
+
if (!order?.order_id) continue;
|
|
196
|
+
const id = order.order_id;
|
|
197
|
+
const rid = order.run_id ?? runId ?? null;
|
|
198
|
+
const stem = orderStem(id);
|
|
199
|
+
const { scope_id, round, attempt } = parseOrderStem(id);
|
|
200
|
+
const result = byOrderId.get(id) || null;
|
|
201
|
+
const call = stem ? byStem.get(stem) || null : null;
|
|
202
|
+
const agent = call ? agentCallRow(call, rid) : null;
|
|
203
|
+
const taskResults = Array.isArray(result?.task_results) ? result.task_results : [];
|
|
204
|
+
const discoveries = Array.isArray(result?.discoveries) ? result.discoveries : [];
|
|
205
|
+
const filesTouched = Array.isArray(result?.files_touched) ? result.files_touched : [];
|
|
206
|
+
|
|
207
|
+
let acPass = 0, acFail = 0, acSkip = 0;
|
|
208
|
+
for (const tr of taskResults) {
|
|
209
|
+
for (const ac of Array.isArray(tr?.ac_results) ? tr.ac_results : []) {
|
|
210
|
+
if (ac?.result === "pass") acPass++;
|
|
211
|
+
else if (ac?.result === "fail") acFail++;
|
|
212
|
+
else acSkip++;
|
|
213
|
+
ac_result.push({
|
|
214
|
+
run_id: rid, order_id: id,
|
|
215
|
+
task_id: tr?.task_id ?? null,
|
|
216
|
+
ac: ac?.ac ?? null,
|
|
217
|
+
result: ac?.result ?? null,
|
|
218
|
+
// The evidence TEXT is the worker's prose and belongs in the result file, not in a fact
|
|
219
|
+
// table. Whether it exists at all is the fact — "no evidence = fail by the worker's own
|
|
220
|
+
// hand" is a contract the warehouse can then check without re-reading every envelope.
|
|
221
|
+
has_evidence: !!(ac?.evidence && String(ac.evidence).trim()),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
for (const d of discoveries) {
|
|
226
|
+
discovery.push({
|
|
227
|
+
run_id: rid, order_id: id,
|
|
228
|
+
marker: d?.marker ?? null,
|
|
229
|
+
lens: d?.lens ?? null,
|
|
230
|
+
severity_hint: d?.severity_hint ?? null,
|
|
231
|
+
test_gap: d?.test_gap ?? null,
|
|
232
|
+
contradicts: d?.contradicts ?? null,
|
|
233
|
+
has_repro: !!(d?.repro && String(d.repro).trim()),
|
|
234
|
+
line: d?.line ?? null,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
for (const f of filesTouched) {
|
|
238
|
+
file_touched.push({
|
|
239
|
+
run_id: rid, order_id: id,
|
|
240
|
+
path: f?.path ?? null,
|
|
241
|
+
change: f?.change ?? null,
|
|
242
|
+
lines: num(f?.lines),
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
dispatch.push({
|
|
247
|
+
run_id: rid,
|
|
248
|
+
order_id: id,
|
|
249
|
+
slug: id.split("/")[0] || null,
|
|
250
|
+
stem,
|
|
251
|
+
worker: order.worker ?? null,
|
|
252
|
+
operation: order.operation ?? null,
|
|
253
|
+
mode: order.mode ?? null,
|
|
254
|
+
scope_id: scope_id ?? order.payload?.scope_contract?.scope_id ?? null,
|
|
255
|
+
round, attempt,
|
|
256
|
+
compiled_at: order.compiled_at ?? null,
|
|
257
|
+
tasks_ordered: Array.isArray(order.payload?.tasks) ? order.payload.tasks.length : 0,
|
|
258
|
+
digested_errors: Array.isArray(order.payload?.digested_errors) ? order.payload.digested_errors.length : 0,
|
|
259
|
+
// `null`, not `"missing"`: a dispatch with no result file is the single most important row
|
|
260
|
+
// in this table, and it must be filterable as an absence rather than as a status value that
|
|
261
|
+
// sorts alongside real ones.
|
|
262
|
+
result_status: result?.status ?? null,
|
|
263
|
+
answered: !!result,
|
|
264
|
+
task_results: taskResults.length,
|
|
265
|
+
ac_pass: acPass, ac_fail: acFail, ac_skipped: acSkip,
|
|
266
|
+
discoveries: discoveries.length,
|
|
267
|
+
files_touched: filesTouched.length,
|
|
268
|
+
lines_touched: sumOrNull(filesTouched.map((f) => f?.lines)),
|
|
269
|
+
has_verdict: !!result?.verdict,
|
|
270
|
+
verdict_overall: result?.verdict?.overall ?? null,
|
|
271
|
+
assumptions: Array.isArray(result?.assumptions) ? result.assumptions.length : 0,
|
|
272
|
+
deviations: Array.isArray(result?.deviations) ? result.deviations.length : 0,
|
|
273
|
+
// The agent-call leg. Present only where the join held; `agent_join` says which.
|
|
274
|
+
agent_seq: agent?.seq ?? null,
|
|
275
|
+
model: agent?.model ?? null,
|
|
276
|
+
wall_ms: agent?.wall_ms ?? null,
|
|
277
|
+
cost_usd: agent?.cost_usd ?? null,
|
|
278
|
+
agent_attempts: agent?.attempts ?? null,
|
|
279
|
+
agent_ok: agent?.ok ?? null,
|
|
280
|
+
agent_join: agent ? "result_path" : null,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
return { dispatch, ac_result, discovery, file_touched };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The run-economics projection — measurement-table row 4, computed from records the harness
|
|
288
|
+
* already writes.
|
|
289
|
+
*
|
|
290
|
+
* Every field is a count, a sum or a duration over recorded rows. Nothing here is normalised
|
|
291
|
+
* against a baseline, because no baseline dataset exists: this reports what a run cost, never
|
|
292
|
+
* whether that was good, which would be a grade.
|
|
293
|
+
*
|
|
294
|
+
* @param {object} o - Sources (destructured):
|
|
295
|
+
* @param {Array<object>} o.agent_call - Rows from {@link agentCallRow}.
|
|
296
|
+
* @param {Array<object>} [o.dispatch] - Rows from {@link dispatchFacts}; supplies the first write.
|
|
297
|
+
* @param {(object|null)} [o.run] - The run row; supplies `started_at` for the latency measures.
|
|
298
|
+
* @returns {object} `{agent_calls, cost_usd, cost_attributed_usd, cost_unattributed_usd,
|
|
299
|
+
* wall_ms_total, calls_to_first_write, seconds_to_first_write, by_model[], retried_calls,
|
|
300
|
+
* failed_calls, dispatches, dispatches_answered, dispatches_costed}` — with nulls, never zeros,
|
|
301
|
+
* wherever the underlying record was absent.
|
|
302
|
+
*/
|
|
303
|
+
export function economics({ agent_call, dispatch = [], run = null }) {
|
|
304
|
+
const calls = Array.isArray(agent_call) ? agent_call : [];
|
|
305
|
+
const wroteBy = new Set(dispatch.filter((d) => (d.files_touched || 0) > 0).map((d) => d.stem));
|
|
306
|
+
|
|
307
|
+
// "Turns to first write" — the harness's own definition of the metric the design doc names but
|
|
308
|
+
// has never had an instrument for. Measured in AGENT CALLS, because a call is the unit that
|
|
309
|
+
// costs money; the seconds figure is reported beside it so a run that is slow and a run that is
|
|
310
|
+
// chatty stay distinguishable.
|
|
311
|
+
const ordered = [...calls].sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
|
|
312
|
+
const firstWriteIdx = ordered.findIndex((c) => c.order_stem && wroteBy.has(c.order_stem));
|
|
313
|
+
const firstWrite = firstWriteIdx === -1 ? null : ordered[firstWriteIdx];
|
|
314
|
+
const startMs = run?.started_at ? Date.parse(run.started_at) : NaN;
|
|
315
|
+
const firstMs = firstWrite?.started_at ? Date.parse(firstWrite.started_at) : NaN;
|
|
316
|
+
|
|
317
|
+
const byModel = new Map();
|
|
318
|
+
for (const c of calls) {
|
|
319
|
+
const k = c.model || "(unnamed)";
|
|
320
|
+
const m = byModel.get(k) || { model: k, calls: 0, cost_usd: null, wall_ms: null };
|
|
321
|
+
m.calls++;
|
|
322
|
+
if (num(c.cost_usd) !== null) m.cost_usd = (m.cost_usd ?? 0) + c.cost_usd;
|
|
323
|
+
if (num(c.wall_ms) !== null) m.wall_ms = (m.wall_ms ?? 0) + c.wall_ms;
|
|
324
|
+
byModel.set(k, m);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const costed = new Set(dispatch.filter((d) => num(d.cost_usd) !== null).map((d) => d.order_id));
|
|
328
|
+
return {
|
|
329
|
+
agent_calls: calls.length,
|
|
330
|
+
cost_usd: sumOrNull(calls.map((c) => c.cost_usd)),
|
|
331
|
+
// The unattributed share is reported, never hidden: it is what the dispatch table's cost
|
|
332
|
+
// column is MISSING, and an analyst who cannot see it will read a partial total as a full one.
|
|
333
|
+
cost_attributed_usd: sumOrNull(calls.filter((c) => c.order_stem).map((c) => c.cost_usd)),
|
|
334
|
+
cost_unattributed_usd: sumOrNull(calls.filter((c) => !c.order_stem).map((c) => c.cost_usd)),
|
|
335
|
+
wall_ms_total: sumOrNull(calls.map((c) => c.wall_ms)),
|
|
336
|
+
calls_to_first_write: firstWriteIdx === -1 ? null : firstWriteIdx + 1,
|
|
337
|
+
seconds_to_first_write: Number.isFinite(startMs) && Number.isFinite(firstMs)
|
|
338
|
+
? Math.round((firstMs - startMs) / 1000) : null,
|
|
339
|
+
retried_calls: calls.filter((c) => (c.attempts ?? 0) > 1).length,
|
|
340
|
+
failed_calls: calls.filter((c) => c.ok === false).length,
|
|
341
|
+
killed_calls: calls.filter((c) => c.killed).length,
|
|
342
|
+
dispatches: dispatch.length,
|
|
343
|
+
dispatches_answered: dispatch.filter((d) => d.answered).length,
|
|
344
|
+
dispatches_costed: costed.size,
|
|
345
|
+
by_model: [...byModel.values()].sort((a, b) => a.model.localeCompare(b.model)),
|
|
346
|
+
};
|
|
347
|
+
}
|