shapeup-sdlc 3.7.6 → 3.7.8
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-plugin/plugin.json +1 -1
- package/kernel/compile.mjs +45 -9
- package/kernel/gate.mjs +5 -1
- package/kernel/init/run.mjs +24 -0
- package/kernel/probe/attempts.mjs +11 -2
- package/kernel/probe/eval.mjs +53 -5
- package/kernel/probe/rounds.mjs +16 -5
- package/kernel/probe/t0.mjs +6 -1
- package/kernel/reduce/graph.mjs +12 -3
- package/kernel/reduce/hill.mjs +12 -3
- package/kernel/reduce/ingest.mjs +3 -2
- package/kernel/reduce/ship.mjs +7 -1
- package/kernel/report/export.mjs +7 -3
- package/kernel/verify/build.mjs +7 -1
- package/kernel/verify/trace.mjs +5 -2
- package/package.json +1 -1
- package/skills/coach/SKILL.md +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shapeup-sdlc-plugin",
|
|
3
3
|
"displayName": "ShapeUp SDLC Plugin",
|
|
4
|
-
"version": "3.7.
|
|
4
|
+
"version": "3.7.8",
|
|
5
5
|
"description": "Shape Up SDLC harness for Claude Code: shaping, intake, orient, scope-mapping, building (T0-verified, sandboxed, scope-contracted), evaluation and QA skills orchestrated by a tech-lead.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Liberty Nguyen",
|
package/kernel/compile.mjs
CHANGED
|
@@ -26,19 +26,20 @@
|
|
|
26
26
|
// pretty-printed envelope, colocated so audits can read it). Prints the path on stdout.
|
|
27
27
|
|
|
28
28
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from "node:fs";
|
|
29
|
+
import { discover, resolve as resolveGate, appendGateLedger, PRESETS } from "./gate.mjs";
|
|
29
30
|
import { resolve, join, dirname, basename, relative, sep } from "node:path";
|
|
30
31
|
import { fileURLToPath } from "node:url";
|
|
31
32
|
import { validate } from "./verify/envelope.mjs";
|
|
32
33
|
import { readTrials } from "./verify/t0.mjs";
|
|
33
34
|
import { runArgs } from "./lib/argv.mjs";
|
|
34
|
-
import { readRunId, dispatchReceipts, legLedger } from "./lib/paths.mjs";
|
|
35
|
+
import { readRunId, dispatchReceipts, legLedger, readReceipt, receipt } from "./lib/paths.mjs";
|
|
35
36
|
// `specDir` is aliased: this module has a local `let specDir` holding the resolved, possibly
|
|
36
37
|
// --spec-overridden directory, and the import is the convention-derived default.
|
|
37
38
|
import {
|
|
38
39
|
tasksDir, specDir as defaultSpecDir, roundLedger, trials, verdictsDir, ordersDir,
|
|
39
40
|
relShared, relLocal, globLocal, globShared, relKnowledgeBase, resultsDir, scopesDir,
|
|
40
41
|
} from "./lib/paths.mjs";
|
|
41
|
-
import { readContract, readAllContracts, tasksForScope, SCOPE_CONTRACT } from "./lib/contract.mjs";
|
|
42
|
+
import { readContract, readAllContracts, tasksForScope, SCOPE_CONTRACT, reqId } from "./lib/contract.mjs";
|
|
42
43
|
import { writeActiveOrder } from "./probe/resume.mjs";
|
|
43
44
|
import { greenVerdict } from "./probe/t0.mjs";
|
|
44
45
|
import { attemptEvidence, readReceipts } from "./probe/attempts.mjs";
|
|
@@ -100,14 +101,27 @@ export function parseTaskFile(path) {
|
|
|
100
101
|
const body = readFileSync(path, "utf8");
|
|
101
102
|
const fm = frontmatter(body);
|
|
102
103
|
const acceptance_criteria = [];
|
|
103
|
-
|
|
104
|
-
|
|
104
|
+
// A `(covers: …)` clause is read across the WHOLE bullet — the checkbox line and the indented
|
|
105
|
+
// continuation lines under it — and each token folds through the one key helper. The clause was
|
|
106
|
+
// scanned on the checkbox line alone, so a board whose every AC carried one on its continuation
|
|
107
|
+
// line projected as a board carrying none: the requirements matrix printed "no evidence" for
|
|
108
|
+
// clauses that had a PASS criterion anchored to them. Measured live. `text` stays byte-identical
|
|
109
|
+
// to the checkbox line, because ingest ticks the box by matching it back.
|
|
110
|
+
const lines = body.split(/\r?\n/);
|
|
111
|
+
for (let i = 0; i < lines.length; i++) {
|
|
112
|
+
const m = lines[i].match(/^\s*- \[[ x]\]\s+(.*)$/);
|
|
105
113
|
if (!m) continue;
|
|
106
|
-
const text = m[1].trim();
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
114
|
+
const text = m[1].trim();
|
|
115
|
+
let block = text;
|
|
116
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
117
|
+
const l = lines[j];
|
|
118
|
+
if (!l.trim() || /^\s*[-*+]\s/.test(l) || /^#/.test(l) || !/^\s/.test(l)) break;
|
|
119
|
+
block += "\n" + l;
|
|
120
|
+
}
|
|
121
|
+
const covers = [];
|
|
122
|
+
for (const cov of block.matchAll(/\(covers:\s*([^)]*)\)/gi)) {
|
|
123
|
+
for (const raw of cov[1].split(",")) { const id = reqId(raw); if (/^REQ-\d+$/.test(id) && !covers.includes(id)) covers.push(id); }
|
|
124
|
+
}
|
|
111
125
|
acceptance_criteria.push(covers.length ? { text, covers } : text);
|
|
112
126
|
}
|
|
113
127
|
return {
|
|
@@ -929,6 +943,28 @@ export async function cli(rawArgv) {
|
|
|
929
943
|
|| (operation ? OP_OWNER[operation] : null);
|
|
930
944
|
if (!worker || !operation) { console.error("compile-order: could not resolve --worker/--operation"); process.exit(2); }
|
|
931
945
|
|
|
946
|
+
// GATE COACH-1 HAS A DETERMINISTIC CALL SITE: THE COACH DISPATCH. The gate asks whether a PO is
|
|
947
|
+
// present to categorize feedback; its answer used to be a prose instruction inside the coach
|
|
948
|
+
// skill, so an unattended lane could dispatch a coach nobody would answer and no row recorded the
|
|
949
|
+
// decision. Compiling the coach order resolves it with the run's own answer set and records the
|
|
950
|
+
// row; `skip` refuses the order, which is what "no live PO" means. `ask` compiles it — the
|
|
951
|
+
// categorization conversation the coach then holds IS the answer.
|
|
952
|
+
if (operation === "coach") {
|
|
953
|
+
const ga = readReceipt(receipt(cwd, slug))?.config?.gate_answers ?? null;
|
|
954
|
+
const presetName = ga && PRESETS[ga] ? ga : null;
|
|
955
|
+
let found = discover({ cwd, slug, preset: presetName, file: ga && !presetName ? ga : null });
|
|
956
|
+
if (found.error) found = { set: PRESETS.interactive, source: "preset:interactive (no answer set on disk)" };
|
|
957
|
+
const r = resolveGate(found.set, "COACH-1", found.source);
|
|
958
|
+
appendGateLedger(cwd, slug, {
|
|
959
|
+
at: new Date().toISOString(), run_id: readRunId(cwd, slug), gate: "COACH-1", status: r.status,
|
|
960
|
+
decision: r.decision ?? null, source: r.source ?? found.source, note: r.note ?? r.reason ?? null, round: null,
|
|
961
|
+
});
|
|
962
|
+
if (r.decision === "skip") {
|
|
963
|
+
console.error(`compile-order: GATE COACH-1 resolved "skip" (${r.source ?? found.source}) — no live PO to categorize feedback, so no coach order is compiled. The decision is on the gate ledger.`);
|
|
964
|
+
process.exit(3);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
932
968
|
// Task selection.
|
|
933
969
|
let tasks;
|
|
934
970
|
const board = readBoard(cwd, slug);
|
package/kernel/gate.mjs
CHANGED
|
@@ -54,7 +54,7 @@ import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync } fr
|
|
|
54
54
|
import { parseBoard } from "./reduce/board.mjs";
|
|
55
55
|
import { join, dirname } from "node:path";
|
|
56
56
|
import { runArgs } from "./lib/argv.mjs";
|
|
57
|
-
import { gateAnswerCandidates, gates as gatesPath, LOCAL, resultsDir, tasksDir, hammerCensus } from "./lib/paths.mjs";
|
|
57
|
+
import { gateAnswerCandidates, gates as gatesPath, LOCAL, resultsDir, tasksDir, hammerCensus, readRunId } from "./lib/paths.mjs";
|
|
58
58
|
|
|
59
59
|
export const GATE_IDS = ["L0", "L1a", "L1a.5", "L1b", "L2", "L3", "QA", "H", "L4", "COACH-1"];
|
|
60
60
|
|
|
@@ -465,7 +465,11 @@ export function cli(rawArgv) {
|
|
|
465
465
|
// per-run ledger row — the same reasoning `resolveRunId` uses for "no run is active": absence is
|
|
466
466
|
// the correct answer, not an error, so the write is skipped rather than guessing a location.
|
|
467
467
|
if (args.slug) {
|
|
468
|
+
// THE ROW CARRIES THE RUN KEY. It did not, and the export stamped the current run's key onto
|
|
469
|
+
// every row it found — a prior run's sign-off became this run's in the one table that answers
|
|
470
|
+
// "was this ship signed off". Driven on a two-run fixture before it was fixed.
|
|
468
471
|
appendGateLedger(cwd, args.slug, {
|
|
472
|
+
at: new Date().toISOString(), run_id: readRunId(cwd, args.slug),
|
|
469
473
|
gate: r.gate, status: r.status, decision: r.decision ?? null,
|
|
470
474
|
source: r.source ?? found.source, note: r.note ?? r.reason ?? null,
|
|
471
475
|
round: args.round ?? null,
|
package/kernel/init/run.mjs
CHANGED
|
@@ -70,6 +70,7 @@
|
|
|
70
70
|
// one moment it mattered named a mechanism that does not parse.
|
|
71
71
|
|
|
72
72
|
import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, copyFileSync, rmSync, statSync } from "node:fs";
|
|
73
|
+
import { discover, resolve as resolveGate, appendGateLedger, PRESETS } from "../gate.mjs";
|
|
73
74
|
import { join, dirname, resolve, relative, sep } from "node:path";
|
|
74
75
|
import { createHash } from "node:crypto";
|
|
75
76
|
import { decideLane, treeSize } from "./fit.mjs";
|
|
@@ -671,6 +672,29 @@ export function cli(rawArgv) {
|
|
|
671
672
|
mkdirSync(dirname(pointer), { recursive: true });
|
|
672
673
|
writeFileSync(pointer, JSON.stringify({ slug, started_at: startedAt }, null, 2) + "\n", "utf8");
|
|
673
674
|
|
|
675
|
+
// GATE L0 HAS A DETERMINISTIC CALL SITE: THE RUN'S OPENING. It used to be a line of prose the
|
|
676
|
+
// tech lead was asked to act on, and the same consumer ledgered L0 on one run and not the next.
|
|
677
|
+
// The intake conversation is the L0 decision; opening the run is the act that records it, with
|
|
678
|
+
// the answer set the run was configured with (a preset name or a file), else the interactive
|
|
679
|
+
// defaults. Best-effort: a row that cannot be written must not fail the opening.
|
|
680
|
+
try {
|
|
681
|
+
const ga = config.gate_answers ?? null;
|
|
682
|
+
const presetName = ga && PRESETS[ga] ? ga : null;
|
|
683
|
+
const found = discover({ cwd, slug, preset: presetName, file: ga && !presetName ? ga : null });
|
|
684
|
+
// A preset or file answers L0 for the run; a run with neither — the interactive lane, where the
|
|
685
|
+
// tech lead held the intake conversation before opening it — has that conversation as its L0
|
|
686
|
+
// decision, and the opening records it as such. An answer set that says `ask` is recorded as
|
|
687
|
+
// `ask`: the row states what the set said, and the intake note says what happened.
|
|
688
|
+
const r = found.error
|
|
689
|
+
? { status: "ok", decision: "proceed", source: "intake (no answer set on disk)", note: "the intake conversation is the L0 decision" }
|
|
690
|
+
: resolveGate(found.set, "L0", found.source);
|
|
691
|
+
appendGateLedger(cwd, slug, {
|
|
692
|
+
at: startedAt, run_id: receipt.run_id, gate: "L0", status: r.status, decision: r.decision ?? null,
|
|
693
|
+
source: r.source ?? found.source, note: `${r.note ?? r.reason ?? ""} — run opened: intake recorded, receipt written`.replace(/^ — /, ""),
|
|
694
|
+
round: null,
|
|
695
|
+
});
|
|
696
|
+
} catch { /* the ledger row is a record of the opening, never a condition of it */ }
|
|
697
|
+
|
|
674
698
|
console.log(JSON.stringify({
|
|
675
699
|
ok: true,
|
|
676
700
|
slug,
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
31
31
|
import { join, resolve } from "node:path";
|
|
32
32
|
import { runArgs } from "../lib/argv.mjs";
|
|
33
|
-
import { dispatchReceipts, legLedger, resultsDir, readRunId } from "../lib/paths.mjs";
|
|
33
|
+
import { dispatchReceipts, legLedger, resultsDir, readRunId, ordersDir } from "../lib/paths.mjs";
|
|
34
34
|
import { readLegs } from "./leg.mjs";
|
|
35
35
|
import { greenVerdict } from "./t0.mjs";
|
|
36
36
|
|
|
@@ -84,7 +84,16 @@ export function attemptEvidence(cwd, slug, scopeId, round, attempt, receipts, le
|
|
|
84
84
|
// so this stays a file check and is deliberately NOT sufficient on its own. It can only turn an
|
|
85
85
|
// already run-scoped receipt into `spent`; a result left behind by an earlier run cannot attest
|
|
86
86
|
// an attempt this run never dispatched.
|
|
87
|
-
|
|
87
|
+
// A WorkResult carries no run key of its own; it answers THIS run's attempt only through the
|
|
88
|
+
// order of the same name, which this run's compile rewrote. A result left by a prior run over the
|
|
89
|
+
// same slug used to close an attempt this run had not even opened.
|
|
90
|
+
const stem = `${scopeId}-r${round}-a${attempt}.json`;
|
|
91
|
+
let orderIsMine = true;
|
|
92
|
+
if (runId != null) {
|
|
93
|
+
try { const o = JSON.parse(readFileSync(join(ordersDir(cwd, slug), stem), "utf8")); orderIsMine = !o?.run_id || o.run_id === runId; }
|
|
94
|
+
catch { orderIsMine = false; }
|
|
95
|
+
}
|
|
96
|
+
const hasResult = orderIsMine && existsSync(join(resultsDir(cwd, slug), stem));
|
|
88
97
|
const state = !hasReceipt ? "unattested" : (hasResult || hasLeg) ? "spent" : "in-flight";
|
|
89
98
|
return { orderId, hasReceipt, hasResult, hasLeg, state };
|
|
90
99
|
}
|
package/kernel/probe/eval.mjs
CHANGED
|
@@ -35,7 +35,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
|
35
35
|
import { join, resolve } from "node:path";
|
|
36
36
|
import { createHash } from "node:crypto";
|
|
37
37
|
import { runArgs } from "../lib/argv.mjs";
|
|
38
|
-
import { resultsDir, scopesDir } from "../lib/paths.mjs";
|
|
38
|
+
import { resultsDir, scopesDir, readRunId } from "../lib/paths.mjs";
|
|
39
39
|
|
|
40
40
|
/** Longest `reason` reported. A deviation is prose written by a worker and can run to paragraphs. */
|
|
41
41
|
const REASON_MAX = 400;
|
|
@@ -89,7 +89,7 @@ export function isScoped(cwd, slug) {
|
|
|
89
89
|
* @returns {(string|null)} A reason phrased for an operator, or null when the file at `path` exists,
|
|
90
90
|
* hashes to the cited `sha256`, and its own `overall` reads "green".
|
|
91
91
|
*/
|
|
92
|
-
function unresolvedCitation(cwd, citation) {
|
|
92
|
+
function unresolvedCitation(cwd, citation, { round = null, runId = null } = {}) {
|
|
93
93
|
const rel = typeof citation?.path === "string" ? citation.path : "";
|
|
94
94
|
if (!rel) return "names no artifact path";
|
|
95
95
|
let text;
|
|
@@ -109,6 +109,20 @@ function unresolvedCitation(cwd, citation) {
|
|
|
109
109
|
try { body = JSON.parse(text); }
|
|
110
110
|
catch { return `cites ${rel}, whose bytes match the hash but do not read as a T0 verdict`; }
|
|
111
111
|
if (body?.overall !== "green") return `cites ${rel}, whose own verdict is "${body?.overall ?? "unknown"}", not green`;
|
|
112
|
+
// THE ARTIFACT HAS TO BE THE ONE THE CITATION SAYS IT IS. A re-hash proves the bytes are the
|
|
113
|
+
// file's; it says nothing about whose verdict the file holds. A PASS citing scope alpha's green
|
|
114
|
+
// artifact while declaring scope beta, or a prior round's, or a prior run's over the same slug,
|
|
115
|
+
// passed the digest check unremarked. The citation's own required `scope_id`, the round being
|
|
116
|
+
// judged and the run's key are compared to what the artifact records about itself.
|
|
117
|
+
if (typeof citation.scope_id === "string" && body?.scope_id && body.scope_id !== citation.scope_id) {
|
|
118
|
+
return `cites ${rel} for scope "${citation.scope_id}", but the artifact records scope "${body.scope_id}"`;
|
|
119
|
+
}
|
|
120
|
+
if (round != null && typeof body?.round === "number" && body.round !== round) {
|
|
121
|
+
return `cites ${rel}, a round ${body.round} artifact, as evidence for round ${round}`;
|
|
122
|
+
}
|
|
123
|
+
if (runId && body?.run_id && body.run_id !== runId) {
|
|
124
|
+
return `cites ${rel}, an artifact of run ${body.run_id}, as evidence for run ${runId}`;
|
|
125
|
+
}
|
|
112
126
|
return null;
|
|
113
127
|
}
|
|
114
128
|
|
|
@@ -143,15 +157,49 @@ function unresolvedCitation(cwd, citation) {
|
|
|
143
157
|
* citation resolves, an unscoped spec, or a block with no PASS/FAIL in it (there is no judgement
|
|
144
158
|
* to invalidate).
|
|
145
159
|
*/
|
|
146
|
-
|
|
160
|
+
/**
|
|
161
|
+
* Why a verdict cannot stand on its own criteria, or null when it can.
|
|
162
|
+
*
|
|
163
|
+
* `overall` is the judge's field, and nothing recomputed it from the criteria the judge graded: a
|
|
164
|
+
* PASS over a failing criterion, or over no criterion at all, validated and ingested, and the
|
|
165
|
+
* round loop branched on it. The evaluator's own first rule is that absence of evidence is a FAIL,
|
|
166
|
+
* so a PASS criterion with no evidence is no evidence either. Recomputed here, on ingest and on
|
|
167
|
+
* read alike: PASS means every graded criterion passed with evidence and at least one was graded;
|
|
168
|
+
* FAIL means at least one graded criterion failed.
|
|
169
|
+
*
|
|
170
|
+
* @param {object} verdict - The WorkResult's `verdict`.
|
|
171
|
+
* @returns {(string|null)} A reason phrased for an operator, or null.
|
|
172
|
+
*/
|
|
173
|
+
export function verdictProblem(verdict) {
|
|
174
|
+
const overall = verdict?.overall;
|
|
175
|
+
if (overall !== "PASS" && overall !== "FAIL") return null;
|
|
176
|
+
const criteria = Array.isArray(verdict.criteria) ? verdict.criteria : [];
|
|
177
|
+
const fails = criteria.filter((c) => c?.verdict === "FAIL");
|
|
178
|
+
const passes = criteria.filter((c) => c?.verdict === "PASS");
|
|
179
|
+
const other = criteria.length - fails.length - passes.length;
|
|
180
|
+
if (overall === "PASS") {
|
|
181
|
+
if (criteria.length === 0) return "the PASS verdict grades no criterion at all — a PASS with no evidence is a claim";
|
|
182
|
+
if (fails.length) return `the verdict says PASS while ${fails.length} of its ${criteria.length} criteria read FAIL — overall is derived from the criteria, never declared over them`;
|
|
183
|
+
if (other) return `the verdict says PASS while ${other} of its criteria carry no PASS/FAIL verdict`;
|
|
184
|
+
const bare = passes.filter((c) => !(typeof c?.evidence === "string" && c.evidence.trim()));
|
|
185
|
+
if (bare.length) return `the PASS verdict has ${bare.length} criterion(s) marked PASS with no evidence — absence of evidence is a FAIL by the evaluator's own first rule`;
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
if (criteria.length === 0) return "the FAIL verdict grades no criterion at all — a FAIL must name what failed";
|
|
189
|
+
if (!fails.length) return `the verdict says FAIL while every one of its ${criteria.length} graded criteria reads PASS — a FAIL must cite the criterion it failed`;
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function citationProblem(cwd, slug, verdict, { round = null } = {}) {
|
|
147
194
|
if (verdict?.overall !== "PASS" && verdict?.overall !== "FAIL") return null;
|
|
148
195
|
if (!isScoped(cwd, slug)) return null;
|
|
149
196
|
if (!Array.isArray(verdict.t0_citations) || !verdict.t0_citations.length) {
|
|
150
197
|
return `the ${verdict.overall} verdict cites no T0 artifact, and a verdict on a scoped spec must ` +
|
|
151
198
|
"cite the T0 verdict it re-hashed (the order lists them under payload.t0_artifacts)";
|
|
152
199
|
}
|
|
200
|
+
const runId = readRunId(cwd, slug);
|
|
153
201
|
for (const citation of verdict.t0_citations) {
|
|
154
|
-
const reason = unresolvedCitation(cwd, citation);
|
|
202
|
+
const reason = unresolvedCitation(cwd, citation, { round, runId });
|
|
155
203
|
if (reason) return `the ${verdict.overall} verdict ${reason} — a T0 citation is re-hashed from disk, never taken on the handed word`;
|
|
156
204
|
}
|
|
157
205
|
return null;
|
|
@@ -185,7 +233,7 @@ export function evalVerdict(cwd, slug, round) {
|
|
|
185
233
|
? `the evaluator returned ${status || "no status"}: ${first}`
|
|
186
234
|
: `status ${status || "unknown"} with no PASS/FAIL verdict`), status);
|
|
187
235
|
}
|
|
188
|
-
const problem = citationProblem(cwd, slug, v);
|
|
236
|
+
const problem = verdictProblem(v) || citationProblem(cwd, slug, v, { round });
|
|
189
237
|
if (problem) return unfit(problem, status, overall);
|
|
190
238
|
return {
|
|
191
239
|
found: true,
|
package/kernel/probe/rounds.mjs
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
24
24
|
import { join } from "node:path";
|
|
25
|
-
import { ordersDir, verdictsDir, roundBuildDir, resultsDir } from "../lib/paths.mjs";
|
|
25
|
+
import { ordersDir, verdictsDir, roundBuildDir, resultsDir, readRunId } from "../lib/paths.mjs";
|
|
26
26
|
|
|
27
27
|
/** Parse a JSON file, returning null rather than throwing — every reader here is best-effort. */
|
|
28
28
|
function readJson(p) {
|
|
@@ -59,12 +59,22 @@ const maxOf = (nums) => (nums.length ? Math.max(...nums) : null);
|
|
|
59
59
|
* @returns {{rounds_used:*, rounds_judged:(number|null)}} Both counts.
|
|
60
60
|
*/
|
|
61
61
|
export function deriveRounds(cwd, slug, fallback) {
|
|
62
|
+
// THIS RUN'S RECORDS ONLY. Orders, verdicts and build gates over one slug accumulate across runs
|
|
63
|
+
// and carry their run key; walking the directories unfiltered handed a run that had dispatched
|
|
64
|
+
// nothing a prior run's round count — into the committed report. A record with no key at all was
|
|
65
|
+
// written before the key existed and is kept; one with a different key is another run's.
|
|
66
|
+
const runId = readRunId(cwd, slug);
|
|
67
|
+
const mine = (rec) => !runId || !rec?.run_id || rec.run_id === runId;
|
|
62
68
|
const orderRounds = [];
|
|
63
69
|
const oDir = ordersDir(cwd, slug);
|
|
70
|
+
const orderOf = {};
|
|
64
71
|
if (existsSync(oDir)) {
|
|
65
72
|
for (const f of readdirSync(oDir)) {
|
|
66
73
|
if (!f.endsWith(".json")) continue;
|
|
67
|
-
const
|
|
74
|
+
const o = readJson(join(oDir, f));
|
|
75
|
+
orderOf[f] = o;
|
|
76
|
+
if (!mine(o)) continue;
|
|
77
|
+
const r = orderRound(o?.order_id);
|
|
68
78
|
if (r !== null) orderRounds.push(r);
|
|
69
79
|
}
|
|
70
80
|
}
|
|
@@ -74,7 +84,7 @@ export function deriveRounds(cwd, slug, fallback) {
|
|
|
74
84
|
if (existsSync(vDir)) {
|
|
75
85
|
for (const f of readdirSync(vDir).filter((x) => x.endsWith(".json"))) {
|
|
76
86
|
const v = readJson(join(vDir, f));
|
|
77
|
-
if (typeof v?.round === "number") verdictRounds.push(v.round);
|
|
87
|
+
if (mine(v) && typeof v?.round === "number") verdictRounds.push(v.round);
|
|
78
88
|
}
|
|
79
89
|
}
|
|
80
90
|
|
|
@@ -83,7 +93,7 @@ export function deriveRounds(cwd, slug, fallback) {
|
|
|
83
93
|
if (existsSync(bDir)) {
|
|
84
94
|
for (const f of readdirSync(bDir)) {
|
|
85
95
|
const m = f.match(/^r(\d+)-t\d+\.json$/);
|
|
86
|
-
if (m) buildGateRounds.push(Number(m[1]));
|
|
96
|
+
if (m && mine(readJson(join(bDir, f)))) buildGateRounds.push(Number(m[1]));
|
|
87
97
|
}
|
|
88
98
|
}
|
|
89
99
|
|
|
@@ -92,7 +102,8 @@ export function deriveRounds(cwd, slug, fallback) {
|
|
|
92
102
|
if (existsSync(rDir)) {
|
|
93
103
|
for (const f of readdirSync(rDir)) {
|
|
94
104
|
const m = f.match(/^evaluate-r(\d+)\.json$/);
|
|
95
|
-
|
|
105
|
+
// A WorkResult carries no run key; it reaches one through its order of the same name.
|
|
106
|
+
if (m && mine(orderOf[f] ?? readJson(join(oDir, f)))) evalRounds.push(Number(m[1]));
|
|
96
107
|
}
|
|
97
108
|
}
|
|
98
109
|
|
package/kernel/probe/t0.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
17
17
|
import { join, resolve } from "node:path";
|
|
18
18
|
import { runArgs } from "../lib/argv.mjs";
|
|
19
|
-
import { verdictsDir } from "../lib/paths.mjs";
|
|
19
|
+
import { verdictsDir, readRunId } from "../lib/paths.mjs";
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Verdict filenames, newest first by their NUMERIC address.
|
|
@@ -55,10 +55,15 @@ export function greenVerdict(cwd, slug, scopeId, round) {
|
|
|
55
55
|
if (!existsSync(dir)) return { green: false, path: null };
|
|
56
56
|
// Newest first: an attempt retried after a red one writes a higher trial ordinal at the same
|
|
57
57
|
// (round, attempt) address, and the LAST verdict is the one that stands.
|
|
58
|
+
// THIS RUN'S VERDICTS. Verdicts over one slug accumulate across runs and carry the run's key; a
|
|
59
|
+
// second run used to be told its scope was green on the first run's artifact, and the round loop
|
|
60
|
+
// skipped building it. A verdict with no key at all predates the key and is kept.
|
|
61
|
+
const runId = readRunId(cwd, slug);
|
|
58
62
|
for (const f of newestFirst(readdirSync(dir).filter((x) => x.endsWith(".json")))) {
|
|
59
63
|
const p = join(dir, f);
|
|
60
64
|
try {
|
|
61
65
|
const b = JSON.parse(readFileSync(p, "utf8"));
|
|
66
|
+
if (runId && b.run_id && b.run_id !== runId) continue;
|
|
62
67
|
if (b.scope_id === scopeId && (round == null || b.round === round) && b.overall === "green") return { green: true, path: p };
|
|
63
68
|
} catch { /* a torn artifact proves nothing; keep looking */ }
|
|
64
69
|
}
|
package/kernel/reduce/graph.mjs
CHANGED
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
localRoot, receipt as receiptPath, ordersDir, resultsDir, verdictsDir, trials as trialsPath, gates as gatesPath, scopesDir, usecasesDir, requirements as requirementsPath, wiringMap as wiringMapPath, legLedger,
|
|
34
34
|
} from "../lib/paths.mjs";
|
|
35
35
|
import { readAllContracts, readContract, ucId, reqId, SCOPE_CONTRACT, WIRING_MAP } from "../lib/contract.mjs";
|
|
36
|
-
import { runIdFromReceipt } from "../lib/paths.mjs";
|
|
36
|
+
import { runIdFromReceipt, readRunId } from "../lib/paths.mjs";
|
|
37
37
|
|
|
38
38
|
/** The graph's home — one file per feature, beside the run trace it projects. */
|
|
39
39
|
export const graphPath = (cwd, slug) => join(localRoot(cwd, slug), "graph.jsonl");
|
|
@@ -384,7 +384,16 @@ export function appendGraph(cwd, slug) {
|
|
|
384
384
|
export function runSubgraph(cwd, slug) {
|
|
385
385
|
const { nodes, edges, lines } = readGraph(cwd, slug);
|
|
386
386
|
const of = (t) => [...nodes.values()].filter((n) => n.t === t);
|
|
387
|
-
|
|
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));
|
|
388
397
|
const resultIds = new Set(results.map((r) => r.order_id));
|
|
389
398
|
const ingestedFrom = new Set([...edges.values()].filter((e) => e.t === "INGESTED").map((e) => e.from));
|
|
390
399
|
const greenByRound = {};
|
|
@@ -394,7 +403,7 @@ export function runSubgraph(cwd, slug) {
|
|
|
394
403
|
}
|
|
395
404
|
return {
|
|
396
405
|
graph_lines: lines,
|
|
397
|
-
run: of("Run")[0]?.run_id ?? null,
|
|
406
|
+
run: runId ?? of("Run")[0]?.run_id ?? null,
|
|
398
407
|
scopes: of("Scope").map((s) => s.scope_id).sort(),
|
|
399
408
|
use_cases: of("UseCase").map((u) => u.use_case).sort(),
|
|
400
409
|
requirements: of("Requirement").map((r) => r.req_id).sort(),
|
package/kernel/reduce/hill.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from "node:fs";
|
|
7
7
|
import { resolve, join } from "node:path";
|
|
8
8
|
import { runArgs } from "../lib/argv.mjs";
|
|
9
|
-
import { scopesDir, hillDir, verdictsDir, resultsDir, discoveryLedger,
|
|
9
|
+
import { scopesDir, hillDir, verdictsDir, resultsDir, discoveryLedger, receipt, readRunId } from "../lib/paths.mjs";
|
|
10
10
|
import { readAllContracts, SCOPE_CONTRACT } from "../lib/contract.mjs";
|
|
11
11
|
import { evalVerdict } from "../probe/eval.mjs";
|
|
12
12
|
import { redBuildRounds } from "../verify/build.mjs";
|
|
@@ -190,9 +190,15 @@ export function deriveHill(cwd, slug) {
|
|
|
190
190
|
// and reports what they currently support, in both directions. A guard phrased as "never lower a
|
|
191
191
|
// phase" would quietly turn a derived value into a high-water mark, which is a different defect
|
|
192
192
|
// wearing this one's clothes. The condition is the narrowest one that is positively provable:
|
|
193
|
-
// the
|
|
193
|
+
// the RUN is not there — its receipt, the record every run's first act writes. The condition used
|
|
194
|
+
// to be the local root's existence, and any single file satisfies that: `reduce graph` creates
|
|
195
|
+
// `graph.jsonl` under it as a side effect, so a committed-only checkout that ran graph and then
|
|
196
|
+
// hill had its FINISHED shards flattened to UPHILL_UNKNOWN, exit 0, no warning. A backstop whose
|
|
197
|
+
// condition another command satisfies is a backstop only in the order nobody varied.
|
|
194
198
|
// -------------------------------------------------------------------------------------------
|
|
195
|
-
|
|
199
|
+
// Evidence the derivation actually needs: the run's receipt, or the T0 verdicts it reads. A
|
|
200
|
+
// `graph.jsonl` alone is neither.
|
|
201
|
+
if (!existsSync(receipt(cwd, slug)) && !existsSync(verdictsDir(cwd, slug))) {
|
|
196
202
|
return scopes.map((s) => ({
|
|
197
203
|
scope_id: s.scope_id,
|
|
198
204
|
phase: committedPhase(hDir, s.scope_id),
|
|
@@ -235,11 +241,14 @@ export function deriveHill(cwd, slug) {
|
|
|
235
241
|
// verdict counting exactly as before.
|
|
236
242
|
const redRounds = redBuildRounds(cwd, slug);
|
|
237
243
|
const t0Facts = {};
|
|
244
|
+
// This run's verdicts only — a prior run's green over the same slug moved this run's dot.
|
|
245
|
+
const hillRunId = readRunId(cwd, slug);
|
|
238
246
|
if (existsSync(vDir)) {
|
|
239
247
|
for (const f of readdirSync(vDir)) {
|
|
240
248
|
if (!f.endsWith(".json")) continue;
|
|
241
249
|
try {
|
|
242
250
|
const b = JSON.parse(readFileSync(join(vDir, f), "utf8"));
|
|
251
|
+
if (hillRunId && b.run_id && b.run_id !== hillRunId) continue;
|
|
243
252
|
if (!t0Facts[b.scope_id]) t0Facts[b.scope_id] = { hasGreen: false, seesawGreen: false };
|
|
244
253
|
if (b.overall === "green" && !redRounds.has(Number(b.round))) {
|
|
245
254
|
t0Facts[b.scope_id].hasGreen = true;
|
package/kernel/reduce/ingest.mjs
CHANGED
|
@@ -37,7 +37,7 @@ import { fileURLToPath } from "node:url";
|
|
|
37
37
|
import { validate } from "../verify/envelope.mjs";
|
|
38
38
|
import { runArgs } from "../lib/argv.mjs";
|
|
39
39
|
import { tasksDir, localRoot, dispatchReceipts, legLedger, readRunId } from "../lib/paths.mjs";
|
|
40
|
-
import { citationProblem } from "../probe/eval.mjs";
|
|
40
|
+
import { citationProblem, verdictProblem } from "../probe/eval.mjs";
|
|
41
41
|
|
|
42
42
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
43
43
|
const RESULT_SCHEMA = JSON.parse(readFileSync(resolve(HERE, "../schemas/work-result.schema.json"), "utf8"));
|
|
@@ -697,7 +697,8 @@ export async function cli(rawArgv) {
|
|
|
697
697
|
// on (see `citationProblem`). `probe eval` refuses it to the round loop; refusing it here as well
|
|
698
698
|
// keeps the verdict ledger from recording a verdict the loop will never branch on.
|
|
699
699
|
if (result.verdict) {
|
|
700
|
-
const
|
|
700
|
+
const evalRound = Number((String(result.order_id).match(/-r(\d+)$/) || [])[1]) || null;
|
|
701
|
+
const problem = verdictProblem(result.verdict) || citationProblem(cwd, String(result.order_id).split("/")[0], result.verdict, { round: evalRound });
|
|
701
702
|
if (problem) {
|
|
702
703
|
console.error(`ingest-result: result refused — ${problem}.`);
|
|
703
704
|
console.error(` The round stays open: re-dispatch the evaluator against its order, which lists`);
|
package/kernel/reduce/ship.mjs
CHANGED
|
@@ -329,7 +329,13 @@ export function buildReport(facts) {
|
|
|
329
329
|
"*Run state (board, orders, results, T0 artifacts, evaluation and QA reports) stays in the",
|
|
330
330
|
"gitignored local tier (ADR-0001). This report",
|
|
331
331
|
"is the frozen conclusion of it.*", "");
|
|
332
|
-
|
|
332
|
+
// THE WHOLE REPORT, ONCE. Board ids were anchored in three places and leaked through a fourth: a
|
|
333
|
+
// discovery-ledger entry copied verbatim into "Discovered, not built" carried `[TASK-005]`, this
|
|
334
|
+
// kernel wrote it straight to the committed tier past the hook that guards the model's edits, and
|
|
335
|
+
// the next run's L1b lint red'd the file the previous run had frozen. A committed report may not
|
|
336
|
+
// name a board id anywhere, so the rule is applied to the finished text rather than section by
|
|
337
|
+
// section.
|
|
338
|
+
return deboard(L.join("\n"), board.anchors);
|
|
333
339
|
}
|
|
334
340
|
|
|
335
341
|
/**
|
package/kernel/report/export.mjs
CHANGED
|
@@ -173,12 +173,14 @@ function criterionRows(dir, runId, t) {
|
|
|
173
173
|
* (see `appendGateLedger`), so this is a pass-through with a stamped `run_id` fallback rather than
|
|
174
174
|
* a re-derivation: two readers of "what did this gate decide" must not compute the answer twice.
|
|
175
175
|
* @param {object} g - One parsed line of `gates.jsonl`.
|
|
176
|
-
* @param {(string|null)} runId -
|
|
176
|
+
* @param {(string|null)} runId - The run being exported (unused for attribution — the row's own key is the only one exported).
|
|
177
177
|
* @returns {object} A flat `gate_decision` row.
|
|
178
178
|
*/
|
|
179
179
|
function gateDecisionRow(g, runId) {
|
|
180
180
|
return {
|
|
181
|
-
|
|
181
|
+
// The row's own key, never the current run's stamped on: a row that carries no key was written
|
|
182
|
+
// before the ledger did, and is exported as unattributed rather than claimed.
|
|
183
|
+
run_id: g?.run_id ?? null,
|
|
182
184
|
gate: g?.gate ?? null,
|
|
183
185
|
decision: g?.decision ?? null,
|
|
184
186
|
status: g?.status ?? null,
|
|
@@ -260,7 +262,9 @@ export function collectRun(cwd, slug) {
|
|
|
260
262
|
criterion_verdict: criterionRows(evaluationDir(cwd, slug), runId, t),
|
|
261
263
|
hook_decision,
|
|
262
264
|
// The decision that crossed each gate, and the round build gate's own artifact.
|
|
263
|
-
|
|
265
|
+
// Scoped to the run, like hook_decision one line up: gate rows over one slug accumulate across
|
|
266
|
+
// runs, and a prior run's L4 exported under this run's key is a fabricated sign-off.
|
|
267
|
+
gate_decision: readJsonl(gatesPath(cwd, slug), t).filter((g) => runId && g?.run_id === runId).map((g) => gateDecisionRow(g, runId)),
|
|
264
268
|
build_gate: readJsonDir(roundBuildDir(cwd, slug), t).map((a) => buildGateRow(a, runId)),
|
|
265
269
|
leg: readJsonl(legLedger(cwd, slug), t)
|
|
266
270
|
.filter((r) => !runId || !r?.run_id || r.run_id === runId)
|
package/kernel/verify/build.mjs
CHANGED
|
@@ -42,7 +42,7 @@ import { join, resolve } from "node:path";
|
|
|
42
42
|
import { spawnSync } from "node:child_process";
|
|
43
43
|
import { createHash } from "node:crypto";
|
|
44
44
|
import { runArgs, isMain } from "../lib/argv.mjs";
|
|
45
|
-
import { harnessRun, projectProfile, roundBuildDir, localRoot, runIdFromRoot } from "../lib/paths.mjs";
|
|
45
|
+
import { harnessRun, projectProfile, roundBuildDir, localRoot, runIdFromRoot, readRunId } from "../lib/paths.mjs";
|
|
46
46
|
import { readContract, readAllContracts, PROJECT_PROFILE, SCOPE_CONTRACT, splitFrontmatter } from "../lib/contract.mjs";
|
|
47
47
|
import { scopesDir } from "../lib/paths.mjs";
|
|
48
48
|
import { digest } from "../probe/digest.mjs";
|
|
@@ -231,9 +231,15 @@ export function redBuildRounds(cwd, slug) {
|
|
|
231
231
|
const latest = new Map();
|
|
232
232
|
let files;
|
|
233
233
|
try { files = readdirSync(roundBuildDir(cwd, slug)); } catch { return new Set(); }
|
|
234
|
+
// This run's gates only: a prior run's red round over the same slug must not hold this run's dot.
|
|
235
|
+
const runId = readRunId(cwd, slug);
|
|
234
236
|
for (const f of files) {
|
|
235
237
|
const m = f.match(/^r(\d+)-t(\d+)\.json$/);
|
|
236
238
|
if (!m) continue;
|
|
239
|
+
if (runId) {
|
|
240
|
+
try { const g = JSON.parse(readFileSync(join(roundBuildDir(cwd, slug), f), "utf8")); if (g?.run_id && g.run_id !== runId) continue; }
|
|
241
|
+
catch { continue; }
|
|
242
|
+
}
|
|
237
243
|
const round = Number(m[1]), trial = Number(m[2]);
|
|
238
244
|
if (!latest.has(round) || latest.get(round).trial < trial) latest.set(round, { trial, file: f });
|
|
239
245
|
}
|
package/kernel/verify/trace.mjs
CHANGED
|
@@ -40,7 +40,7 @@ import { resolve, join, dirname, relative, isAbsolute } from "node:path";
|
|
|
40
40
|
import { readBoard } from "../compile.mjs";
|
|
41
41
|
import { runArgs } from "../lib/argv.mjs";
|
|
42
42
|
import { sharedRoot, traceDir, relLocal } from "../lib/paths.mjs";
|
|
43
|
-
import { readContract, unreadableReason, LEGACY_LAYOUT, WIRING_MAP, PROJECT_PROFILE } from "../lib/contract.mjs";
|
|
43
|
+
import { readContract, unreadableReason, LEGACY_LAYOUT, WIRING_MAP, PROJECT_PROFILE, reqId } from "../lib/contract.mjs";
|
|
44
44
|
|
|
45
45
|
// --- requirements.md registry parser -----------------------------------------
|
|
46
46
|
// A committed markdown table: | REQ-id | clause (verbatim) | source | status | note |
|
|
@@ -87,7 +87,10 @@ export function coveredReqIds(board) {
|
|
|
87
87
|
for (const task of board) {
|
|
88
88
|
for (const ac of task.acceptance_criteria || []) {
|
|
89
89
|
const covers = typeof ac === "object" && Array.isArray(ac.covers) ? ac.covers : [];
|
|
90
|
-
|
|
90
|
+
// ONE KEY SPACE. `R-2`, `[[REQ-5]]` and `req-4` are the same clause spelled three ways, and the
|
|
91
|
+
// sibling rule accepts all of them; testing the raw string here counted every one as nothing, so
|
|
92
|
+
// an author told at L1b to cover a requirement with an AC — which they had — stayed red.
|
|
93
|
+
for (const raw of covers) { const id = reqId(raw); if (/^REQ-\d+$/.test(id)) covered.add(id); }
|
|
91
94
|
}
|
|
92
95
|
}
|
|
93
96
|
return covered;
|
package/package.json
CHANGED
package/skills/coach/SKILL.md
CHANGED
|
@@ -117,7 +117,7 @@ fields nobody used" → "Prefer the minimum DTO that satisfies the AC; don't add
|
|
|
117
117
|
fields"). Keep the originating why — a rule without its reason gets ignored or misapplied.
|
|
118
118
|
|
|
119
119
|
### Step 2 — ⏸ GATE COACH-1: Categorize (ASK, never assume)
|
|
120
|
-
This is the load-bearing gate. **
|
|
120
|
+
This is the load-bearing gate. Dispatched with an order, it is already resolved and on the gate ledger: compiling a coach order crosses COACH-1 with the run's answer set and refuses the order on `skip`, so an order in your hands means `ask`. Invoked standalone, **resolve it first** — `node
|
|
121
121
|
"${CLAUDE_PLUGIN_ROOT}/kernel/harness.mjs" gate --resolve COACH-1 --slug <slug>
|
|
122
122
|
[--file <path>|--preset <name>]` — so the ledger carries a row for the decision this gate makes,
|
|
123
123
|
same as every other gate in the run. Exit 0 (`decision=skip`) — an unattended lane with no live PO;
|