shapeup-sdlc 3.18.3 → 4.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 +2 -3
- package/.claude-plugin/plugin.json +1 -1
- package/AGENTS.md +115 -56
- package/README.md +101 -143
- package/SECURITY.md +16 -25
- package/agents/orient.md +14 -0
- package/agents/qa-edge-hunter.md +13 -0
- package/agents/spec-evaluator.md +14 -0
- package/agents/task-executor.md +14 -0
- package/bin/init.mjs +13 -16
- package/bin/lib/grant.mjs +8 -19
- package/commands/build.md +9 -14
- package/commands/eval.md +3 -3
- package/commands/hammer.md +9 -5
- package/commands/retro.md +12 -11
- package/commands/scopes.md +1 -12
- package/commands/ship.md +45 -88
- package/commands/wire.md +1 -8
- package/hooks/dispatch-receipt.mjs +94 -8
- package/hooks/hooks.json +0 -41
- package/hooks/lib/decision.mjs +6 -9
- package/hooks/lib/tier.mjs +50 -0
- package/hooks/sandbox-guard.mjs +140 -64
- package/kernel/gate.mjs +203 -22
- package/kernel/harness.mjs +20 -67
- package/kernel/init/run.mjs +175 -523
- package/kernel/lib/contract.mjs +9 -93
- package/kernel/lib/floor.mjs +64 -0
- package/kernel/lib/paths.mjs +39 -112
- package/kernel/order.mjs +157 -0
- package/kernel/probe/digest.mjs +2 -38
- package/kernel/schemas/domain.schema.json +146 -1125
- package/kernel/schemas/work-order.schema.json +8 -4
- package/kernel/schemas/work-result.schema.json +1 -1
- package/kernel/verify/envelope.mjs +2 -2
- package/kernel/verify/skills.mjs +27 -8
- package/kernel/verify/t0.mjs +2 -2
- package/package.json +4 -2
- package/skills/building/SKILL.md +144 -0
- package/skills/building/resources/gates.md +76 -0
- package/skills/building/resources/guidance.md +23 -0
- package/skills/{translator/references/preservation-rules.md → building/resources/intake-preservation.md} +1 -1
- package/skills/building/resources/intake.md +157 -0
- package/skills/building/resources/orders.md +69 -0
- package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/doc-schemas.md +1 -1
- package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/task-generation.md +13 -13
- package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/test-surface.md +6 -7
- package/skills/building/resources/plan-scopes.md +95 -0
- package/skills/building/resources/plan-spec.md +102 -0
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/_index.tmpl.md +1 -1
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/assess-report.tmpl.md +10 -11
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/run-state.tmpl.md +4 -4
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/scope-summary.tmpl.md +2 -2
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/synthesis.tmpl.md +4 -4
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/usecase.tmpl.md +2 -2
- package/skills/building/resources/plan-wire.md +82 -0
- package/skills/building/resources/plan.md +98 -0
- package/skills/building/resources/retro-research.md +60 -0
- package/skills/building/resources/retro-scan.md +49 -0
- package/skills/building/resources/retro.md +225 -0
- package/skills/building/resources/rounds.md +78 -0
- package/skills/building/resources/stop.md +127 -0
- package/skills/orient/SKILL.md +37 -37
- package/skills/qa-edge-hunter/SKILL.md +14 -14
- package/skills/shapeup/SKILL.md +5 -5
- package/skills/shapeup/resources/breadboarding.md +1 -1
- package/skills/shapeup/resources/context-compaction.md +5 -5
- package/skills/shapeup/resources/shaping.md +1 -1
- package/skills/spec-evaluator/SKILL.md +17 -21
- package/skills/spec-evaluator/references/dimension-contract.md +1 -1
- package/skills/spec-evaluator/references/dimensions/completeness.md +3 -3
- package/skills/spec-evaluator/references/dimensions/spec-conformance.md +1 -1
- package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +5 -5
- package/skills/task-executor/SKILL.md +11 -15
- package/commands/hill.md +0 -10
- package/hooks/gate-intake.mjs +0 -109
- package/hooks/gate-zerowork.mjs +0 -349
- package/hooks/tier-guard.mjs +0 -162
- package/kernel/compile.mjs +0 -1419
- package/kernel/init/fit.mjs +0 -199
- package/kernel/init/run-args.mjs +0 -206
- package/kernel/lib/breadboard.mjs +0 -165
- package/kernel/probe/attempts.mjs +0 -161
- package/kernel/probe/concurrency.mjs +0 -535
- package/kernel/probe/eval.mjs +0 -405
- package/kernel/probe/hunt.mjs +0 -104
- package/kernel/probe/leg.mjs +0 -229
- package/kernel/probe/owner.mjs +0 -142
- package/kernel/probe/requirements.mjs +0 -328
- package/kernel/probe/resume.mjs +0 -1110
- package/kernel/probe/rounds.mjs +0 -115
- package/kernel/probe/staged.mjs +0 -83
- package/kernel/probe/stats.mjs +0 -477
- package/kernel/probe/t0.mjs +0 -115
- package/kernel/reduce/board.mjs +0 -350
- package/kernel/reduce/graph.mjs +0 -482
- package/kernel/reduce/hill.mjs +0 -322
- package/kernel/reduce/ingest.mjs +0 -767
- package/kernel/reduce/leftovers.mjs +0 -159
- package/kernel/reduce/ship.mjs +0 -529
- package/kernel/reduce/snapshot.mjs +0 -291
- package/kernel/reduce/verdict.mjs +0 -172
- package/kernel/report/export.mjs +0 -384
- package/kernel/report/facts.mjs +0 -234
- package/kernel/verify/budget.mjs +0 -153
- package/kernel/verify/build.mjs +0 -428
- package/kernel/verify/dispatch.mjs +0 -114
- package/kernel/verify/spec.mjs +0 -975
- package/kernel/verify/trace.mjs +0 -604
- package/skills/ba-pitch-analyzer/SKILL.md +0 -204
- package/skills/coach/SKILL.md +0 -397
- package/skills/hill-chart/SKILL.md +0 -154
- package/skills/hill-chart/assets/dashboard.template.html +0 -1432
- package/skills/scope-architect/SKILL.md +0 -174
- package/skills/scope-hammer/SKILL.md +0 -231
- package/skills/solution-architect/SKILL.md +0 -158
- package/skills/tech-lead/SKILL.md +0 -151
- package/skills/tech-lead/references/gates.md +0 -605
- package/skills/tech-lead/references/protocol.md +0 -889
- package/skills/tech-lead/references/tiny-lane.md +0 -53
- package/skills/tech-lead/workflows/shapeup-run.js +0 -2187
- package/skills/translator/SKILL.md +0 -258
- /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/contract-patterns.md +0 -0
- /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/ddd-patterns.md +0 -0
- /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/integration-analysis.md +0 -0
- /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/ux-behavior-patterns.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/api-feasibility.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/be-service.contract.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/offline-storage.contract.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/third-party-api.contract.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/context-map.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/event-choreography.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/migration-plan.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/team-handoff.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/domain-model.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/feedback.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/integration.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task-board.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task-spike.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/ux-behavior.tmpl.md +0 -0
package/kernel/reduce/graph.mjs
DELETED
|
@@ -1,482 +0,0 @@
|
|
|
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 { readLegs } from "../probe/leg.mjs";
|
|
30
|
-
import { join, dirname, basename, resolve } from "node:path";
|
|
31
|
-
import { runArgs } from "../lib/argv.mjs";
|
|
32
|
-
import {
|
|
33
|
-
localRoot, receipt as receiptPath, ordersDir, resultsDir, verdictsDir, trials as trialsPath, gates as gatesPath, scopesDir, usecasesDir, requirements as requirementsPath, wiringMap as wiringMapPath, legLedger,
|
|
34
|
-
} from "../lib/paths.mjs";
|
|
35
|
-
import { readAllContracts, readContract, ucId, reqId, SCOPE_CONTRACT, WIRING_MAP } from "../lib/contract.mjs";
|
|
36
|
-
import { runIdFromReceipt, readRunId } 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", "Leg", "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", "INGESTED", "EVALUATES", "SUPERSEDES", "COVERS", "DEPENDS_ON", "DERIVED_FROM", "IMPLEMENTS"];
|
|
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
|
-
// LAST LINE WINS, as the banner says — a REPLACE, not a merge. Merging kept attributes from
|
|
67
|
-
// superseded lines alive forever, so an attribute removed from an artifact survived in the
|
|
68
|
-
// projection and a rebuilt graph stopped matching the maintained one.
|
|
69
|
-
if (row.k === "node" && row.id) nodes.set(row.id, row);
|
|
70
|
-
else if (row.k === "edge" && row.from && row.to && row.t) edges.set(`${row.from}|${row.t}|${row.to}`, row);
|
|
71
|
-
}
|
|
72
|
-
return { nodes, edges, lines };
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Read a JSON file without throwing. @param {string} p Path. @returns {*} Parsed value or null. */
|
|
76
|
-
function readJson(p) {
|
|
77
|
-
try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; }
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** Every `## Heading`-anchored id in a requirements or use-case document. */
|
|
81
|
-
function idsIn(text, re) {
|
|
82
|
-
return [...new Set([...String(text || "").matchAll(re)].map((m) => m[1]))];
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Project every artifact on disk for one run into nodes and edges.
|
|
87
|
-
*
|
|
88
|
-
* Pure: reads the tree, returns the graph it implies, writes nothing. That is what lets the same
|
|
89
|
-
* function serve both the incremental append and the backfill of a run recorded before this file
|
|
90
|
-
* existed — there is no second code path to keep in step.
|
|
91
|
-
*
|
|
92
|
-
* @param {string} cwd - Project root.
|
|
93
|
-
* @param {string} slug - Feature slug.
|
|
94
|
-
* @returns {{nodes: object[], edges: object[]}} Everything the artifacts imply, in a stable order.
|
|
95
|
-
*/
|
|
96
|
-
export function project(cwd, slug) {
|
|
97
|
-
const nodes = [], edges = [];
|
|
98
|
-
const node = (id, t, attrs = {}) => nodes.push({ k: "node", id, t, ...attrs });
|
|
99
|
-
const edge = (from, t, to) => edges.push({ k: "edge", from, t, to });
|
|
100
|
-
|
|
101
|
-
const root = localRoot(cwd, slug);
|
|
102
|
-
const receipt = readJson(receiptPath(cwd, slug));
|
|
103
|
-
const runId = runIdFromReceipt(receipt);
|
|
104
|
-
const runNode = runId ? `run:${runId}` : null;
|
|
105
|
-
|
|
106
|
-
// ---- work lineage --------------------------------------------------------------------------
|
|
107
|
-
if (runNode) {
|
|
108
|
-
node(runNode, "Run", {
|
|
109
|
-
run_id: runId, slug,
|
|
110
|
-
started_at: receipt?.started_at ?? null,
|
|
111
|
-
intake_sha256: receipt?.intake_sha256 ?? null,
|
|
112
|
-
auto_level: receipt?.config?.auto_level ?? null,
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const oDir = ordersDir(cwd, slug);
|
|
117
|
-
const rDir = resultsDir(cwd, slug);
|
|
118
|
-
const orderByFile = new Map();
|
|
119
|
-
if (existsSync(oDir)) {
|
|
120
|
-
for (const f of readdirSync(oDir).filter((x) => x.endsWith(".json")).sort()) {
|
|
121
|
-
const o = readJson(join(oDir, f));
|
|
122
|
-
if (!o?.order_id) continue;
|
|
123
|
-
const id = `order:${o.order_id}`;
|
|
124
|
-
orderByFile.set(f, { id, order: o });
|
|
125
|
-
node(id, "Order", {
|
|
126
|
-
order_id: o.order_id, worker: o.worker ?? null, operation: o.operation ?? null,
|
|
127
|
-
round: o.round ?? null, attempt: o.attempt ?? null,
|
|
128
|
-
scope_id: o.payload?.scope_contract?.scope_id ?? o.payload?.scope_id ?? null,
|
|
129
|
-
run_id: o.run_id ?? runId ?? null,
|
|
130
|
-
});
|
|
131
|
-
if (runNode) edge(runNode, "PRODUCED", id);
|
|
132
|
-
const scopeId = o.payload?.scope_contract?.scope_id ?? o.payload?.scope_id ?? null;
|
|
133
|
-
if (scopeId) edge(id, "DERIVED_FROM", `scope:${slug}:${scopeId}`);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
if (existsSync(rDir)) {
|
|
138
|
-
for (const f of readdirSync(rDir).filter((x) => x.endsWith(".json")).sort()) {
|
|
139
|
-
const r = readJson(join(rDir, f));
|
|
140
|
-
if (!r?.order_id) continue;
|
|
141
|
-
const id = `result:${r.order_id}`;
|
|
142
|
-
node(id, "Result", {
|
|
143
|
-
order_id: r.order_id, status: r.status ?? null,
|
|
144
|
-
artifacts: Array.isArray(r.artifacts) ? r.artifacts.length : 0,
|
|
145
|
-
discoveries: Array.isArray(r.discoveries) ? r.discoveries.length : 0,
|
|
146
|
-
});
|
|
147
|
-
edge(`order:${r.order_id}`, "PRODUCED", id);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// Leg-completion rows — the record that separates "the result landed" from "the single writer
|
|
152
|
-
// applied it". It was in neither this graph nor the export, so a run could show five orders
|
|
153
|
-
// producing five results and nobody could see that only three were ever read. A Result with no
|
|
154
|
-
// INGESTED edge leaving it is finished work the board never saw, and `--subgraph run` names it.
|
|
155
|
-
for (const l of readLegs(legLedger(cwd, slug))) {
|
|
156
|
-
if (!l?.order_id) continue;
|
|
157
|
-
const id = `leg:${l.order_id}`;
|
|
158
|
-
node(id, "Leg", {
|
|
159
|
-
order_id: l.order_id, worker: l.worker ?? null, operation: l.operation ?? null,
|
|
160
|
-
scope_id: l.scope_id ?? null, round: l.round ?? null, attempt: l.attempt ?? null,
|
|
161
|
-
dispatched_at: l.dispatched_at ?? null, ingested_at: l.ingested_at ?? null,
|
|
162
|
-
attested: l.attested ?? null, run_id: l.run_id ?? runId ?? null,
|
|
163
|
-
});
|
|
164
|
-
edge(`result:${l.order_id}`, "INGESTED", id);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// T0 verdicts — the artifact the evaluator is required to cite, and the reason the lineage half
|
|
168
|
-
// of this graph is worth having: a verdict node is the anchor of every "show me the evidence".
|
|
169
|
-
const vDir = verdictsDir(cwd, slug);
|
|
170
|
-
const verdictIdsByRound = new Map(); // round -> [verdict node id, ...], for the gate edge below
|
|
171
|
-
if (existsSync(vDir)) {
|
|
172
|
-
for (const f of readdirSync(vDir).filter((x) => x.endsWith(".json")).sort()) {
|
|
173
|
-
const v = readJson(join(vDir, f));
|
|
174
|
-
if (!v) continue;
|
|
175
|
-
const id = `verdict:${slug}:${basename(f, ".json")}`;
|
|
176
|
-
node(id, "Verdict", {
|
|
177
|
-
artifact: f, overall: v.overall ?? null, regression: !!v.regression,
|
|
178
|
-
round: v.round ?? null, attempt: v.attempt ?? null, scope_id: v.scope_id ?? null,
|
|
179
|
-
run_id: v.run_id ?? runId ?? null,
|
|
180
|
-
});
|
|
181
|
-
if (v.scope_id) edge(id, "EVALUATES", `scope:${slug}:${v.scope_id}`);
|
|
182
|
-
if (v.round != null && v.attempt != null && v.scope_id) {
|
|
183
|
-
edge(`order:${slug}/${v.scope_id}-r${v.round}-a${v.attempt}`, "PRODUCED", id);
|
|
184
|
-
}
|
|
185
|
-
if (v.round != null) {
|
|
186
|
-
if (!verdictIdsByRound.has(v.round)) verdictIdsByRound.set(v.round, []);
|
|
187
|
-
verdictIdsByRound.get(v.round).push(id);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Gate crossings — `kernel/gate.mjs` is the sole writer of `gates.jsonl`; this reads the same
|
|
193
|
-
// ledger `verify/t0.mjs` writes for `trials.jsonl`, defensive per-line JSON.parse-or-skip.
|
|
194
|
-
//
|
|
195
|
-
// ID KEYED ON GATE ID + ORDINAL, not gate id alone, for the exact reason the trial key above is
|
|
196
|
-
// not the ordinal alone: L2 and L3 are crossed once per round, so a bare `gate:<slug>:L2` id would
|
|
197
|
-
// collapse every round's crossing onto one node — a repeat of the trial-key bug this file already
|
|
198
|
-
// learned from once. The ordinal counts a gate id's occurrences in ledger order, which also
|
|
199
|
-
// disambiguates round-independent gates (L1a, …) re-crossed across relaunches, where `round` alone
|
|
200
|
-
// is `null` on every row.
|
|
201
|
-
const gPath = gatesPath(cwd, slug);
|
|
202
|
-
if (existsSync(gPath)) {
|
|
203
|
-
const seenByGate = new Map();
|
|
204
|
-
for (const line of readFileSync(gPath, "utf8").split("\n")) {
|
|
205
|
-
if (!line.trim()) continue;
|
|
206
|
-
const g = readJson0(line);
|
|
207
|
-
if (!g?.gate) continue;
|
|
208
|
-
const n = (seenByGate.get(g.gate) || 0) + 1;
|
|
209
|
-
seenByGate.set(g.gate, n);
|
|
210
|
-
const id = `gate:${slug}:${g.gate}:${n}`;
|
|
211
|
-
node(id, "GateDecision", {
|
|
212
|
-
gate: g.gate, decision: g.decision ?? null, status: g.status ?? null,
|
|
213
|
-
source: g.source ?? null, note: g.note ?? null, round: g.round ?? null,
|
|
214
|
-
run_id: g.run_id ?? runId ?? null,
|
|
215
|
-
});
|
|
216
|
-
// EVERY gate depends on the Run, UNCONDITIONALLY — and a round-scoped one ALSO depends on that
|
|
217
|
-
// round's T0 verdict(s), the evidence it was decided against.
|
|
218
|
-
//
|
|
219
|
-
// The Run edge used to be an `else` FALLBACK, and a conditional edge TARGET cannot survive an
|
|
220
|
-
// append-only log. Gates are crossed BEFORE the round's verdict artifact lands, so the first
|
|
221
|
-
// projection minted `DEPENDS_ON run` and a later one added the verdict edges beside it —
|
|
222
|
-
// and `appendGraph` has no tombstone, so the fallback became permanent. The incremental graph
|
|
223
|
-
// then carried an edge a rebuild does not imply, breaking the one property this file exists to
|
|
224
|
-
// promise: that it can be deleted and rebuilt identically. Unconditional is also truer — a gate
|
|
225
|
-
// does depend on its run. The fix is not a guard; it is removing the condition.
|
|
226
|
-
if (runNode) edge(id, "DEPENDS_ON", runNode);
|
|
227
|
-
const roundVerdicts = (g.gate === "L2" || g.gate === "L3") ? verdictIdsByRound.get(g.round) : null;
|
|
228
|
-
for (const vid of roundVerdicts || []) edge(id, "DEPENDS_ON", vid);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// Trials — the ratchet's own history, with a real parent link already in the row.
|
|
233
|
-
const tPath = trialsPath(cwd, slug);
|
|
234
|
-
if (existsSync(tPath)) {
|
|
235
|
-
for (const line of readFileSync(tPath, "utf8").split("\n")) {
|
|
236
|
-
if (!line.trim()) continue;
|
|
237
|
-
const t = readJson0(line);
|
|
238
|
-
if (!t?.trial) continue;
|
|
239
|
-
// THE SCOPE IS PART OF THE KEY, because the ordinal alone is not unique. A trial ordinal
|
|
240
|
-
// counts within its scope, so `trial:<slug>:1` names one row per scope and every one of them
|
|
241
|
-
// collapsed onto a single node — the Map this graph is read back into keeps the last writer,
|
|
242
|
-
// so a scope's whole execution record vanished silently. Measured: four scopes' trials
|
|
243
|
-
// projected to two nodes. `--trace` is supposed to reach "the execution record"; it reached
|
|
244
|
-
// whichever scope happened to be written last. `baseline_trial` is chosen from the same
|
|
245
|
-
// scope's prior rows, so the SUPERSEDES edge resolves inside the same partition.
|
|
246
|
-
//
|
|
247
|
-
// AND THE RUN IS PART OF THE KEY TOO, for the same reason one step out. A trial ordinal
|
|
248
|
-
// restarts at 1 in the next run while `trials.jsonl` is APPEND-ONLY, so both runs' rows live
|
|
249
|
-
// on disk together and `trial:<slug>:<scope>:1` named two of them — the second run silently
|
|
250
|
-
// overwrote the first run's execution record, which is the identical defect the paragraph
|
|
251
|
-
// above records fixing once already, one key component short. `baseline_trial` is chosen from
|
|
252
|
-
// the same run's rows, so SUPERSEDES still resolves inside the partition.
|
|
253
|
-
const trialRun = t.run_id ?? runId ?? "norun";
|
|
254
|
-
const trialKey = (n) => `trial:${slug}:${trialRun}:${t.scope_id ? `${t.scope_id}:` : ""}${n}`;
|
|
255
|
-
const id = trialKey(t.trial);
|
|
256
|
-
node(id, "Trial", {
|
|
257
|
-
trial: t.trial, round: t.round ?? null, attempt: t.attempt ?? null,
|
|
258
|
-
scope_id: t.scope_id ?? null, status: t.status ?? null, artifact: t.artifact ?? null,
|
|
259
|
-
sha256: t.sha256 ?? null, run_id: t.run_id ?? runId ?? null,
|
|
260
|
-
});
|
|
261
|
-
if (t.baseline_trial) edge(id, "SUPERSEDES", trialKey(t.baseline_trial));
|
|
262
|
-
if (t.artifact) edge(id, "EVALUATES", `verdict:${slug}:${basename(String(t.artifact), ".json")}`);
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// ---- domain --------------------------------------------------------------------------------
|
|
267
|
-
const sDir = scopesDir(cwd, slug);
|
|
268
|
-
if (existsSync(sDir)) {
|
|
269
|
-
for (const { contract } of readAllContracts(sDir, SCOPE_CONTRACT)) {
|
|
270
|
-
if (!contract?.scope_id) continue;
|
|
271
|
-
const id = `scope:${slug}:${contract.scope_id}`;
|
|
272
|
-
node(id, "Scope", {
|
|
273
|
-
scope_id: contract.scope_id, title: contract.title ?? null,
|
|
274
|
-
substrate: (contract.allowed_file_substrate || []).length,
|
|
275
|
-
});
|
|
276
|
-
// The domain family's entry edges. Before scope contracts anchored into the committed spec
|
|
277
|
-
// these could not be drawn: `covers` had this reader and no writer, and there was no
|
|
278
|
-
// Scope→UseCase relation at all, so a Scope sat in the graph as an isolated node beside the
|
|
279
|
-
// UseCase nodes it was built from and `--trace` stopped there instead of reaching the
|
|
280
|
-
// objective. The contract now names both, so both are projectable.
|
|
281
|
-
for (const uc of contract.use_cases || []) edge(id, "IMPLEMENTS", `uc:${slug}:${ucId(uc)}`);
|
|
282
|
-
// Normalised to the registry's key space on the way in, the same mapping spec-lint applies:
|
|
283
|
-
// a contract citing the pitch's `R<n>` and a Requirement node keyed `REQ-<n>` are one node,
|
|
284
|
-
// and an edge drawn to the other spelling is an edge to a node the graph does not hold.
|
|
285
|
-
for (const req of contract.covers || []) edge(id, "COVERS", `req:${slug}:${reqId(req)}`);
|
|
286
|
-
for (const dep of contract.depends_on || []) edge(id, "DEPENDS_ON", `scope:${slug}:${dep}`);
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
const uDir = usecasesDir(cwd, slug);
|
|
291
|
-
if (existsSync(uDir)) {
|
|
292
|
-
for (const f of readdirSync(uDir).filter((x) => x.endsWith(".md") && x !== "_index.md").sort()) {
|
|
293
|
-
const ucId = basename(f, ".md");
|
|
294
|
-
node(`uc:${slug}:${ucId}`, "UseCase", { use_case: ucId, file: f });
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
const reqPath = requirementsPath(cwd, slug);
|
|
299
|
-
if (existsSync(reqPath)) {
|
|
300
|
-
for (const r of idsIn(readFileSync(reqPath, "utf8"), /^\s*[-*|]?\s*\**\s*(REQ-[A-Z0-9-]+)/gm)) {
|
|
301
|
-
node(`req:${slug}:${r}`, "Requirement", { req_id: r });
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// The wiring map is literally a graph written as a table: per use case, engine → seam →
|
|
306
|
-
// entry-point call site → affordance. Reading it back as edges is the whole reason it is a table.
|
|
307
|
-
const wPath = wiringMapPath(cwd, slug);
|
|
308
|
-
if (existsSync(wPath)) {
|
|
309
|
-
// READ THE FIELDS THE SPEC ACTUALLY PRODUCES. This loop asked for `contract.wiring` while
|
|
310
|
-
// WIRING_MAP names the field `entries`, and for `row.seam`/`row.entry_point` while WiringEntry
|
|
311
|
-
// declares `wiring_seam`/`entry_call_site` — three name mismatches stacked on a parser that was
|
|
312
|
-
// returning nothing anyway. Every one of them independently yields an empty domain half, and
|
|
313
|
-
// none of them can fail loudly: an absent field and an absent wiring map look identical here.
|
|
314
|
-
// The legacy spellings stay as fallbacks so a map written either way still projects.
|
|
315
|
-
const found = readContract(wPath, WIRING_MAP);
|
|
316
|
-
for (const row of found?.contract?.entries || found?.contract?.wiring || found?.contract?.rows || []) {
|
|
317
|
-
const uc = row.use_case || row.uc;
|
|
318
|
-
const seam = row.wiring_seam || row.seam;
|
|
319
|
-
const entryPoint = row.entry_call_site ?? row.entry_point ?? null;
|
|
320
|
-
if (!uc || !seam) continue;
|
|
321
|
-
const seamId = `seam:${slug}:${seam}`;
|
|
322
|
-
node(seamId, "Seam", { seam, engine: row.engine ?? null, entry_point: entryPoint });
|
|
323
|
-
edge(`uc:${slug}:${ucId(uc)}`, "DEPENDS_ON", seamId);
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
return { nodes, edges };
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/** JSON.parse for one line, without throwing. @param {string} l Line. @returns {*} Parsed or null. */
|
|
331
|
-
function readJson0(l) {
|
|
332
|
-
try { return JSON.parse(l); } catch { return null; }
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/**
|
|
336
|
-
* Append everything the artifacts imply that the graph does not already carry.
|
|
337
|
-
*
|
|
338
|
-
* @param {string} cwd - Project root.
|
|
339
|
-
* @param {string} slug - Feature slug.
|
|
340
|
-
* @returns {{path: string, nodes_added: number, edges_added: number, nodes_total: number, edges_total: number, backfilled: boolean}}
|
|
341
|
-
* `backfilled` is true when the graph did not exist before this call — the migration case, which
|
|
342
|
-
* is the same code path as the steady-state one.
|
|
343
|
-
*/
|
|
344
|
-
export function appendGraph(cwd, slug) {
|
|
345
|
-
const p = graphPath(cwd, slug);
|
|
346
|
-
const before = readGraph(cwd, slug);
|
|
347
|
-
const backfilled = before.lines === 0;
|
|
348
|
-
const { nodes, edges } = project(cwd, slug);
|
|
349
|
-
|
|
350
|
-
const out = [];
|
|
351
|
-
for (const n of nodes) {
|
|
352
|
-
const prev = before.nodes.get(n.id);
|
|
353
|
-
// Append only when something actually changed — the file is a log, and a log that repeats
|
|
354
|
-
// itself on every run stops being readable long before it stops being correct.
|
|
355
|
-
if (!prev || JSON.stringify({ ...prev, k: undefined }) !== JSON.stringify({ ...n, k: undefined })) out.push(n);
|
|
356
|
-
}
|
|
357
|
-
for (const e of edges) {
|
|
358
|
-
if (!before.edges.has(`${e.from}|${e.t}|${e.to}`)) out.push(e);
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
if (out.length) {
|
|
362
|
-
mkdirSync(dirname(p), { recursive: true });
|
|
363
|
-
appendFileSync(p, out.map((r) => JSON.stringify(r)).join("\n") + "\n");
|
|
364
|
-
}
|
|
365
|
-
const after = readGraph(cwd, slug);
|
|
366
|
-
return {
|
|
367
|
-
path: p,
|
|
368
|
-
nodes_added: out.filter((r) => r.k === "node").length,
|
|
369
|
-
edges_added: out.filter((r) => r.k === "edge").length,
|
|
370
|
-
nodes_total: after.nodes.size,
|
|
371
|
-
edges_total: after.edges.size,
|
|
372
|
-
backfilled,
|
|
373
|
-
};
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
/**
|
|
377
|
-
* The bounded subgraph a relaunch needs — the fast-forward as a query rather than a directory walk.
|
|
378
|
-
*
|
|
379
|
-
* @param {string} cwd - Project root.
|
|
380
|
-
* @param {string} slug - Feature slug.
|
|
381
|
-
* @returns {object} Counts and ids only. Deliberately small: the point of the graph is that the
|
|
382
|
-
* orchestrator's working set is a subgraph, not a re-read of all state.
|
|
383
|
-
*/
|
|
384
|
-
export function runSubgraph(cwd, slug) {
|
|
385
|
-
const { nodes, edges, lines } = readGraph(cwd, slug);
|
|
386
|
-
const of = (t) => [...nodes.values()].filter((n) => n.t === t);
|
|
387
|
-
// ONE RUN'S SUBGRAPH. The graph is append-only over a slug and every run of it lands there; this
|
|
388
|
-
// query used to aggregate every run's verdicts into `green_scopes_by_round` and report the FIRST
|
|
389
|
-
// run ever recorded as `run`, so a relaunch skipped scopes a prior run had built. Work nodes carry
|
|
390
|
-
// the run key and are filtered on it; a node with no key predates the key and is kept; a Result
|
|
391
|
-
// has no key of its own and belongs to the run its Order does.
|
|
392
|
-
const runId = readRunId(cwd, slug);
|
|
393
|
-
const mine = (n) => !runId || !n.run_id || n.run_id === runId;
|
|
394
|
-
const orders = of("Order").filter(mine), verdicts = of("Verdict").filter(mine);
|
|
395
|
-
const orderIds = new Set(orders.map((o) => o.order_id));
|
|
396
|
-
const results = of("Result").filter((r) => !runId || orderIds.has(r.order_id));
|
|
397
|
-
const resultIds = new Set(results.map((r) => r.order_id));
|
|
398
|
-
const ingestedFrom = new Set([...edges.values()].filter((e) => e.t === "INGESTED").map((e) => e.from));
|
|
399
|
-
const greenByRound = {};
|
|
400
|
-
for (const v of verdicts) {
|
|
401
|
-
if (v.overall !== "green" || v.round == null || !v.scope_id) continue;
|
|
402
|
-
(greenByRound[v.round] ||= new Set()).add(v.scope_id);
|
|
403
|
-
}
|
|
404
|
-
return {
|
|
405
|
-
graph_lines: lines,
|
|
406
|
-
run: runId ?? of("Run")[0]?.run_id ?? null,
|
|
407
|
-
scopes: of("Scope").map((s) => s.scope_id).sort(),
|
|
408
|
-
use_cases: of("UseCase").map((u) => u.use_case).sort(),
|
|
409
|
-
requirements: of("Requirement").map((r) => r.req_id).sort(),
|
|
410
|
-
seams: of("Seam").map((s) => s.seam).sort(),
|
|
411
|
-
orders: orders.length,
|
|
412
|
-
pending_orders: orders.filter((o) => !resultIds.has(o.order_id)).map((o) => o.order_id).sort(),
|
|
413
|
-
// Results the single writer never applied — a leg that came back and was not read.
|
|
414
|
-
unapplied_results: results.filter((r) => !ingestedFrom.has(`result:${r.order_id}`)).map((r) => r.order_id).sort(),
|
|
415
|
-
rounds_with_green: Object.keys(greenByRound).map(Number).sort((a, b) => a - b),
|
|
416
|
-
green_scopes_by_round: Object.fromEntries(Object.entries(greenByRound).map(([r, s]) => [r, [...s].sort()])),
|
|
417
|
-
trials: of("Trial").length,
|
|
418
|
-
edges: edges.size,
|
|
419
|
-
};
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
/**
|
|
423
|
-
* Walk backwards from one node, following every edge that points at it or away from it.
|
|
424
|
-
*
|
|
425
|
-
* This is the reliability invariant made executable: pick a verdict, walk to the order that
|
|
426
|
-
* produced it, the scope it evaluates, the requirements that scope covers, and the trials that
|
|
427
|
-
* superseded it — in one query, over one file, instead of a grep across four.
|
|
428
|
-
*
|
|
429
|
-
* @param {string} cwd - Project root.
|
|
430
|
-
* @param {string} slug - Feature slug.
|
|
431
|
-
* @param {string} startId - The node to trace from.
|
|
432
|
-
* @param {number} [depth=4] - How many hops to follow.
|
|
433
|
-
* @returns {{start: string, found: boolean, path: object[]}} Each hop as `{from, t, to, node}`. A
|
|
434
|
-
* hop whose far node carries `missing: true` is a dangling edge — the artifact it names is not on
|
|
435
|
-
* disk. That is reported rather than dropped: an edge to nothing is a fact about the run.
|
|
436
|
-
*/
|
|
437
|
-
export function trace(cwd, slug, startId, depth = 4) {
|
|
438
|
-
const { nodes, edges } = readGraph(cwd, slug);
|
|
439
|
-
if (!nodes.has(startId)) return { start: startId, found: false, path: [] };
|
|
440
|
-
const seen = new Set([startId]);
|
|
441
|
-
let frontier = [startId];
|
|
442
|
-
const path = [];
|
|
443
|
-
for (let d = 0; d < depth && frontier.length; d++) {
|
|
444
|
-
const next = [];
|
|
445
|
-
for (const e of edges.values()) {
|
|
446
|
-
for (const [near, far] of [[e.from, e.to], [e.to, e.from]]) {
|
|
447
|
-
if (!frontier.includes(near) || seen.has(far)) continue;
|
|
448
|
-
seen.add(far);
|
|
449
|
-
next.push(far);
|
|
450
|
-
path.push({ from: e.from, t: e.t, to: e.to, node: nodes.get(far) ?? { id: far, t: "?", missing: true } });
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
frontier = next;
|
|
454
|
-
}
|
|
455
|
-
return { start: startId, found: true, path };
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
export const ARGV_SPEC = {
|
|
459
|
-
usage: "harness.mjs reduce graph --slug <slug> [--cwd <dir>] [--subgraph run] [--trace <node-id>] [--depth N]",
|
|
460
|
-
_: { arity: 0, max: 0, name: "(no positional operands)" },
|
|
461
|
-
slug: { type: "str", required: true },
|
|
462
|
-
cwd: { type: "path" },
|
|
463
|
-
subgraph: { type: "enum", values: ["run"] },
|
|
464
|
-
trace: { type: "str" },
|
|
465
|
-
depth: { type: "int", min: 1, max: 12, default: 4 },
|
|
466
|
-
};
|
|
467
|
-
|
|
468
|
-
/**
|
|
469
|
-
* Append everything the artifacts imply, then optionally answer a query over the result.
|
|
470
|
-
*
|
|
471
|
-
* @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
|
|
472
|
-
* @returns {void} Exits 0; prints the append report, or the requested query, as JSON.
|
|
473
|
-
*/
|
|
474
|
-
export function cli(rawArgv) {
|
|
475
|
-
const args = runArgs(ARGV_SPEC, rawArgv);
|
|
476
|
-
const cwd = resolve(args.cwd || process.cwd());
|
|
477
|
-
const report = appendGraph(cwd, args.slug);
|
|
478
|
-
if (args.trace) { console.log(JSON.stringify(trace(cwd, args.slug, args.trace, args.depth), null, 2)); process.exit(0); }
|
|
479
|
-
if (args.subgraph) { console.log(JSON.stringify(runSubgraph(cwd, args.slug), null, 2)); process.exit(0); }
|
|
480
|
-
console.log(JSON.stringify(report, null, 2));
|
|
481
|
-
process.exit(0);
|
|
482
|
-
}
|