shapeup-sdlc 3.7.7 → 3.7.9
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/README.md +6 -4
- package/kernel/compile.mjs +45 -9
- package/kernel/init/run.mjs +24 -0
- package/kernel/probe/attempts.mjs +11 -2
- package/kernel/probe/resume.mjs +24 -3
- package/kernel/probe/staged.mjs +83 -0
- package/kernel/probe/t0.mjs +6 -1
- package/kernel/reduce/graph.mjs +12 -3
- package/kernel/reduce/hill.mjs +12 -3
- package/kernel/report/export.mjs +5 -0
- package/kernel/schemas/domain.schema.json +26 -0
- package/kernel/verify/build.mjs +7 -1
- package/kernel/verify/env.mjs +180 -0
- package/kernel/verify/t0.mjs +10 -1
- package/package.json +1 -1
- package/skills/coach/SKILL.md +1 -1
- package/skills/tech-lead/workflows/shapeup-run.js +18 -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.9",
|
|
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/README.md
CHANGED
|
@@ -48,10 +48,12 @@ Two limits, stated here because the point of this section is that a claim withou
|
|
|
48
48
|
behind it is the thing this harness exists to prevent: the **seesaw** regression arm is declared
|
|
49
49
|
and not yet wired (no run writes its registry — wiring it is an open Betting Table decision), and
|
|
50
50
|
the citation **re-hash** the kernel performs proves self-consistency, not provenance — the digest
|
|
51
|
-
and the cited verdict are checked, the scope, round and run the artifact belongs to are not.
|
|
52
|
-
|
|
53
|
-
about the
|
|
54
|
-
|
|
51
|
+
and the cited verdict are checked, the scope, round and run the artifact belongs to are not. Both are
|
|
52
|
+
open items in `shapeup/knowledge-base/harness-defects.md`, not shipped guarantees. A T0 artifact is
|
|
53
|
+
also evidence about the machine that produced it, and now says so: each verdict carries where it
|
|
54
|
+
ran — the absolute path, the git tree, the resolved toolchain, lockfile digests, declared cache
|
|
55
|
+
directories and a digest over an allowlist of environment values — so a disagreeing re-run can be
|
|
56
|
+
told from a regression. That block measures and judges nothing.
|
|
55
57
|
→ *Prevents: "done" asserted with nothing behind it.*
|
|
56
58
|
|
|
57
59
|
**3. Parallel work can't corrupt shared state.** Each scope gets a write-whitelist of files
|
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/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/resume.mjs
CHANGED
|
@@ -65,6 +65,7 @@ import { parseBoard } from "../reduce/board.mjs";
|
|
|
65
65
|
import { intake, harnessRun, wiringMap, projectProfile, scopesDir, resultsDir, ordersDir, orientDir, activeOrder, activeScope, usecasesDir, breadboard, receipt, readReceipt, requirements, exportRunDir, lastRun, readRunId, tasksDir, gates, verdictsDir, roundBuildDir } from "../lib/paths.mjs";
|
|
66
66
|
import { evalVerdict } from "./eval.mjs";
|
|
67
67
|
import { deriveRounds } from "./rounds.mjs";
|
|
68
|
+
import { stagedWorkflowDrift, driftWarning } from "./staged.mjs";
|
|
68
69
|
import { collectRun, writeRun } from "../report/export.mjs";
|
|
69
70
|
|
|
70
71
|
/** The run-state values `references/protocol.md` (Part 4 — State) defines. A typo'd status is a rejection,
|
|
@@ -445,9 +446,11 @@ export function nextPhase(f) {
|
|
|
445
446
|
*
|
|
446
447
|
* @param {string} cwd - Project root.
|
|
447
448
|
* @param {string} slug - Feature slug.
|
|
449
|
+
* @param {object} [opts] - `pluginRoot`, when the caller knows where the plugin it is running from
|
|
450
|
+
* lives: the state then also reports whether the staged orchestrator is the installed one.
|
|
448
451
|
* @returns {object} The ResumeState record (domain.schema.json $defs/ResumeState).
|
|
449
452
|
*/
|
|
450
|
-
export function deriveResumeState(cwd, slug) {
|
|
453
|
+
export function deriveResumeState(cwd, slug, { pluginRoot = null } = {}) {
|
|
451
454
|
const hrPath = harnessRun(cwd, slug);
|
|
452
455
|
const hr = existsSync(hrPath) ? parseFrontmatter(readFileSync(hrPath, "utf8")) : {};
|
|
453
456
|
|
|
@@ -517,7 +520,22 @@ export function deriveResumeState(cwd, slug) {
|
|
|
517
520
|
.map((f) => Number(f.match(/\d+/)[0]))
|
|
518
521
|
.filter((n) => evalVerdict(cwd, slug, n).found),
|
|
519
522
|
};
|
|
520
|
-
|
|
523
|
+
// WHICH ORCHESTRATOR THIS LAUNCH WILL RUN. A run keeps the workflow copy it opened with — right,
|
|
524
|
+
// and invisible: a relaunch after an upgrade executes the old one and reports normally, so every
|
|
525
|
+
// observation is of the previous release. Reported, never enforced (see probe/staged.mjs).
|
|
526
|
+
const staged = stagedWorkflowDrift(cwd, pluginRoot, slug);
|
|
527
|
+
const warning = driftWarning(staged);
|
|
528
|
+
return {
|
|
529
|
+
...facts,
|
|
530
|
+
staged_workflow: {
|
|
531
|
+
checked: staged.checked,
|
|
532
|
+
drift: staged.drift.map((d) => d.file),
|
|
533
|
+
installed_version: staged.installed_version,
|
|
534
|
+
run_version: staged.run_version,
|
|
535
|
+
...(warning ? { warning } : {}),
|
|
536
|
+
},
|
|
537
|
+
next_phase: nextPhase(facts),
|
|
538
|
+
};
|
|
521
539
|
}
|
|
522
540
|
|
|
523
541
|
/**
|
|
@@ -933,6 +951,9 @@ export const ARGV_SPEC = {
|
|
|
933
951
|
// Not `--close`: the caller (shapeup-run.js's closeIfTerminal) hands over a RunReturn arm, never
|
|
934
952
|
// a status it decided was terminal itself — RUN_RETURN_CLOSE/closeArm above make that call.
|
|
935
953
|
"close-arm": { type: "str" },
|
|
954
|
+
// The launch hands its own plugin root so the state probe can say whether the orchestrator about
|
|
955
|
+
// to run is the installed one. Optional: a caller that does not know it gets `checked: false`.
|
|
956
|
+
"plugin-root": { type: "path" },
|
|
936
957
|
cause: { type: "str" },
|
|
937
958
|
};
|
|
938
959
|
|
|
@@ -1001,7 +1022,7 @@ export function cli(rawArgv) {
|
|
|
1001
1022
|
process.exit(r.ok ? 0 : 3);
|
|
1002
1023
|
}
|
|
1003
1024
|
|
|
1004
|
-
console.log(JSON.stringify(deriveResumeState(cwd, args.slug)));
|
|
1025
|
+
console.log(JSON.stringify(deriveResumeState(cwd, args.slug, { pluginRoot: args.pluginRoot ?? null })));
|
|
1005
1026
|
process.exit(0);
|
|
1006
1027
|
}
|
|
1007
1028
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// staged — is the orchestrator this launch will execute the one that is installed?
|
|
2
|
+
//
|
|
3
|
+
// A run stages its own copy of the workflow scripts into the local tier when it is OPENED, and
|
|
4
|
+
// keeps that copy for the run's life. That is deliberate and right: an upgrade must not swap the
|
|
5
|
+
// orchestrator under a run in flight. The consequence is not obvious from anywhere a person about
|
|
6
|
+
// to soak an upgrade would look — relaunching an existing run after installing a new version
|
|
7
|
+
// executes the OLD orchestrator, reports normally, closes normally, and every observation made of
|
|
8
|
+
// it is an observation of the previous release. That is worse than a failed soak: it is confident
|
|
9
|
+
// evidence about the wrong artifact.
|
|
10
|
+
//
|
|
11
|
+
// So the launch compares, and says so. A warning, never a block — the run keeping its copy is the
|
|
12
|
+
// correct behaviour, and the operator is the one who decides whether this run is the one they
|
|
13
|
+
// meant to measure.
|
|
14
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { workflowsStage, receipt, readReceipt } from "../lib/paths.mjs";
|
|
18
|
+
|
|
19
|
+
const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
|
|
20
|
+
|
|
21
|
+
/** A file's digest, or null when it cannot be read. */
|
|
22
|
+
function digestOf(path) {
|
|
23
|
+
try { return sha256(readFileSync(path)); } catch { return null; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The version the plugin at `pluginRoot` declares, or null. */
|
|
27
|
+
export function installedPluginVersion(pluginRoot) {
|
|
28
|
+
if (!pluginRoot) return null;
|
|
29
|
+
try { return JSON.parse(readFileSync(join(pluginRoot, ".claude-plugin", "plugin.json"), "utf8")).version ?? null; }
|
|
30
|
+
catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How the orchestrator this launch will run compares with the one installed.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} cwd - Project root.
|
|
37
|
+
* @param {(string|null)} pluginRoot - The installed plugin's root, as the launch knows it.
|
|
38
|
+
* @param {(string|null)} [slug] - The run, when the caller knows it — for the version the run opened under.
|
|
39
|
+
* @returns {object} `{checked, drift, staged_dir, plugin_root, installed_version, run_version}`.
|
|
40
|
+
* `drift` lists each script whose staged copy differs from the installed one; `checked: false`
|
|
41
|
+
* means the comparison could not be made (no plugin root, or nothing staged), which is not the
|
|
42
|
+
* same fact as "they agree" and is reported as itself.
|
|
43
|
+
*/
|
|
44
|
+
export function stagedWorkflowDrift(cwd, pluginRoot, slug = null) {
|
|
45
|
+
const stagedDir = workflowsStage(cwd);
|
|
46
|
+
const srcDir = pluginRoot ? join(pluginRoot, "skills", "tech-lead", "workflows") : null;
|
|
47
|
+
const out = {
|
|
48
|
+
checked: false,
|
|
49
|
+
drift: [],
|
|
50
|
+
staged_dir: stagedDir,
|
|
51
|
+
plugin_root: pluginRoot ?? null,
|
|
52
|
+
installed_version: installedPluginVersion(pluginRoot),
|
|
53
|
+
run_version: slug ? (readReceipt(receipt(cwd, slug))?.plugin?.version ?? null) : null,
|
|
54
|
+
};
|
|
55
|
+
if (!srcDir || !existsSync(srcDir) || !existsSync(stagedDir)) return out;
|
|
56
|
+
let names;
|
|
57
|
+
try { names = readdirSync(stagedDir).filter((f) => f.endsWith(".js")).sort(); } catch { return out; }
|
|
58
|
+
if (!names.length) return out;
|
|
59
|
+
out.checked = true;
|
|
60
|
+
for (const f of names) {
|
|
61
|
+
const staged = digestOf(join(stagedDir, f));
|
|
62
|
+
const installed = digestOf(join(srcDir, f));
|
|
63
|
+
// A script the installed plugin no longer carries is drift too — the staged copy is running
|
|
64
|
+
// something that has no counterpart in what is installed.
|
|
65
|
+
if (staged !== installed) out.drift.push({ file: f, staged_sha256: staged, installed_sha256: installed });
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The one-line warning a launch prints, or null when there is nothing to say.
|
|
72
|
+
* @param {object} d - A {@link stagedWorkflowDrift} result.
|
|
73
|
+
* @returns {(string|null)} Operator-facing text naming both versions.
|
|
74
|
+
*/
|
|
75
|
+
export function driftWarning(d) {
|
|
76
|
+
if (!d?.checked || !d.drift.length) return null;
|
|
77
|
+
const versions = d.run_version && d.installed_version && d.run_version !== d.installed_version
|
|
78
|
+
? ` The run opened under plugin ${d.run_version}; ${d.installed_version} is installed.`
|
|
79
|
+
: d.installed_version ? ` Installed plugin: ${d.installed_version}.` : "";
|
|
80
|
+
return `the orchestrator this launch runs is NOT the installed one — ${d.drift.length} staged script(s) differ `
|
|
81
|
+
+ `(${d.drift.map((x) => x.file).join(", ")}).${versions} A run keeps the copy it opened with, by design, so this `
|
|
82
|
+
+ `launch measures the release the run was opened under. Open a NEW run to soak an upgrade.`;
|
|
83
|
+
}
|
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/report/export.mjs
CHANGED
|
@@ -130,6 +130,11 @@ function t0Row(a, runId) {
|
|
|
130
130
|
fixtures_green: a?.fixtures_green ?? null,
|
|
131
131
|
db_probe_green: a?.db_probe_green ?? null,
|
|
132
132
|
seesaw_green: a?.seesaw_green ?? null,
|
|
133
|
+
// One field a reader compares, and the block itself stays in the artifact for a human to diff:
|
|
134
|
+
// two rows with the same tree and different env digests are two machines, not a regression.
|
|
135
|
+
env_sha256: a?.env?.env_sha256 ?? null,
|
|
136
|
+
tree_head: a?.env?.tree?.head ?? null,
|
|
137
|
+
tree_dirty: a?.env?.tree?.dirty ?? null,
|
|
133
138
|
fixtures_total: fixtures.length,
|
|
134
139
|
fixtures_passed: fixtures.filter((f) => f?.pass === true).length,
|
|
135
140
|
seesaw_ran: a?.seesaw?.ran ?? null,
|
|
@@ -1354,6 +1354,21 @@
|
|
|
1354
1354
|
"$ref": "#/$defs/AegisTriple"
|
|
1355
1355
|
},
|
|
1356
1356
|
"description": "Populated only on red; feeds the next order."
|
|
1357
|
+
},
|
|
1358
|
+
"env": {
|
|
1359
|
+
"type": "object",
|
|
1360
|
+
"description": "Where the fixtures ran — host, cwd, git tree, resolved toolchain paths, lockfile digests, profile-declared cache dirs, and a digest over an allowlist of environment variable VALUES (names in the clear, values never stored). `env_sha256` digests the whole block, so a reader compares one field and a human diffs the rest: two verdicts with the same tree and different digests were measured on different machines, which is a fact about portability rather than a regression. Written by kernel/verify/env.mjs, which judges nothing — no field here makes a verdict green or red.",
|
|
1361
|
+
"properties": {
|
|
1362
|
+
"schema_version": { "const": 1 },
|
|
1363
|
+
"env_sha256": { "type": "string" },
|
|
1364
|
+
"host": { "type": "object", "description": "platform, arch, os_release, node, hostname_sha256 (hashed — equality is all a reader needs)." },
|
|
1365
|
+
"cwd": { "type": "string", "description": "Absolute: a path-keyed toolchain cache is identified by this." },
|
|
1366
|
+
"tree": { "type": "object", "description": "git head, branch, and a dirty flag — which says something differs, never what." },
|
|
1367
|
+
"toolchain": { "type": "array", "description": "Each invoked binary as written, and where it resolved on this machine (null when nothing resolved it)." },
|
|
1368
|
+
"lockfiles": { "type": "array", "description": "Digest per lockfile present at the root. Says what was declared, never what is installed." },
|
|
1369
|
+
"caches": { "type": ["array", "null"], "description": "Cache dirs the project profile declares, resolved. null means the profile declared none — NOT that there are none." },
|
|
1370
|
+
"env": { "type": "object", "description": "The allowlist of variable names, and one digest over their values." }
|
|
1371
|
+
}
|
|
1357
1372
|
}
|
|
1358
1373
|
}
|
|
1359
1374
|
},
|
|
@@ -2652,6 +2667,17 @@
|
|
|
2652
2667
|
"description": "ANALYZE finished: the spec folder's usecases/ carries at least one use case that is not _index.md. WIRE reads these — one wiring-map entry per use case — which is why ANALYZE precedes WIRE in the phase chain: dispatched against an empty spec folder, WIRE escalates on every launch."
|
|
2653
2668
|
},
|
|
2654
2669
|
"has_board": { "type": "boolean", "description": "The per-machine board (tasks/TASK-*.md) holds at least one task. ANALYZE is complete only with both the committed spec tree and this; a committed tree with no board resumes at analyze, where the board-only operation regenerates it." },
|
|
2670
|
+
"staged_workflow": {
|
|
2671
|
+
"type": "object",
|
|
2672
|
+
"description": "Whether the orchestrator this launch will execute is the installed one. A run stages its own copy of the workflow scripts when it is OPENED and keeps them for its life — right for a run in flight, and silent: relaunching after an upgrade executes the OLD orchestrator, reports normally, and every observation is of the previous release. `checked: false` means the comparison could not be made (no plugin root, nothing staged), which is not the same fact as agreement. A warning, never a block: opening a new run is how an upgrade is soaked.",
|
|
2673
|
+
"properties": {
|
|
2674
|
+
"checked": { "type": "boolean" },
|
|
2675
|
+
"drift": { "type": "array", "items": { "type": "string" }, "description": "Staged scripts whose bytes differ from the installed plugin's." },
|
|
2676
|
+
"installed_version": { "type": ["string", "null"] },
|
|
2677
|
+
"run_version": { "type": ["string", "null"], "description": "The plugin version the run's receipt records — what this run actually opened under." },
|
|
2678
|
+
"warning": { "type": "string" }
|
|
2679
|
+
}
|
|
2680
|
+
},
|
|
2655
2681
|
"has_requirements": {
|
|
2656
2682
|
"type": "boolean",
|
|
2657
2683
|
"description": "The requirements registry is on disk: shapeup/<slug>/requirements.md exists. A PLAIN FACT, not a phase — the orchestrator guards its single `coverage` dispatch on this boolean, and it is deliberately absent from kernel/probe/resume.mjs's PHASE_ARTIFACT map, which doubles as nextPhase()'s ordered list: an entry there would fast-forward every run recorded before the registry existed to the registry instead of to build."
|
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
|
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// env — the machine a T0 verdict was measured on.
|
|
2
|
+
//
|
|
3
|
+
// A T0 artifact records `exit 0`, `pass: true` and a captured tail, and the harness treats that as
|
|
4
|
+
// the fact a generator cannot fabricate. What it did not record is that the command's outcome
|
|
5
|
+
// depended on state outside the tree. Measured on a consumer whose toolchain resolves its build
|
|
6
|
+
// plugins through a cache keyed by the project's ABSOLUTE PATH: the working tree built green; a
|
|
7
|
+
// clone of the same commit at a different path failed on a registry 404; a clone with a
|
|
8
|
+
// hand-seeded cache compiled a different plugin set and produced two errors the original never
|
|
9
|
+
// saw. Three environments, three outcomes, one tree — and three byte-identical verdicts apart from
|
|
10
|
+
// their captured output.
|
|
11
|
+
//
|
|
12
|
+
// The consequence is not that such a project is badly configured; that is its own problem. It is
|
|
13
|
+
// that a green verdict is portable evidence in appearance only, and nothing in it said so. This
|
|
14
|
+
// module records enough about where a command ran that two machines disagreeing can be told from a
|
|
15
|
+
// regression. It measures and never judges: no field here makes a verdict green or red.
|
|
16
|
+
//
|
|
17
|
+
// WHAT IS DELIBERATELY NOT HERE. No wall clock — a duration is not an environment fact and invites
|
|
18
|
+
// comparing speeds across machines. No dependency-tree manifest — the lockfile digest plus the
|
|
19
|
+
// cache paths answer the decision this record exists for, and a manifest is unbounded. No raw
|
|
20
|
+
// environment values: variables are hashed, never stored, and only from a declared allowlist.
|
|
21
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
22
|
+
import { join, resolve } from "node:path";
|
|
23
|
+
import { createHash } from "node:crypto";
|
|
24
|
+
import { spawnSync } from "node:child_process";
|
|
25
|
+
import { platform, arch, release, hostname } from "node:os";
|
|
26
|
+
import { splitFrontmatter } from "../lib/contract.mjs";
|
|
27
|
+
|
|
28
|
+
const sha256 = (t) => createHash("sha256").update(t).digest("hex");
|
|
29
|
+
|
|
30
|
+
/** Lockfiles worth digesting, by ecosystem. Bounded on purpose — a glob would walk the tree. */
|
|
31
|
+
export const LOCKFILES = [
|
|
32
|
+
"package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb",
|
|
33
|
+
"oh-package-lock.json5", "Podfile.lock", "Gemfile.lock", "Cargo.lock", "go.sum",
|
|
34
|
+
"poetry.lock", "Pipfile.lock", "composer.lock", "gradle.lockfile", "pubspec.lock",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Environment variables whose VALUES are hashed into the fingerprint. Names are recorded in the
|
|
39
|
+
* clear; values never are. Kept short and reviewed — a wide allowlist is how a token ends up
|
|
40
|
+
* hashed into a record somebody later publishes.
|
|
41
|
+
*/
|
|
42
|
+
export const ENV_ALLOWLIST = ["PATH", "NODE_ENV", "CI", "LANG", "TZ"];
|
|
43
|
+
|
|
44
|
+
/** What an unset variable hashes as — distinct from a variable set to the empty string. */
|
|
45
|
+
const UNSET = "<unset>";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The tokens a shell command actually invokes — one per `&&`/`;`/`||` segment, with `cd`, `env` and
|
|
49
|
+
* `VAR=value` prefixes stripped. Returns the token AS WRITTEN (`./scripts/t0-assemble.sh`,
|
|
50
|
+
* `/Applications/…/hvigorw`), because the resolved path is the signal a basename loses.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} cmd - A shell command line.
|
|
53
|
+
* @returns {string[]} Invoked tokens, in order, without duplicates.
|
|
54
|
+
*/
|
|
55
|
+
export function invokedTokens(cmd) {
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const seg of String(cmd || "").split(/&&|;|\|\|/).map((s) => s.trim()).filter(Boolean)) {
|
|
58
|
+
if (seg.startsWith("cd ")) continue;
|
|
59
|
+
const tokens = seg.split(/\s+/).filter((t) => t && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(t) && t !== "env" && t !== "cd");
|
|
60
|
+
if (tokens.length && !out.includes(tokens[0])) out.push(tokens[0]);
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Where a token resolves on this machine, or null when nothing resolves it. */
|
|
66
|
+
function resolveBin(token, cwd) {
|
|
67
|
+
try {
|
|
68
|
+
const r = spawnSync(`command -v ${JSON.stringify(token)}`, { shell: true, cwd, encoding: "utf8", timeout: 10_000 });
|
|
69
|
+
const p = (r.stdout || "").trim().split("\n")[0];
|
|
70
|
+
return p || null;
|
|
71
|
+
} catch { return null; }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** `git rev-parse HEAD` plus whether the tree is dirty. All null outside a repository. */
|
|
75
|
+
function treeState(cwd) {
|
|
76
|
+
const git = (args) => {
|
|
77
|
+
try {
|
|
78
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 20_000 });
|
|
79
|
+
return r.status === 0 ? (r.stdout || "").trim() : null;
|
|
80
|
+
} catch { return null; }
|
|
81
|
+
};
|
|
82
|
+
const head = git(["rev-parse", "HEAD"]);
|
|
83
|
+
if (head === null) return { head: null, dirty: null, branch: null };
|
|
84
|
+
const porcelain = git(["status", "--porcelain"]);
|
|
85
|
+
return {
|
|
86
|
+
head,
|
|
87
|
+
// A dirty flag says "something differs", never what — the honest limit of one boolean, and the
|
|
88
|
+
// reason `head` alone cannot call two measurements the same measurement.
|
|
89
|
+
dirty: porcelain === null ? null : porcelain.length > 0,
|
|
90
|
+
branch: git(["rev-parse", "--abbrev-ref", "HEAD"]),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Cache directories the project profile declares, resolved. A path-keyed cache is identified by its
|
|
96
|
+
* path, which is exactly the mechanism that made one tree build three ways.
|
|
97
|
+
*
|
|
98
|
+
* `null` means the profile declared nothing — NOT that there are none. "Not asked" and "none" are
|
|
99
|
+
* different facts, and collapsing them is the mistake the seesaw arm already makes elsewhere.
|
|
100
|
+
*
|
|
101
|
+
* @param {(string|null)} profilePath - `shapeup/<slug>/project-profile.md`, when the caller knows it.
|
|
102
|
+
* @returns {(object[]|null)} One entry per declared cache, or null when none is declared.
|
|
103
|
+
*/
|
|
104
|
+
export function declaredCaches(profilePath) {
|
|
105
|
+
if (!profilePath || !existsSync(profilePath)) return null;
|
|
106
|
+
let meta;
|
|
107
|
+
try { meta = splitFrontmatter(readFileSync(profilePath, "utf8")).meta || {}; } catch { return null; }
|
|
108
|
+
const raw = meta.cache_dirs ?? meta.caches ?? null;
|
|
109
|
+
const list = Array.isArray(raw)
|
|
110
|
+
? raw
|
|
111
|
+
: typeof raw === "string" && raw.trim() && raw.trim() !== "~"
|
|
112
|
+
? raw.replace(/^\[|\]$/g, "").split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean)
|
|
113
|
+
: null;
|
|
114
|
+
if (!list || !list.length) return null;
|
|
115
|
+
return list.map((p) => {
|
|
116
|
+
const path = p.startsWith("~") ? join(process.env.HOME || "", p.slice(1)) : p;
|
|
117
|
+
let mtime = null;
|
|
118
|
+
try { mtime = statSync(path).mtime.toISOString(); } catch { /* absent is a fact, not an error */ }
|
|
119
|
+
return { declared: p, path, exists: existsSync(path), mtime };
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Stable JSON: object keys sorted at every depth, so a digest does not depend on insertion order.
|
|
125
|
+
* @param {*} value - Anything JSON-representable.
|
|
126
|
+
* @returns {string} The canonical form.
|
|
127
|
+
*/
|
|
128
|
+
export function canonical(value) {
|
|
129
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
130
|
+
if (value && typeof value === "object") {
|
|
131
|
+
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(",")}}`;
|
|
132
|
+
}
|
|
133
|
+
return JSON.stringify(value ?? null);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The environment block a T0 verdict carries.
|
|
138
|
+
*
|
|
139
|
+
* @param {string} rawCwd - The directory the fixtures ran in; resolved to an absolute path here.
|
|
140
|
+
* @param {object} [opts] - `commands` (the fixture command lines) and `profilePath`.
|
|
141
|
+
* @returns {object} `{schema_version, host, cwd, tree, toolchain, lockfiles, caches, env, env_sha256}`.
|
|
142
|
+
* `env_sha256` digests the block itself, so a reader compares one field and a human diffs the
|
|
143
|
+
* rest. Without the digest every consumer re-implements the comparison and they disagree, which
|
|
144
|
+
* is three key spaces for one id waiting to happen on a new record.
|
|
145
|
+
*/
|
|
146
|
+
export function environmentFingerprint(rawCwd, { commands = [], profilePath = null } = {}) {
|
|
147
|
+
// ABSOLUTE, always. A caller that ran with `--cwd .` would otherwise record "." as the place —
|
|
148
|
+
// and the place is the whole point: the cache that made one tree build three ways is keyed by
|
|
149
|
+
// the project's absolute path.
|
|
150
|
+
const cwd = resolve(rawCwd || process.cwd());
|
|
151
|
+
const tokens = [...new Set(commands.flatMap((c) => invokedTokens(c)))];
|
|
152
|
+
const block = {
|
|
153
|
+
schema_version: 1,
|
|
154
|
+
host: {
|
|
155
|
+
platform: platform(),
|
|
156
|
+
arch: arch(),
|
|
157
|
+
os_release: release(),
|
|
158
|
+
node: process.version,
|
|
159
|
+
// Hashed: equality is all a reader needs, and a hostname names a person's laptop.
|
|
160
|
+
hostname_sha256: sha256(hostname()),
|
|
161
|
+
},
|
|
162
|
+
cwd,
|
|
163
|
+
tree: treeState(cwd),
|
|
164
|
+
toolchain: tokens.map((bin) => ({ bin, path: resolveBin(bin, cwd) })),
|
|
165
|
+
lockfiles: LOCKFILES
|
|
166
|
+
.filter((f) => existsSync(join(cwd, f)))
|
|
167
|
+
.map((f) => {
|
|
168
|
+
try { return { file: f, sha256: sha256(readFileSync(join(cwd, f))) }; }
|
|
169
|
+
catch { return { file: f, sha256: null }; }
|
|
170
|
+
}),
|
|
171
|
+
caches: declaredCaches(profilePath),
|
|
172
|
+
env: {
|
|
173
|
+
allowlist: ENV_ALLOWLIST,
|
|
174
|
+
// One digest over the allowlisted names AND values: a PATH that changed shows up, and no
|
|
175
|
+
// value is stored.
|
|
176
|
+
sha256: sha256(ENV_ALLOWLIST.map((k) => `${k}=${process.env[k] ?? UNSET}`).join("\n")),
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
return { ...block, env_sha256: sha256(canonical(block)) };
|
|
180
|
+
}
|
package/kernel/verify/t0.mjs
CHANGED
|
@@ -40,10 +40,11 @@ import { join, dirname } from "node:path";
|
|
|
40
40
|
import { spawnSync } from "node:child_process";
|
|
41
41
|
import { createHash } from "node:crypto";
|
|
42
42
|
import { digest } from "../probe/digest.mjs";
|
|
43
|
+
import { environmentFingerprint } from "./env.mjs";
|
|
43
44
|
import { runArgs } from "../lib/argv.mjs";
|
|
44
45
|
import { snapshot, restore, keptRef } from "./ratchet-tree.mjs";
|
|
45
46
|
import { readContract, SCOPE_CONTRACT } from "../lib/contract.mjs";
|
|
46
|
-
import { runIdFromRoot, localRoot, SHARED } from "../lib/paths.mjs";
|
|
47
|
+
import { runIdFromRoot, localRoot, SHARED, projectProfile } from "../lib/paths.mjs";
|
|
47
48
|
|
|
48
49
|
/**
|
|
49
50
|
* The feature slug a scope contract belongs to, from its path.
|
|
@@ -576,6 +577,14 @@ export async function cli(rawArgv) {
|
|
|
576
577
|
const { path, sha256: hash, trial } = writeArtifact(outDir, round, attempt, {
|
|
577
578
|
...(runId ? { run_id: runId } : {}),
|
|
578
579
|
scope_id: contract.scope_id,
|
|
580
|
+
// WHERE IT RAN, beside what it measured. A verdict that records only the tree is portable
|
|
581
|
+
// evidence in appearance only: the same commit built three ways on three machines because the
|
|
582
|
+
// toolchain resolved through a path-keyed cache. This block does not judge — it is what lets a
|
|
583
|
+
// disagreeing re-run be told from a regression (see verify/env.mjs).
|
|
584
|
+
env: environmentFingerprint(cwd, {
|
|
585
|
+
commands: [...fixtures.results.map((r) => r.cmd), ...(dbProbe?.cmd ? [dbProbe.cmd] : [])],
|
|
586
|
+
profilePath: projectProfile(cwd, slugFromContractPath(contractPath)),
|
|
587
|
+
}),
|
|
579
588
|
// The evidence, not just the score — see `commandEvidence` for what the three-field record
|
|
580
589
|
// could not tell apart, and why `exit` still reads the way it always did.
|
|
581
590
|
fixtures: fixtures.results.map((r) => commandEvidence(r)),
|
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;
|
|
@@ -414,6 +414,16 @@ const RESUME = {
|
|
|
414
414
|
has_orient_artifacts: { type: "boolean" },
|
|
415
415
|
has_spec_tree: { type: "boolean" },
|
|
416
416
|
has_board: { type: "boolean" },
|
|
417
|
+
staged_workflow: {
|
|
418
|
+
type: "object",
|
|
419
|
+
properties: {
|
|
420
|
+
checked: { type: "boolean" },
|
|
421
|
+
drift: { type: "array", items: { type: "string" } },
|
|
422
|
+
installed_version: nullable("string"),
|
|
423
|
+
run_version: nullable("string"),
|
|
424
|
+
warning: { type: "string" },
|
|
425
|
+
},
|
|
426
|
+
},
|
|
417
427
|
// The requirements registry — a fact, not a phase. See the COVERAGE block below for why it is
|
|
418
428
|
// guarded on this bare boolean and never asked about through `probe resume --require`.
|
|
419
429
|
has_requirements: { type: "boolean" },
|
|
@@ -1232,7 +1242,14 @@ if (launchRecordAbort) return await withWarnings(launchRecordAbort);
|
|
|
1232
1242
|
|
|
1233
1243
|
phase("Orient");
|
|
1234
1244
|
|
|
1235
|
-
const rs = await query(`probe resume --slug ${slug}`, RESUME, "Orient", "resume-state");
|
|
1245
|
+
const rs = await query(`probe resume --slug ${slug} --plugin-root "${args.pluginRoot}"`, RESUME, "Orient", "resume-state");
|
|
1246
|
+
// WHICH ORCHESTRATOR IS RUNNING. A run keeps the workflow copy it opened with — correct for a run
|
|
1247
|
+
// in flight, and silent: a relaunch after an upgrade executes the old script, reports normally and
|
|
1248
|
+
// closes normally, so every observation is of the previous release. Said out loud, never enforced.
|
|
1249
|
+
if (rs.staged_workflow?.warning) {
|
|
1250
|
+
log(`RUN STATE — ${rs.staged_workflow.warning}`);
|
|
1251
|
+
stateWarnings.push(rs.staged_workflow.warning);
|
|
1252
|
+
}
|
|
1236
1253
|
// A probe that produced nothing is not an EMPTY run — it is an unknown one. Treating it as empty
|
|
1237
1254
|
// would re-dispatch every phase from the top, over a run that may be in progress.
|
|
1238
1255
|
if (!rs) {
|