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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Board derivation
|
|
2
|
+
// Board derivation — the planner's graph math, as code a model never re-derives.
|
|
3
3
|
//
|
|
4
4
|
// The mechanical half of the old ba-pitch-analyzer Phase 7b + v3.3 link-field rules — pure
|
|
5
5
|
// graph math a model should never re-derive (and gets wrong: hand-authored inverses drift asymmetric
|
|
@@ -11,25 +11,27 @@
|
|
|
11
11
|
// • Appetite Guard arithmetic (--appetite-hours N → overflow flag; the HAMMER *pause* on
|
|
12
12
|
// overflow is an orchestrator gate, never resolved here)
|
|
13
13
|
// • board-vs-T0 drift check (a FINISHED scope whose tasks still read `ready`) when scope
|
|
14
|
-
// contracts
|
|
14
|
+
// contracts anchor use cases the board's tasks name back — flag, never fix
|
|
15
|
+
// • board-vs-dispatch reconciliation (BOARD-UNDISPATCHED, Phase 3.5 / S6): a scope with a
|
|
16
|
+
// done-marked task but no dispatched-and-answered order anywhere for it — a cut, never
|
|
17
|
+
// dispatched scope cannot read `done` — flag, never fix
|
|
15
18
|
//
|
|
16
19
|
// Zero dependencies. Usage:
|
|
17
|
-
// node
|
|
20
|
+
// node kernel/harness.mjs reduce board --slug <slug> [--cwd <dir>] [--write]
|
|
18
21
|
// [--appetite-hours N]
|
|
19
22
|
// Prints a JSON report; exit 0 (drift/overflow are flags for the caller's gate, not errors).
|
|
20
23
|
|
|
21
24
|
import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
|
|
22
25
|
import { resolve, join } from "node:path";
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import { readAllContracts, splitFrontmatter, SCOPE_CONTRACT } from "../../tech-lead/scripts/lib/contract-md.mjs";
|
|
26
|
+
import { runArgs } from "../lib/argv.mjs";
|
|
27
|
+
import { tasksDir, scopesDir, hillDir, ordersDir, resultsDir } from "../lib/paths.mjs";
|
|
28
|
+
import { readAllContracts, splitFrontmatter, tasksForScope, SCOPE_CONTRACT } from "../lib/contract.mjs";
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
31
|
* Read a list field from a frontmatter string, inline `[a, b]` or YAML block sequence alike.
|
|
30
32
|
*
|
|
31
33
|
* The SECOND instance of the same defect, in a second parser. This repo had two hand-rolled
|
|
32
|
-
* frontmatter readers: `contract
|
|
34
|
+
* frontmatter readers: `lib/contract.mjs` for the committed contracts, and this one for the board.
|
|
33
35
|
* It was fixed in the first, and this one still silently dropped a block sequence — so a board
|
|
34
36
|
* written as
|
|
35
37
|
* use_case_refs:
|
|
@@ -61,7 +63,7 @@ const listField = (fm, key) => {
|
|
|
61
63
|
* Parse every TASK-*.md in a board directory into structured task records.
|
|
62
64
|
* @param {string} tasksDir - Absolute path to the LOCAL tasks directory.
|
|
63
65
|
* @returns {Array<{file:string, id:string, type:string, status:string, hours:number, pkg:string,
|
|
64
|
-
* depends_on:string[], unlocks:string[], use_case_refs:string[], body:string}>} One record per
|
|
66
|
+
* depends_on:string[], unlocks:string[], use_case_refs:string[], scope_id:string, body:string}>} One record per
|
|
65
67
|
* task file; [] when the directory does not exist.
|
|
66
68
|
*/
|
|
67
69
|
export function parseBoard(tasksDir) {
|
|
@@ -81,6 +83,7 @@ export function parseBoard(tasksDir) {
|
|
|
81
83
|
depends_on: listField(fm, "depends_on"),
|
|
82
84
|
unlocks: listField(fm, "unlocks"),
|
|
83
85
|
use_case_refs: listField(fm, "use_case_refs"),
|
|
86
|
+
scope_id: (fm.match(/^scope_id:\s*(\S+)/im) || [, ""])[1].trim(),
|
|
84
87
|
body,
|
|
85
88
|
};
|
|
86
89
|
});
|
|
@@ -136,24 +139,89 @@ export function criticalPath(tasks) {
|
|
|
136
139
|
}
|
|
137
140
|
|
|
138
141
|
/**
|
|
139
|
-
* Flag board-vs-T0 drift: FINISHED scopes whose
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
+
* Flag board-vs-T0 drift: FINISHED scopes whose tasks are not yet done (flag only, never fix).
|
|
143
|
+
*
|
|
144
|
+
* The scope's tasks are joined through its committed `use_cases`, not read off a task-id list the
|
|
145
|
+
* contract used to carry — see {@link tasksForScope}. A scope that anchors no use case contributes
|
|
146
|
+
* nothing here rather than a false clean bill: "no anchor" is SCOPE-ANCHOR's finding to report.
|
|
147
|
+
*
|
|
148
|
+
* @param {Array<{id:string, status:string, use_case_refs?:string[]}>} tasks - The parsed board.
|
|
149
|
+
* @param {Array<{scope_id:string, use_cases?:string[], finished?:boolean}>} scopes - Scope facts.
|
|
142
150
|
* @returns {Array<{scope_id:string, task_id:string, status:string}>} One entry per drifting task; [] when none.
|
|
143
151
|
*/
|
|
144
152
|
export function driftCheck(tasks, scopes) {
|
|
145
|
-
const byId = Object.fromEntries(tasks.map((t) => [t.id, t]));
|
|
146
153
|
const drift = [];
|
|
147
154
|
for (const s of scopes) {
|
|
148
|
-
if (!
|
|
149
|
-
for (const
|
|
150
|
-
|
|
151
|
-
if (t && t.status !== "done") drift.push({ scope_id: s.scope_id, task_id: id, status: t.status });
|
|
155
|
+
if (!s.finished) continue;
|
|
156
|
+
for (const t of tasksForScope(tasks, s)) {
|
|
157
|
+
if (t.status !== "done") drift.push({ scope_id: s.scope_id, task_id: t.id, status: t.status });
|
|
152
158
|
}
|
|
153
159
|
}
|
|
154
160
|
return drift;
|
|
155
161
|
}
|
|
156
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Read every dispatched-and-answered order's scope id (Phase 3.5 / S6).
|
|
165
|
+
*
|
|
166
|
+
* "Dispatched and answered" is filename presence in both directories, the same completion signal
|
|
167
|
+
* `sandbox-guard`'s `liveOrders()` already uses — a result join on content would let a malformed
|
|
168
|
+
* result count as an answer. The scope an order belongs to comes from `payload.scope_contract.
|
|
169
|
+
* scope_id`, not the filename: a non-BUILD order's suffix puts the operation before the scope id
|
|
170
|
+
* (`compileOrder`'s `scopedRoundSuffix`), so filename-prefix matching would miss it.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} orders - Absolute path to the run's `orders/` directory.
|
|
173
|
+
* @param {string} results - Absolute path to the run's `results/` directory.
|
|
174
|
+
* @returns {Set<string>} scope_ids with at least one dispatched order answered by a same-named
|
|
175
|
+
* result file; empty when neither directory has anything to read.
|
|
176
|
+
*/
|
|
177
|
+
function answeredScopeIds(orders, results) {
|
|
178
|
+
const answered = new Set();
|
|
179
|
+
if (!existsSync(orders)) return answered;
|
|
180
|
+
const done = new Set(existsSync(results) ? readdirSync(results) : []);
|
|
181
|
+
for (const f of readdirSync(orders)) {
|
|
182
|
+
if (!f.endsWith(".json") || !done.has(f)) continue;
|
|
183
|
+
try {
|
|
184
|
+
const sid = JSON.parse(readFileSync(join(orders, f), "utf8"))?.payload?.scope_contract?.scope_id;
|
|
185
|
+
if (sid) answered.add(sid);
|
|
186
|
+
} catch { /* an unreadable order answers nothing — spec-lint reports unreadable contracts, not this */ }
|
|
187
|
+
}
|
|
188
|
+
return answered;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Flag a scope whose board tasks read done but has no dispatched-and-answered order on disk
|
|
193
|
+
* (Phase 3.5 / S6) — the defect that motivated this phase: a scope cut before it was ever
|
|
194
|
+
* dispatched can still read `done` on the board because nothing compared the board's rows to what
|
|
195
|
+
* was actually dispatched, though both the scope contract and the order/result trail already exist
|
|
196
|
+
* on disk. A sibling to {@link driftCheck} (a FINISHED scope whose tasks are NOT done) — this is
|
|
197
|
+
* the opposite direction (done tasks, no dispatch trail) and both must keep running.
|
|
198
|
+
*
|
|
199
|
+
* @param {Array<{id:string, status:string, use_case_refs?:string[]}>} tasks - The parsed board.
|
|
200
|
+
* @param {Array<{scope_id:string, use_cases?:string[]}>} scopes - Scope contracts.
|
|
201
|
+
* @param {string} orders - Absolute path to the run's `orders/` directory.
|
|
202
|
+
* @param {string} results - Absolute path to the run's `results/` directory.
|
|
203
|
+
* @returns {Array<{rule:string, level:string, scope_id:string, detail:string}>} One finding per
|
|
204
|
+
* undispatched-but-done scope, in this codebase's `{rule, level, detail}` finding shape
|
|
205
|
+
* (`kernel/verify/spec.mjs`); [] when every done-marked scope has a dispatch trail.
|
|
206
|
+
*/
|
|
207
|
+
export function undispatchedCheck(tasks, scopes, orders, results) {
|
|
208
|
+
const answered = answeredScopeIds(orders, results);
|
|
209
|
+
const findings = [];
|
|
210
|
+
for (const s of scopes) {
|
|
211
|
+
// Joined through the contract's committed `use_cases`, same as driftCheck — the two read the
|
|
212
|
+
// scope↔task relation from one function so they cannot disagree about which tasks a scope owns.
|
|
213
|
+
const doneTaskId = tasksForScope(tasks, s).find((t) => t.status === "done")?.id;
|
|
214
|
+
if (doneTaskId && !answered.has(s.scope_id)) {
|
|
215
|
+
findings.push({
|
|
216
|
+
rule: "BOARD-UNDISPATCHED", level: "red", scope_id: s.scope_id,
|
|
217
|
+
detail: `${doneTaskId} reads done for scope ${s.scope_id}, but no order under orders/ has a ` +
|
|
218
|
+
`matching result under results/ for this scope — a cut, never-dispatched scope cannot read done`,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return findings;
|
|
223
|
+
}
|
|
224
|
+
|
|
157
225
|
/**
|
|
158
226
|
* Derive the full board report (unlocks, hours, packages, critical path, appetite overflow, drift).
|
|
159
227
|
* @param {{cwd:string, slug:string, appetiteHours?:(number|null)}} opts - Working root, feature
|
|
@@ -170,7 +238,8 @@ export function derive({ cwd, slug, appetiteHours = null }) {
|
|
|
170
238
|
const packages = {};
|
|
171
239
|
for (const t of tasks) packages[t.pkg || "(none)"] = (packages[t.pkg || "(none)"] || 0) + 1;
|
|
172
240
|
|
|
173
|
-
// Scope facts for the drift check: contract `
|
|
241
|
+
// Scope facts for the drift check: the contract (its `use_cases` carry the task join)
|
|
242
|
+
// + the committed hill shard's phase.
|
|
174
243
|
const hillRoot = hillDir(cwd, slug);
|
|
175
244
|
const scopes = readAllContracts(scopesDir(cwd, slug), SCOPE_CONTRACT).map(({ contract }) => {
|
|
176
245
|
const shard = join(hillRoot, `${contract.scope_id}.yml`);
|
|
@@ -194,6 +263,7 @@ export function derive({ cwd, slug, appetiteHours = null }) {
|
|
|
194
263
|
unlocks,
|
|
195
264
|
unlocks_stale: tasks.filter((t) => JSON.stringify([...t.unlocks].sort()) !== JSON.stringify(unlocks[t.id])).map((t) => t.id),
|
|
196
265
|
drift: driftCheck(tasks, scopes),
|
|
266
|
+
undispatched: undispatchedCheck(tasks, scopes, ordersDir(cwd, slug), resultsDir(cwd, slug)),
|
|
197
267
|
_tasks: tasks,
|
|
198
268
|
};
|
|
199
269
|
}
|
|
@@ -220,9 +290,9 @@ export function writeUnlocks(report) {
|
|
|
220
290
|
return written;
|
|
221
291
|
}
|
|
222
292
|
|
|
223
|
-
/** The typed argv contract (see `
|
|
293
|
+
/** The typed argv contract (see `kernel/lib/argv.mjs`). */
|
|
224
294
|
export const ARGV_SPEC = {
|
|
225
|
-
usage: "
|
|
295
|
+
usage: "harness.mjs reduce board --slug <slug> [--cwd <dir>] [--write] [--appetite-hours N]",
|
|
226
296
|
_: { arity: 0, max: 0, name: "(no positional operands)" },
|
|
227
297
|
slug: { type: "str", required: true },
|
|
228
298
|
cwd: { type: "path" },
|
|
@@ -230,9 +300,15 @@ export const ARGV_SPEC = {
|
|
|
230
300
|
"appetite-hours": { type: "num", min: 0 },
|
|
231
301
|
};
|
|
232
302
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
303
|
+
/**
|
|
304
|
+
* Derive the task board from the scope contracts, and optionally write it.
|
|
305
|
+
*
|
|
306
|
+
* @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
|
|
307
|
+
* @returns {(Promise<void>|void)} Settles when the subcommand has written its output; most paths
|
|
308
|
+
* call `process.exit()` with the subcommand's documented code rather than returning.
|
|
309
|
+
*/
|
|
310
|
+
export async function cli(rawArgv) {
|
|
311
|
+
const args = runArgs(ARGV_SPEC, rawArgv);
|
|
236
312
|
const cwd = resolve(args.cwd || process.cwd());
|
|
237
313
|
const report = derive({ cwd, slug: args.slug, appetiteHours: args.appetiteHours ?? null });
|
|
238
314
|
if (args.write) report.unlocks_written = writeUnlocks(report);
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
// graph — the run graph: one typed, append-only edge list per feature.
|
|
2
|
+
//
|
|
3
|
+
// WHAT IT IS FOR. The harness has always been graph-shaped and never a graph: orders produce
|
|
4
|
+
// results, results produce verdicts, verdicts cite T0 artifacts, scopes cover requirements, trials
|
|
5
|
+
// supersede trials. All of it was real and all of it was scattered across markdown and JSON, so
|
|
6
|
+
// "trace this verdict back to the objective" meant grepping four files, and a relaunch re-derived
|
|
7
|
+
// the whole run state by walking directories. This file makes the same facts one queryable thing.
|
|
8
|
+
//
|
|
9
|
+
// TWO FAMILIES, DELIBERATELY NOT COLLAPSED (they answer different questions and change at different
|
|
10
|
+
// rates):
|
|
11
|
+
// WORK LINEAGE — Run, Order, Result, Verdict, Trial. "What happened, and what produced it."
|
|
12
|
+
// DOMAIN — Scope, UseCase, Requirement, Seam. "What exists, and how it relates."
|
|
13
|
+
// An edge may cross the families (a Scope COVERS a Requirement; an Order is DERIVED_FROM a Scope);
|
|
14
|
+
// a node never belongs to both.
|
|
15
|
+
//
|
|
16
|
+
// THE GRAPH IS DERIVED, NEVER AUTHORED. Every node here is projected from an artifact already on
|
|
17
|
+
// disk, so the graph can be deleted and rebuilt byte-identically, and a run that predates it is
|
|
18
|
+
// backfilled by the same code path that maintains it. That is what makes it safe to treat as the
|
|
19
|
+
// read model: it cannot drift from the artifacts, because it has no independent existence.
|
|
20
|
+
//
|
|
21
|
+
// SINGLE WRITER. `harness reduce graph` is the only thing that appends. It lives under `reduce/`
|
|
22
|
+
// rather than `lib/` to say so in the file layout — `probe` imports it read-only.
|
|
23
|
+
//
|
|
24
|
+
// APPEND-ONLY, and idempotent. Re-running over an unchanged tree appends nothing; re-running after
|
|
25
|
+
// new artifacts land appends only what is new. A node that reappears with new attributes is
|
|
26
|
+
// appended again and the LAST line wins on read, so the file is a log and the projection is a fold.
|
|
27
|
+
|
|
28
|
+
import { existsSync, readdirSync, readFileSync, appendFileSync, mkdirSync } from "node:fs";
|
|
29
|
+
import { join, dirname, basename, resolve } from "node:path";
|
|
30
|
+
import { runArgs } from "../lib/argv.mjs";
|
|
31
|
+
import {
|
|
32
|
+
localRoot, receipt as receiptPath, ordersDir, resultsDir, verdictsDir, trials as trialsPath,
|
|
33
|
+
gates as gatesPath, scopesDir, usecasesDir, requirements as requirementsPath, wiringMap as wiringMapPath,
|
|
34
|
+
} from "../lib/paths.mjs";
|
|
35
|
+
import { readAllContracts, readContract, ucId, SCOPE_CONTRACT, WIRING_MAP } from "../lib/contract.mjs";
|
|
36
|
+
import { runIdFromReceipt } from "../lib/paths.mjs";
|
|
37
|
+
|
|
38
|
+
/** The graph's home — one file per feature, beside the run trace it projects. */
|
|
39
|
+
export const graphPath = (cwd, slug) => join(localRoot(cwd, slug), "graph.jsonl");
|
|
40
|
+
|
|
41
|
+
/** Node types, by family. A type outside these sets is a bug, not an extension point. */
|
|
42
|
+
export const WORK_NODES = ["Run", "Order", "Result", "Verdict", "Trial", "GateDecision"];
|
|
43
|
+
export const DOMAIN_NODES = ["Scope", "UseCase", "Requirement", "Seam"];
|
|
44
|
+
|
|
45
|
+
/** Edge types. Each names a direction that is meaningful to read backwards. */
|
|
46
|
+
export const EDGES = ["PRODUCED", "EVALUATES", "SUPERSEDES", "COVERS", "DEPENDS_ON", "DERIVED_FROM"];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Read the graph as a log and fold it into nodes and edges.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} cwd - Project root.
|
|
52
|
+
* @param {string} slug - Feature slug.
|
|
53
|
+
* @returns {{nodes: Map<string,object>, edges: Map<string,object>, lines: number}} The folded graph;
|
|
54
|
+
* `lines` counts what was read, so a caller can tell an empty graph from an absent one.
|
|
55
|
+
*/
|
|
56
|
+
export function readGraph(cwd, slug) {
|
|
57
|
+
const nodes = new Map(), edges = new Map();
|
|
58
|
+
const p = graphPath(cwd, slug);
|
|
59
|
+
let lines = 0;
|
|
60
|
+
if (!existsSync(p)) return { nodes, edges, lines };
|
|
61
|
+
for (const line of readFileSync(p, "utf8").split("\n")) {
|
|
62
|
+
if (!line.trim()) continue;
|
|
63
|
+
let row;
|
|
64
|
+
try { row = JSON.parse(line); } catch { continue; } // a torn line proves nothing; skip it
|
|
65
|
+
lines++;
|
|
66
|
+
if (row.k === "node" && row.id) nodes.set(row.id, { ...(nodes.get(row.id) || {}), ...row });
|
|
67
|
+
else if (row.k === "edge" && row.from && row.to && row.t) edges.set(`${row.from}|${row.t}|${row.to}`, row);
|
|
68
|
+
}
|
|
69
|
+
return { nodes, edges, lines };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Read a JSON file without throwing. @param {string} p Path. @returns {*} Parsed value or null. */
|
|
73
|
+
function readJson(p) {
|
|
74
|
+
try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Every `## Heading`-anchored id in a requirements or use-case document. */
|
|
78
|
+
function idsIn(text, re) {
|
|
79
|
+
return [...new Set([...String(text || "").matchAll(re)].map((m) => m[1]))];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Project every artifact on disk for one run into nodes and edges.
|
|
84
|
+
*
|
|
85
|
+
* Pure: reads the tree, returns the graph it implies, writes nothing. That is what lets the same
|
|
86
|
+
* function serve both the incremental append and the backfill of a run recorded before this file
|
|
87
|
+
* existed — there is no second code path to keep in step.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} cwd - Project root.
|
|
90
|
+
* @param {string} slug - Feature slug.
|
|
91
|
+
* @returns {{nodes: object[], edges: object[]}} Everything the artifacts imply, in a stable order.
|
|
92
|
+
*/
|
|
93
|
+
export function project(cwd, slug) {
|
|
94
|
+
const nodes = [], edges = [];
|
|
95
|
+
const node = (id, t, attrs = {}) => nodes.push({ k: "node", id, t, ...attrs });
|
|
96
|
+
const edge = (from, t, to) => edges.push({ k: "edge", from, t, to });
|
|
97
|
+
|
|
98
|
+
const root = localRoot(cwd, slug);
|
|
99
|
+
const receipt = readJson(receiptPath(cwd, slug));
|
|
100
|
+
const runId = runIdFromReceipt(receipt);
|
|
101
|
+
const runNode = runId ? `run:${runId}` : null;
|
|
102
|
+
|
|
103
|
+
// ---- work lineage --------------------------------------------------------------------------
|
|
104
|
+
if (runNode) {
|
|
105
|
+
node(runNode, "Run", {
|
|
106
|
+
run_id: runId, slug,
|
|
107
|
+
started_at: receipt?.started_at ?? null,
|
|
108
|
+
intake_sha256: receipt?.intake_sha256 ?? null,
|
|
109
|
+
auto_level: receipt?.config?.auto_level ?? null,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const oDir = ordersDir(cwd, slug);
|
|
114
|
+
const rDir = resultsDir(cwd, slug);
|
|
115
|
+
const orderByFile = new Map();
|
|
116
|
+
if (existsSync(oDir)) {
|
|
117
|
+
for (const f of readdirSync(oDir).filter((x) => x.endsWith(".json")).sort()) {
|
|
118
|
+
const o = readJson(join(oDir, f));
|
|
119
|
+
if (!o?.order_id) continue;
|
|
120
|
+
const id = `order:${o.order_id}`;
|
|
121
|
+
orderByFile.set(f, { id, order: o });
|
|
122
|
+
node(id, "Order", {
|
|
123
|
+
order_id: o.order_id, worker: o.worker ?? null, operation: o.operation ?? null,
|
|
124
|
+
round: o.round ?? null, attempt: o.attempt ?? null,
|
|
125
|
+
scope_id: o.payload?.scope_contract?.scope_id ?? o.payload?.scope_id ?? null,
|
|
126
|
+
run_id: o.run_id ?? runId ?? null,
|
|
127
|
+
});
|
|
128
|
+
if (runNode) edge(runNode, "PRODUCED", id);
|
|
129
|
+
const scopeId = o.payload?.scope_contract?.scope_id ?? o.payload?.scope_id ?? null;
|
|
130
|
+
if (scopeId) edge(id, "DERIVED_FROM", `scope:${slug}:${scopeId}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (existsSync(rDir)) {
|
|
135
|
+
for (const f of readdirSync(rDir).filter((x) => x.endsWith(".json")).sort()) {
|
|
136
|
+
const r = readJson(join(rDir, f));
|
|
137
|
+
if (!r?.order_id) continue;
|
|
138
|
+
const id = `result:${r.order_id}`;
|
|
139
|
+
node(id, "Result", {
|
|
140
|
+
order_id: r.order_id, status: r.status ?? null,
|
|
141
|
+
artifacts: Array.isArray(r.artifacts) ? r.artifacts.length : 0,
|
|
142
|
+
discoveries: Array.isArray(r.discoveries) ? r.discoveries.length : 0,
|
|
143
|
+
});
|
|
144
|
+
edge(`order:${r.order_id}`, "PRODUCED", id);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// T0 verdicts — the artifact the evaluator is required to cite, and the reason the lineage half
|
|
149
|
+
// of this graph is worth having: a verdict node is the anchor of every "show me the evidence".
|
|
150
|
+
const vDir = verdictsDir(cwd, slug);
|
|
151
|
+
const verdictIdsByRound = new Map(); // round -> [verdict node id, ...], for the gate edge below
|
|
152
|
+
if (existsSync(vDir)) {
|
|
153
|
+
for (const f of readdirSync(vDir).filter((x) => x.endsWith(".json")).sort()) {
|
|
154
|
+
const v = readJson(join(vDir, f));
|
|
155
|
+
if (!v) continue;
|
|
156
|
+
const id = `verdict:${slug}:${basename(f, ".json")}`;
|
|
157
|
+
node(id, "Verdict", {
|
|
158
|
+
artifact: f, overall: v.overall ?? null, regression: !!v.regression,
|
|
159
|
+
round: v.round ?? null, attempt: v.attempt ?? null, scope_id: v.scope_id ?? null,
|
|
160
|
+
run_id: v.run_id ?? runId ?? null,
|
|
161
|
+
});
|
|
162
|
+
if (v.scope_id) edge(id, "EVALUATES", `scope:${slug}:${v.scope_id}`);
|
|
163
|
+
if (v.round != null && v.attempt != null && v.scope_id) {
|
|
164
|
+
edge(`order:${slug}/${v.scope_id}-r${v.round}-a${v.attempt}`, "PRODUCED", id);
|
|
165
|
+
}
|
|
166
|
+
if (v.round != null) {
|
|
167
|
+
if (!verdictIdsByRound.has(v.round)) verdictIdsByRound.set(v.round, []);
|
|
168
|
+
verdictIdsByRound.get(v.round).push(id);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Gate crossings — `kernel/gate.mjs` is the sole writer of `gates.jsonl`; this reads the same
|
|
174
|
+
// ledger `verify/t0.mjs` writes for `trials.jsonl`, defensive per-line JSON.parse-or-skip.
|
|
175
|
+
//
|
|
176
|
+
// ID KEYED ON GATE ID + ORDINAL, not gate id alone, for the exact reason the trial key above is
|
|
177
|
+
// not the ordinal alone: L2 and L3 are crossed once per round, so a bare `gate:<slug>:L2` id would
|
|
178
|
+
// collapse every round's crossing onto one node — a repeat of the trial-key bug this file already
|
|
179
|
+
// learned from once. The ordinal counts a gate id's occurrences in ledger order, which also
|
|
180
|
+
// disambiguates round-independent gates (L1a, …) re-crossed across relaunches, where `round` alone
|
|
181
|
+
// is `null` on every row.
|
|
182
|
+
const gPath = gatesPath(cwd, slug);
|
|
183
|
+
if (existsSync(gPath)) {
|
|
184
|
+
const seenByGate = new Map();
|
|
185
|
+
for (const line of readFileSync(gPath, "utf8").split("\n")) {
|
|
186
|
+
if (!line.trim()) continue;
|
|
187
|
+
const g = readJson0(line);
|
|
188
|
+
if (!g?.gate) continue;
|
|
189
|
+
const n = (seenByGate.get(g.gate) || 0) + 1;
|
|
190
|
+
seenByGate.set(g.gate, n);
|
|
191
|
+
const id = `gate:${slug}:${g.gate}:${n}`;
|
|
192
|
+
node(id, "GateDecision", {
|
|
193
|
+
gate: g.gate, decision: g.decision ?? null, status: g.status ?? null,
|
|
194
|
+
source: g.source ?? null, note: g.note ?? null, round: g.round ?? null,
|
|
195
|
+
run_id: g.run_id ?? runId ?? null,
|
|
196
|
+
});
|
|
197
|
+
// A round-scoped gate's decision depends on that round's T0 verdict(s) — the most honest
|
|
198
|
+
// shape, since that is the evidence the decision was made against. Every other gate (and a
|
|
199
|
+
// round-scoped one with no verdict yet on disk) depends on the Run instead, so no
|
|
200
|
+
// `GateDecision` node is ever orphaned.
|
|
201
|
+
const roundVerdicts = (g.gate === "L2" || g.gate === "L3") ? verdictIdsByRound.get(g.round) : null;
|
|
202
|
+
if (roundVerdicts?.length) {
|
|
203
|
+
for (const vid of roundVerdicts) edge(id, "DEPENDS_ON", vid);
|
|
204
|
+
} else if (runNode) {
|
|
205
|
+
edge(id, "DEPENDS_ON", runNode);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Trials — the ratchet's own history, with a real parent link already in the row.
|
|
211
|
+
const tPath = trialsPath(cwd, slug);
|
|
212
|
+
if (existsSync(tPath)) {
|
|
213
|
+
for (const line of readFileSync(tPath, "utf8").split("\n")) {
|
|
214
|
+
if (!line.trim()) continue;
|
|
215
|
+
const t = readJson0(line);
|
|
216
|
+
if (!t?.trial) continue;
|
|
217
|
+
// THE SCOPE IS PART OF THE KEY, because the ordinal alone is not unique. A trial ordinal
|
|
218
|
+
// counts within its scope, so `trial:<slug>:1` names one row per scope and every one of them
|
|
219
|
+
// collapsed onto a single node — the Map this graph is read back into keeps the last writer,
|
|
220
|
+
// so a scope's whole execution record vanished silently. Measured: four scopes' trials
|
|
221
|
+
// projected to two nodes. `--trace` is supposed to reach "the execution record"; it reached
|
|
222
|
+
// whichever scope happened to be written last. `baseline_trial` is chosen from the same
|
|
223
|
+
// scope's prior rows, so the SUPERSEDES edge resolves inside the same partition.
|
|
224
|
+
const trialKey = (n) => `trial:${slug}:${t.scope_id ? `${t.scope_id}:` : ""}${n}`;
|
|
225
|
+
const id = trialKey(t.trial);
|
|
226
|
+
node(id, "Trial", {
|
|
227
|
+
trial: t.trial, round: t.round ?? null, attempt: t.attempt ?? null,
|
|
228
|
+
scope_id: t.scope_id ?? null, status: t.status ?? null, artifact: t.artifact ?? null,
|
|
229
|
+
sha256: t.sha256 ?? null, run_id: t.run_id ?? runId ?? null,
|
|
230
|
+
});
|
|
231
|
+
if (t.baseline_trial) edge(id, "SUPERSEDES", trialKey(t.baseline_trial));
|
|
232
|
+
if (t.artifact) edge(id, "EVALUATES", `verdict:${slug}:${basename(String(t.artifact), ".json")}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---- domain --------------------------------------------------------------------------------
|
|
237
|
+
const sDir = scopesDir(cwd, slug);
|
|
238
|
+
if (existsSync(sDir)) {
|
|
239
|
+
for (const { contract } of readAllContracts(sDir, SCOPE_CONTRACT)) {
|
|
240
|
+
if (!contract?.scope_id) continue;
|
|
241
|
+
const id = `scope:${slug}:${contract.scope_id}`;
|
|
242
|
+
node(id, "Scope", {
|
|
243
|
+
scope_id: contract.scope_id, title: contract.title ?? null,
|
|
244
|
+
substrate: (contract.allowed_file_substrate || []).length,
|
|
245
|
+
});
|
|
246
|
+
// The domain family's entry edges. Before scope contracts anchored into the committed spec
|
|
247
|
+
// these could not be drawn: `covers` had this reader and no writer, and there was no
|
|
248
|
+
// Scope→UseCase relation at all, so a Scope sat in the graph as an isolated node beside the
|
|
249
|
+
// UseCase nodes it was built from and `--trace` stopped there instead of reaching the
|
|
250
|
+
// objective. The contract now names both, so both are projectable.
|
|
251
|
+
for (const uc of contract.use_cases || []) edge(id, "IMPLEMENTS", `uc:${slug}:${ucId(uc)}`);
|
|
252
|
+
for (const req of contract.covers || []) edge(id, "COVERS", `req:${slug}:${req}`);
|
|
253
|
+
for (const dep of contract.depends_on || []) edge(id, "DEPENDS_ON", `scope:${slug}:${dep}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const uDir = usecasesDir(cwd, slug);
|
|
258
|
+
if (existsSync(uDir)) {
|
|
259
|
+
for (const f of readdirSync(uDir).filter((x) => x.endsWith(".md") && x !== "_index.md").sort()) {
|
|
260
|
+
const ucId = basename(f, ".md");
|
|
261
|
+
node(`uc:${slug}:${ucId}`, "UseCase", { use_case: ucId, file: f });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const reqPath = requirementsPath(cwd, slug);
|
|
266
|
+
if (existsSync(reqPath)) {
|
|
267
|
+
for (const r of idsIn(readFileSync(reqPath, "utf8"), /^\s*[-*|]?\s*\**\s*(REQ-[A-Z0-9-]+)/gm)) {
|
|
268
|
+
node(`req:${slug}:${r}`, "Requirement", { req_id: r });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// The wiring map is literally a graph written as a table: per use case, engine → seam →
|
|
273
|
+
// entry-point call site → affordance. Reading it back as edges is the whole reason it is a table.
|
|
274
|
+
const wPath = wiringMapPath(cwd, slug);
|
|
275
|
+
if (existsSync(wPath)) {
|
|
276
|
+
// READ THE FIELDS THE SPEC ACTUALLY PRODUCES. This loop asked for `contract.wiring` while
|
|
277
|
+
// WIRING_MAP names the field `entries`, and for `row.seam`/`row.entry_point` while WiringEntry
|
|
278
|
+
// declares `wiring_seam`/`entry_call_site` — three name mismatches stacked on a parser that was
|
|
279
|
+
// returning nothing anyway. Every one of them independently yields an empty domain half, and
|
|
280
|
+
// none of them can fail loudly: an absent field and an absent wiring map look identical here.
|
|
281
|
+
// The legacy spellings stay as fallbacks so a map written either way still projects.
|
|
282
|
+
const found = readContract(wPath, WIRING_MAP);
|
|
283
|
+
for (const row of found?.contract?.entries || found?.contract?.wiring || found?.contract?.rows || []) {
|
|
284
|
+
const uc = row.use_case || row.uc;
|
|
285
|
+
const seam = row.wiring_seam || row.seam;
|
|
286
|
+
const entryPoint = row.entry_call_site ?? row.entry_point ?? null;
|
|
287
|
+
if (!uc || !seam) continue;
|
|
288
|
+
const seamId = `seam:${slug}:${seam}`;
|
|
289
|
+
node(seamId, "Seam", { seam, engine: row.engine ?? null, entry_point: entryPoint });
|
|
290
|
+
edge(`uc:${slug}:${ucId(uc)}`, "DEPENDS_ON", seamId);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return { nodes, edges };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** JSON.parse for one line, without throwing. @param {string} l Line. @returns {*} Parsed or null. */
|
|
298
|
+
function readJson0(l) {
|
|
299
|
+
try { return JSON.parse(l); } catch { return null; }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Append everything the artifacts imply that the graph does not already carry.
|
|
304
|
+
*
|
|
305
|
+
* @param {string} cwd - Project root.
|
|
306
|
+
* @param {string} slug - Feature slug.
|
|
307
|
+
* @returns {{path: string, nodes_added: number, edges_added: number, nodes_total: number, edges_total: number, backfilled: boolean}}
|
|
308
|
+
* `backfilled` is true when the graph did not exist before this call — the migration case, which
|
|
309
|
+
* is the same code path as the steady-state one.
|
|
310
|
+
*/
|
|
311
|
+
export function appendGraph(cwd, slug) {
|
|
312
|
+
const p = graphPath(cwd, slug);
|
|
313
|
+
const before = readGraph(cwd, slug);
|
|
314
|
+
const backfilled = before.lines === 0;
|
|
315
|
+
const { nodes, edges } = project(cwd, slug);
|
|
316
|
+
|
|
317
|
+
const out = [];
|
|
318
|
+
for (const n of nodes) {
|
|
319
|
+
const prev = before.nodes.get(n.id);
|
|
320
|
+
// Append only when something actually changed — the file is a log, and a log that repeats
|
|
321
|
+
// itself on every run stops being readable long before it stops being correct.
|
|
322
|
+
if (!prev || JSON.stringify({ ...prev, k: undefined }) !== JSON.stringify({ ...n, k: undefined })) out.push(n);
|
|
323
|
+
}
|
|
324
|
+
for (const e of edges) {
|
|
325
|
+
if (!before.edges.has(`${e.from}|${e.t}|${e.to}`)) out.push(e);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (out.length) {
|
|
329
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
330
|
+
appendFileSync(p, out.map((r) => JSON.stringify(r)).join("\n") + "\n");
|
|
331
|
+
}
|
|
332
|
+
const after = readGraph(cwd, slug);
|
|
333
|
+
return {
|
|
334
|
+
path: p,
|
|
335
|
+
nodes_added: out.filter((r) => r.k === "node").length,
|
|
336
|
+
edges_added: out.filter((r) => r.k === "edge").length,
|
|
337
|
+
nodes_total: after.nodes.size,
|
|
338
|
+
edges_total: after.edges.size,
|
|
339
|
+
backfilled,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* The bounded subgraph a relaunch needs — the fast-forward as a query rather than a directory walk.
|
|
345
|
+
*
|
|
346
|
+
* @param {string} cwd - Project root.
|
|
347
|
+
* @param {string} slug - Feature slug.
|
|
348
|
+
* @returns {object} Counts and ids only. Deliberately small: the point of the graph is that the
|
|
349
|
+
* orchestrator's working set is a subgraph, not a re-read of all state.
|
|
350
|
+
*/
|
|
351
|
+
export function runSubgraph(cwd, slug) {
|
|
352
|
+
const { nodes, edges, lines } = readGraph(cwd, slug);
|
|
353
|
+
const of = (t) => [...nodes.values()].filter((n) => n.t === t);
|
|
354
|
+
const orders = of("Order"), results = of("Result"), verdicts = of("Verdict");
|
|
355
|
+
const resultIds = new Set(results.map((r) => r.order_id));
|
|
356
|
+
const greenByRound = {};
|
|
357
|
+
for (const v of verdicts) {
|
|
358
|
+
if (v.overall !== "green" || v.round == null || !v.scope_id) continue;
|
|
359
|
+
(greenByRound[v.round] ||= new Set()).add(v.scope_id);
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
graph_lines: lines,
|
|
363
|
+
run: of("Run")[0]?.run_id ?? null,
|
|
364
|
+
scopes: of("Scope").map((s) => s.scope_id).sort(),
|
|
365
|
+
use_cases: of("UseCase").map((u) => u.use_case).sort(),
|
|
366
|
+
requirements: of("Requirement").map((r) => r.req_id).sort(),
|
|
367
|
+
seams: of("Seam").map((s) => s.seam).sort(),
|
|
368
|
+
orders: orders.length,
|
|
369
|
+
pending_orders: orders.filter((o) => !resultIds.has(o.order_id)).map((o) => o.order_id).sort(),
|
|
370
|
+
rounds_with_green: Object.keys(greenByRound).map(Number).sort((a, b) => a - b),
|
|
371
|
+
green_scopes_by_round: Object.fromEntries(Object.entries(greenByRound).map(([r, s]) => [r, [...s].sort()])),
|
|
372
|
+
trials: of("Trial").length,
|
|
373
|
+
edges: edges.size,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Walk backwards from one node, following every edge that points at it or away from it.
|
|
379
|
+
*
|
|
380
|
+
* This is the reliability invariant made executable: pick a verdict, walk to the order that
|
|
381
|
+
* produced it, the scope it evaluates, the requirements that scope covers, and the trials that
|
|
382
|
+
* superseded it — in one query, over one file, instead of a grep across four.
|
|
383
|
+
*
|
|
384
|
+
* @param {string} cwd - Project root.
|
|
385
|
+
* @param {string} slug - Feature slug.
|
|
386
|
+
* @param {string} startId - The node to trace from.
|
|
387
|
+
* @param {number} [depth=4] - How many hops to follow.
|
|
388
|
+
* @returns {{start: string, found: boolean, path: object[]}} Each hop as `{from, t, to, node}`. A
|
|
389
|
+
* hop whose far node carries `missing: true` is a dangling edge — the artifact it names is not on
|
|
390
|
+
* disk. That is reported rather than dropped: an edge to nothing is a fact about the run.
|
|
391
|
+
*/
|
|
392
|
+
export function trace(cwd, slug, startId, depth = 4) {
|
|
393
|
+
const { nodes, edges } = readGraph(cwd, slug);
|
|
394
|
+
if (!nodes.has(startId)) return { start: startId, found: false, path: [] };
|
|
395
|
+
const seen = new Set([startId]);
|
|
396
|
+
let frontier = [startId];
|
|
397
|
+
const path = [];
|
|
398
|
+
for (let d = 0; d < depth && frontier.length; d++) {
|
|
399
|
+
const next = [];
|
|
400
|
+
for (const e of edges.values()) {
|
|
401
|
+
for (const [near, far] of [[e.from, e.to], [e.to, e.from]]) {
|
|
402
|
+
if (!frontier.includes(near) || seen.has(far)) continue;
|
|
403
|
+
seen.add(far);
|
|
404
|
+
next.push(far);
|
|
405
|
+
path.push({ from: e.from, t: e.t, to: e.to, node: nodes.get(far) ?? { id: far, t: "?", missing: true } });
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
frontier = next;
|
|
409
|
+
}
|
|
410
|
+
return { start: startId, found: true, path };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export const ARGV_SPEC = {
|
|
414
|
+
usage: "harness.mjs reduce graph --slug <slug> [--cwd <dir>] [--subgraph run] [--trace <node-id>] [--depth N]",
|
|
415
|
+
_: { arity: 0, max: 0, name: "(no positional operands)" },
|
|
416
|
+
slug: { type: "str", required: true },
|
|
417
|
+
cwd: { type: "path" },
|
|
418
|
+
subgraph: { type: "enum", values: ["run"] },
|
|
419
|
+
trace: { type: "str" },
|
|
420
|
+
depth: { type: "int", min: 1, max: 12, default: 4 },
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Append everything the artifacts imply, then optionally answer a query over the result.
|
|
425
|
+
*
|
|
426
|
+
* @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
|
|
427
|
+
* @returns {void} Exits 0; prints the append report, or the requested query, as JSON.
|
|
428
|
+
*/
|
|
429
|
+
export function cli(rawArgv) {
|
|
430
|
+
const args = runArgs(ARGV_SPEC, rawArgv);
|
|
431
|
+
const cwd = resolve(args.cwd || process.cwd());
|
|
432
|
+
const report = appendGraph(cwd, args.slug);
|
|
433
|
+
if (args.trace) { console.log(JSON.stringify(trace(cwd, args.slug, args.trace, args.depth), null, 2)); process.exit(0); }
|
|
434
|
+
if (args.subgraph) { console.log(JSON.stringify(runSubgraph(cwd, args.slug), null, 2)); process.exit(0); }
|
|
435
|
+
console.log(JSON.stringify(report, null, 2));
|
|
436
|
+
process.exit(0);
|
|
437
|
+
}
|