forgedock 1.26.0 → 1.27.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.
Files changed (42) hide show
  1. package/bin/engine/outcome-codes.mjs +3 -0
  2. package/bin/engine/phases.mjs +38 -2
  3. package/bin/engine/telemetry-seam.mjs +6 -0
  4. package/bin/engine/telemetry-seam.test.mjs +4 -0
  5. package/bin/engine-cli.mjs +78 -20
  6. package/bin/engine.mjs +5 -1
  7. package/bin/forgedock.mjs +94 -30
  8. package/bin/init-detect.mjs +68 -3
  9. package/bin/init-enrich.mjs +3 -0
  10. package/bin/init-repo.mjs +213 -0
  11. package/bin/journey.mjs +278 -43
  12. package/bin/lean/batch.mjs +2 -1
  13. package/bin/lean/batch.test.mjs +49 -1
  14. package/bin/lean/conformance.mjs +76 -10
  15. package/bin/lean/conformance.test.mjs +32 -3
  16. package/bin/lean/effects.mjs +96 -11
  17. package/bin/lean/lean.mjs +326 -33
  18. package/bin/lean/lean.test.mjs +618 -10
  19. package/bin/lean/ledger.mjs +67 -0
  20. package/bin/lean/ledger.test.mjs +65 -0
  21. package/bin/lean/orchestrate.mjs +9 -0
  22. package/bin/lean/prompts.mjs +42 -8
  23. package/bin/lean/render.mjs +1 -1
  24. package/bin/lean/verify.mjs +113 -0
  25. package/bin/lean/verify.test.mjs +95 -0
  26. package/commands/adopt.md +3 -0
  27. package/commands/audit.md +3 -8
  28. package/commands/forgedock-init.md +118 -6
  29. package/commands/review-pr.md +2 -2
  30. package/docs/CONFIG.md +52 -3
  31. package/docs/OUTCOME-CODES.md +3 -0
  32. package/package.json +1 -1
  33. package/scripts/bootstrap-validate.mjs +287 -0
  34. package/scripts/check-devdocs-freshness.sh +120 -0
  35. package/scripts/check-devdocs-freshness.test.sh +120 -0
  36. package/scripts/ci-local/run.mjs +13 -2
  37. package/scripts/ci-local.test.sh +13 -1
  38. package/scripts/northstar-docs.test.sh +68 -0
  39. package/scripts/review-prepare.sh +11 -9
  40. package/scripts/review-prepare.test.sh +14 -0
  41. package/scripts/structure-delta.sh +150 -0
  42. package/scripts/structure-delta.test.sh +135 -0
@@ -0,0 +1,67 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Lane resource ledger (#4179): `~/.forge/ledger/<batch>/<lane>.json` lists every resource a lane owns (worktree, branch,
5
+ * run tmp, spawned pids, containers/volumes the builder reported) with owner, batch, lane, issue, created and expires.
6
+ * Each resource is registered BEFORE it is created and released only after its teardown succeeded, so a lane that dies
7
+ * leaves an exact record behind. Teardown acts only on what the ledger lists; this module itself only reads and writes
8
+ * the ledger file (no git, gh, docker or kill calls).
9
+ */
10
+ import * as nodeFs from "node:fs";
11
+ import { homedir, hostname } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ export const ledgerRoot = () => join(homedir(), ".forge", "ledger");
15
+ export const LEDGER_TTL_MS = 6 * 3600 * 1000;
16
+ export const LEDGER_KINDS = Object.freeze(["worktree", "branch", "tmp", "pid", "container", "volume"]);
17
+ /** A path segment that cannot escape the root: [A-Za-z0-9._-] only, never "." or "..". */
18
+ export const safeSegment = (s) => { const v = String(s ?? "").replace(/[^A-Za-z0-9._-]/g, "_"); return !v || /^\.+$/.test(v) ? "_" : v; };
19
+
20
+ /** Docker ownership labels (#4181): owner, batch, lane, issue, expires (epoch ms, as the ledger), values [A-Za-z0-9_.-] only. */
21
+ export function dockerLabels({ batch, lane, issue, expires } = {}) {
22
+ const v = (x) => String(x ?? "").replace(/[^A-Za-z0-9_.-]/g, "-") || "unknown";
23
+ return [`forgedock.owner=1`, `forgedock.batch=${v(batch)}`, `forgedock.lane=${v(lane)}`, `forgedock.issue=${v(issue)}`, `forgedock.expires=${v(expires)}`];
24
+ }
25
+
26
+ export function makeLedger({ root = ledgerRoot(), batch = "adhoc", lane, owner, issue, host = hostname(), pid = process.pid, now = () => Date.now(), ttlMs = LEDGER_TTL_MS, fs = nodeFs } = {}) {
27
+ const dir = join(root, safeSegment(batch));
28
+ const path = join(dir, `${safeSegment(lane ?? `lane-${issue}`)}.json`);
29
+ const read = () => { try { const j = JSON.parse(fs.readFileSync(path, "utf8")); return j && typeof j === "object" && Array.isArray(j.resources) ? j : null; } catch { return null; } };
30
+ const write = (j) => {
31
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
32
+ const tmp = `${path}.tmp-${process.pid}`;
33
+ fs.writeFileSync(tmp, JSON.stringify(j, null, 2) + "\n");
34
+ fs.renameSync(tmp, path);
35
+ };
36
+ const fresh = () => { const c = now(); return { v: 1, owner, batch: String(batch), lane: String(lane ?? `lane-${issue}`), issue, host, pid, created: c, expires: c + ttlMs, resources: [] }; };
37
+ const same = (r, kind, value) => r.kind === kind && String(r.value) === String(value);
38
+ return {
39
+ path,
40
+ read,
41
+ /** Idempotent; call BEFORE the resource is created. `extra` (e.g. {marker}) is kept on the entry. */
42
+ register(kind, value, extra = {}) {
43
+ if (!LEDGER_KINDS.includes(kind) || value == null || value === "") return false;
44
+ const j = read() ?? fresh();
45
+ Object.assign(j, { owner, host, pid, expires: now() + ttlMs });
46
+ if (!j.resources.some((r) => same(r, kind, value))) j.resources.push({ kind, value, ...extra, registered: now() });
47
+ write(j);
48
+ return true;
49
+ },
50
+ release(kind, value) {
51
+ const j = read();
52
+ if (!j) return false;
53
+ const n = j.resources.length;
54
+ j.resources = j.resources.filter((r) => !same(r, kind, value));
55
+ if (j.resources.length !== n) write(j);
56
+ return j.resources.length !== n;
57
+ },
58
+ entries(kind) { return (read()?.resources ?? []).filter((r) => !kind || r.kind === kind); },
59
+ /** Delete the file only when it lists nothing; a non-empty ledger stays for the next run or the reaper. */
60
+ remove() {
61
+ const j = read();
62
+ if (j && j.resources.length) return false;
63
+ try { fs.rmSync(path, { force: true }); } catch { return false; }
64
+ return true;
65
+ },
66
+ };
67
+ }
@@ -0,0 +1,65 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
6
+ import { join, dirname } from "node:path";
7
+ import { tmpDir } from "../tests/_tmp.mjs";
8
+ import { makeLedger, safeSegment, LEDGER_TTL_MS, dockerLabels } from "./ledger.mjs";
9
+
10
+ describe("lane resource ledger (#4179)", () => {
11
+ it("register/release round trip carries owner, batch, lane, issue, created and expires", () => {
12
+ const root = tmpDir("ledger-");
13
+ let t = 1000;
14
+ const l = makeLedger({ root, batch: "b20261019-1", lane: "lean-7", owner: "core_1", issue: 7, host: "h", pid: 11, now: () => t });
15
+ assert.equal(l.path, join(root, "b20261019-1", "lean-7.json"));
16
+ assert.equal(l.read(), null);
17
+ assert.equal(l.register("worktree", "/wt/lean-7"), true);
18
+ l.register("branch", "feat/issue-7-lean");
19
+ l.register("pid", 4242, { marker: "claude" });
20
+ l.register("worktree", "/wt/lean-7"); // idempotent
21
+ t = 2000;
22
+ l.register("container", "lean7-pg");
23
+ const j = JSON.parse(readFileSync(l.path, "utf8"));
24
+ assert.deepEqual({ owner: j.owner, batch: j.batch, lane: j.lane, issue: j.issue, host: j.host, pid: j.pid, created: j.created }, { owner: "core_1", batch: "b20261019-1", lane: "lean-7", issue: 7, host: "h", pid: 11, created: 1000 });
25
+ assert.equal(j.expires, 2000 + LEDGER_TTL_MS, "each registration extends expiry");
26
+ assert.deepEqual(j.resources.map((r) => `${r.kind}:${r.value}`), ["worktree:/wt/lean-7", "branch:feat/issue-7-lean", "pid:4242", "container:lean7-pg"]);
27
+ assert.equal(l.entries("pid")[0].marker, "claude");
28
+ assert.equal(l.register("nonsense", "x"), false, "unknown kinds are refused");
29
+ assert.equal(l.release("pid", "4242"), true);
30
+ assert.equal(l.release("pid", 4242), false);
31
+ assert.equal(l.remove(), false, "a ledger that still lists resources is kept");
32
+ for (const r of l.entries()) l.release(r.kind, r.value);
33
+ assert.equal(l.remove(), true);
34
+ assert.equal(existsSync(l.path), false);
35
+ });
36
+
37
+ it("writes are atomic (no tmp file left behind) and names cannot escape the root", () => {
38
+ const root = tmpDir("ledger-");
39
+ const l = makeLedger({ root, batch: "../../etc", lane: "../passwd", issue: 1 });
40
+ l.register("tmp", "/x");
41
+ assert.equal(dirname(dirname(l.path)), root);
42
+ assert.deepEqual(readdirSync(dirname(l.path)), [l.path.split("/").at(-1)]);
43
+ for (const [i, o] of [["..", "_"], [".", "_"], ["", "_"], ["a/b", "a_b"], ["milestone/x", "milestone_x"], ["b1.2", "b1.2"]]) assert.equal(safeSegment(i), o);
44
+ });
45
+
46
+ it("a simulated crash (no teardown) leaves every registered resource readable by a fresh process", () => {
47
+ const root = tmpDir("ledger-");
48
+ const l = makeLedger({ root, batch: "b1", lane: "lean-9", owner: "core_9", issue: 9 });
49
+ for (const [k, v] of [["tmp", "/home/x/.forge/tmp/runs/9-1"], ["branch", "feat/issue-9-lean"], ["worktree", "/wt/lean-9"], ["pid", 77]]) l.register(k, v);
50
+ // the process dies here: nothing is released
51
+ const after = makeLedger({ root, batch: "b1", lane: "lean-9", issue: 9 }).read();
52
+ assert.equal(after.owner, "core_9");
53
+ assert.deepEqual(after.resources.map((r) => r.kind), ["tmp", "branch", "worktree", "pid"]);
54
+ });
55
+ });
56
+
57
+ describe("docker ownership labels (#4181)", () => {
58
+ it("owner, batch, lane, issue and expires, in order; values cannot inject", () => {
59
+ assert.deepEqual(dockerLabels({ batch: "b1", lane: "lean-7", issue: 7, expires: 123 }),
60
+ ["forgedock.owner=1", "forgedock.batch=b1", "forgedock.lane=lean-7", "forgedock.issue=7", "forgedock.expires=123"]);
61
+ const l = dockerLabels({ batch: "a b;rm -rf", lane: "", issue: null, expires: undefined });
62
+ assert.equal(l[1], "forgedock.batch=a-b-rm--rf"); assert.equal(l[2], "forgedock.lane=unknown"); assert.equal(l[3], "forgedock.issue=unknown");
63
+ for (const e of l) assert.match(e, /^forgedock\.[a-z]+=[A-Za-z0-9_.-]+$/);
64
+ });
65
+ });
@@ -218,6 +218,15 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
218
218
  if (p) track(n, Promise.resolve(p).then((r) => settle(n, r, rec, checked.get(n) ?? null)));
219
219
  else { runRecs.delete(n); if (!pending.includes(n) && !results.has(n)) pending.unshift(n); }
220
220
  }
221
+ // A resume re-derives each unfinished issue's lane from its current milestone (#4153): a re-milestone takes effect.
222
+ if (saved && resolveLane) {
223
+ for (const n of order) {
224
+ if (results.has(n) || running.has(n)) continue;
225
+ let l; try { l = await resolveLane(n); } catch (e) { l = { ok: false, code: "lane.unresolved", detail: e?.message ?? String(e) }; }
226
+ lanes.set(n, l);
227
+ }
228
+ noteCrossLane();
229
+ }
221
230
  save();
222
231
 
223
232
  const K = () => { let k = null; try { k = getParallel?.(); } catch { k = null; } return Number.isInteger(k) && k >= 1 ? k : parallel; };
@@ -12,6 +12,29 @@ export const CONTRACT_FILE = `${LEAN_DIR}/contract.json`;
12
12
 
13
13
  const HOST_RULES = `Host rules (many lanes share this machine): run only targeted tests (specific test files or -k filters), never the full suite. Never create extra checkouts, copies or base clones of the repo; compare against the base with git show or git diff origin/<base> instead. Never install browsers. Keep all scratch files under $TMPDIR.`;
14
14
 
15
+ /** Verify-by-CI (#4167): in ci/hybrid mode the engine runs the repo's CI on GitHub runners; the host runs only light checks. */
16
+ const LIGHT = "run only formatters and linters on the files you changed";
17
+ const VERIFY_RUN = { ci: `${LIGHT} (no type-checks, test suites or test containers)`, hybrid: "run the targeted validate commands" };
18
+ const VERIFY_RULES = {
19
+ ci: `Verification runs on CI (forge.yaml verify.mode: ci). Do NOT run type-checks (tsc and the like), test suites (pytest, jest, node --test and the like) or docker/test containers on this host: ${LIGHT}. The engine pushes the branch, runs CI and sends failures back to you. In ${RESULT_FILE} record each validate id you did not run as {"id":"V1","command":"","ci":true}.`,
20
+ hybrid: `Verification also runs on CI (forge.yaml verify.mode: hybrid). Run the targeted validate commands and ${LIGHT}; never the full suite and no docker/test containers. The engine pushes the branch, runs CI and sends failures back to you. Record each validate id you did not run as {"id":"V1","command":"","ci":true}.`,
21
+ };
22
+ const rerun = (mode) => (mode === "ci" ? `Re-run the light checks only (${LIGHT}); the engine re-verifies on CI` : "Re-run the validate commands");
23
+
24
+ /** Base-sync conflict resolution: which checks to run after resolving (#4167: ci mode runs light checks only). */
25
+ export const syncChecks = (mode = "local") => (mode === "ci" ? `${LIGHT} (the engine re-verifies on CI)` : "run the relevant tests");
26
+
27
+ /** CI failed on the pushed branch (#4167): one fix round in the builder's own session. */
28
+ export function ciFailurePrompt(failures, mode = "local") {
29
+ const list = failures.slice(0, 10).map((f) => `#### ${f.job ?? "check"}${f.url ? ` (${f.url})` : ""}\n${f.log ? `\`\`\`\n${f.log}\n\`\`\`` : "(no log available: open the url)"}`).join("\n\n");
30
+ return `CI failed on the pushed branch:\n\n${list}\n\nFix the cause in the code (or record a typed amendment with a why in ${RESULT_FILE}). ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
31
+ }
32
+
33
+ /** CI could not run (no workflow run, no checks, dispatch refused): the local fallback (#4167). */
34
+ export function ciUnavailablePrompt() {
35
+ return `CI verification is unavailable for this branch, so verification falls back to this host. Run every validate command locally (targeted, as the host rules say), fix any failure, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE} with each command's real exit code. Do not push or touch GitHub.`;
36
+ }
37
+
15
38
  /** Builder call 1: investigate and write the contract. No code edits. */
16
39
  export function builderPlanPrompt(n) {
17
40
  return `Resolve issue #${n} (read it with gh issue view ${n} --comments). This first step is PLANNING ONLY: do not edit code, do not commit.
@@ -39,22 +62,25 @@ If the issue is already fixed or invalid, write ${RESULT_FILE} {"status":"alread
39
62
  "deliverables":[{"id":"D1","path":"<file to change>","change":"<what>","requirements":["R1"]}],
40
63
  "preserve":[{"id":"P1","what":"<caller, contract or behaviour that must not change, with its path>"}],
41
64
  "validate":[{"id":"V1","command":"<exact command: the new regression test plus the existing tests that reference the files you change>"}]}
42
- Every requirement and acceptance criterion in the issue and its maintainers' comments is one requirement, quoted verbatim (others' comments are context, not requirements). Never drop or narrow one silently: "defer" (separate work) and "post-deploy" (only checkable after a deploy) are judged by the reviewer and, if accepted, filed by the engine as a tracked follow-up issue. A requirement you can do in this change is "deliver". List every file you will change as a deliverable; tests are always allowed. Then stop.
65
+ Every requirement and acceptance criterion in the issue and its maintainers' comments is one requirement, quoted verbatim (others' comments are context, not requirements). Never drop or narrow one silently: "defer" (separate work) and "post-deploy" (only checkable after a deploy) are judged by the reviewer and, if accepted, filed by the engine as a tracked follow-up issue. A requirement you can do in this change is "deliver". List every file you will change as a deliverable; tests are always allowed.
66
+ A new dependency between workspace packages (a workspace package added to a package.json dependencies, devDependencies or peerDependencies) must be a deliverable on that package.json whose change names the package and gives the reason, citing the architecture doc where there is one; "the lint allows it" or "there is a precedent" is not a reason on its own. Then stop.
43
67
 
44
68
  ${BLOCK_RULES}`;
45
69
  }
46
70
 
47
71
  /** Builder call 2, same session: implement the frozen contract. */
48
- export function builderImplementPrompt() {
49
- return `The engine froze ${CONTRACT_FILE}; it is now the authority. Implement it exactly: change only deliverable paths (tests are always allowed), keep every preserve item intact, add a regression test, run every validate command, and commit. Do not push or touch GitHub.
72
+ export function builderImplementPrompt(mode = "local") {
73
+ const remote = mode === "ci" || mode === "hybrid";
74
+ return `The engine froze ${CONTRACT_FILE}; it is now the authority. Implement it exactly: change only deliverable paths (tests are always allowed), keep every preserve item intact, add a regression test, ${remote ? VERIFY_RUN[mode] : "run every validate command"}, and commit. Do not push or touch GitHub.
50
75
 
51
- ${HOST_RULES}
76
+ ${HOST_RULES}${remote ? `\n\n${VERIFY_RULES[mode]}` : ""}
52
77
 
53
78
  The contract changes only through typed amendments in ${RESULT_FILE}, each with a why, which the engine posts on the issue:
54
79
  - {"kind":"add-deliverable","path":"<file>","why":""}: a file you must change that the contract did not list.
55
80
  - {"kind":"add-requirement","requirement":{"id":"R9","quote":"<verbatim from the issue>","disposition":"deliver|defer|post-deploy"},"why":""}: an issue requirement the contract missed.
56
81
  - {"kind":"defer|post-deploy","requirement":"R#","why":""}: a contracted requirement that becomes separate work or a post-deploy check. The engine files it as a tracked follow-up.
57
82
  Never report a deliver requirement as "deferred" or "follow-up" without an amendment.
83
+ A new dependency between workspace packages in a package.json that no deliverable names (that package.json, with the package in its change) is blocked by the engine: add it through add-deliverable with a why naming the package, or do not add it.
58
84
 
59
85
  When done and committed, write ${IMPLEMENTATION_FILE} (markdown, no HTML comments):
60
86
  ### Approach
@@ -64,6 +90,8 @@ When done and committed, write ${IMPLEMENTATION_FILE} (markdown, no HTML comment
64
90
  ### Notes (behavior changes, env vars)
65
91
 
66
92
  Finally write ${RESULT_FILE}: {"status":"fixed","summary":"one line","requirements":[{"id":"R1","status":"implemented|partial|missing","evidence":"file:line"}],"validation":[{"id":"V1","command":"","exit":0}],"tests":[{"command":"","result":"pass|fail: ...","fails_without_fix":true|false|"unknown"}],"amendments":[]}
93
+ Docker: start containers with docker run --rm; pass --label for each entry of $FORGE_DOCKER_LABELS on every docker run, volume create, network create and build (compose: on services, volumes, networks); tear down via trap/finally.
94
+ If you started Docker containers or volumes that are still there, list their names in ${RESULT_FILE} as "resources":{"containers":[],"volumes":[]} so the engine removes them when the lane ends.
67
95
  If you find you are blocked, write "status":"blocked" with a typed "block" instead.`;
68
96
  }
69
97
 
@@ -72,8 +100,13 @@ export function contractCorrectionPrompt(problems) {
72
100
  }
73
101
 
74
102
  /** The one bounded correction round, in the builder's own session. */
75
- export function conformanceResumePrompt(failures) {
76
- return `The change does not conform to the frozen contract and the issue:\n${failures.slice(0, 30).map((f) => `- ${f.code}: ${String(f.detail).slice(0, 500)}`).join("\n")}\n\nFix each item in the code, or record a typed amendment with a why in ${RESULT_FILE}. Re-run the validate commands, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
103
+ export function conformanceResumePrompt(failures, mode = "local") {
104
+ return `The change does not conform to the frozen contract and the issue:\n${failures.slice(0, 30).map((f) => `- ${f.code}: ${String(f.detail).slice(0, 500)}`).join("\n")}\n\nFix each item in the code, or record a typed amendment with a why in ${RESULT_FILE}. ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
105
+ }
106
+
107
+ /** forge#4159: the one resume for package.json edges between workspace packages the contract does not name. */
108
+ export function structurePrompt(edges, mode = "local") {
109
+ return `This change adds dependencies between workspace packages that the frozen contract does not name:\n${edges.slice(0, 30).map((e) => `- ${e.from} -> ${e.to} (${e.kind}, ${e.manifest})`).join("\n")}\n\nFor each one, add it to the contract with a reason, or remove it: either record {"kind":"add-deliverable","path":"<that package.json>","why":"<names the package and why this dependency is right>"} in ${RESULT_FILE}, or drop the dependency from the package.json and the code that needs it. ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
77
110
  }
78
111
 
79
112
  /** Human-only block reasons (#4124). Everything else agents resolve: a dependency, a split, prod data monitoring can read. */
@@ -97,6 +130,7 @@ You receive the GitHub issue with its comments, the frozen contract (requirement
97
130
  - scope: one row per changed file: the deliverable id that authorizes it (deliverables may be directories or globs), "test", "incidental" (changelog, lockfile), or "none" with what it changes.
98
131
  - preserve: one row per preserve id: intact|violated|unverified, with path:line.
99
132
  - regressions: concrete breakages only, each with path:line.
133
+ Start from <row_skeleton> when given: it already lists every row the engine requires. Keep every id and file, fill each status and evidence, and add issue_coverage rows for issue requirements the contract does not quote. Scope rows are needed only for the files listed there; the engine authorizes the rest.
100
134
 
101
135
  Output ONLY a JSON object in a json code fence:
102
136
  {"issue_coverage":[{"quote":"","contract_ids":["R1"],"status":"covered|narrowed|missing"}],"requirements":[{"id":"R1","status":"","evidence":"path:line","validated_by":"V1","deferral_ok":true,"note":""}],"scope":[{"file":"","authorized_by":"D1|test|none","note":""}],"preserve":[{"id":"P1","status":"","evidence":"path:line"}],"regressions":[{"at":"path:line","failure":""}],"verdict":"CONFORMS|NONCONFORMING","summary":"one line"}
@@ -132,9 +166,9 @@ export function boundDiff(diff, { stat = "", base = "", maxBytes = REVIEW_DIFF_M
132
166
  + (list.length ? `\n<omitted_files>\n${list.join("\n")}\n</omitted_files>` : "");
133
167
  }
134
168
 
135
- export function reviewerPrompt({ issue, comments = [], contract, result, files = [], diff, stat = "", base = "" }) {
169
+ export function reviewerPrompt({ issue, comments = [], contract, result, files = [], diff, stat = "", base = "", structure = "", skeleton = null, retry = null }) {
136
170
  const notes = comments.map((c) => String(c?.body ?? "")).filter((b) => !/^<!-- FORGE:/.test(b.trim()));
137
- return `Issue #${issue.number}: ${issue.title}\n\n<issue_body>\n${cap(issue.body ?? "", 20000)}\n</issue_body>\n\n<issue_comments>\n${cap(notes.join("\n---\n"), 8000)}\n</issue_comments>\n\n<contract>\n${cap(JSON.stringify(contract ?? {}, null, 1), 12000)}\n</contract>\n\n<builder_result>\n${cap(JSON.stringify(result ?? {}, null, 1), 6000)}\n</builder_result>\n\n<changed_files>\n${files.join("\n")}\n</changed_files>\n\n<diff>\n${boundDiff(diff, { stat, base })}\n</diff>`;
171
+ return `Issue #${issue.number}: ${issue.title}\n\n<issue_body>\n${cap(issue.body ?? "", 20000)}\n</issue_body>\n\n<issue_comments>\n${cap(notes.join("\n---\n"), 8000)}\n</issue_comments>\n\n<contract>\n${cap(JSON.stringify(contract ?? {}, null, 1), 12000)}\n</contract>\n\n<builder_result>\n${cap(JSON.stringify(result ?? {}, null, 1), 6000)}\n</builder_result>\n\n<changed_files>\n${files.join("\n")}\n</changed_files>\n\n<diff>\n${boundDiff(diff, { stat, base })}\n</diff>${structure ? `\n<structure>${String(structure).replace(/[\r\n]+/g, " ")}</structure>` : ""}${skeleton ? `\n\n<row_skeleton>\n${cap(JSON.stringify(skeleton), 12000)}\n</row_skeleton>` : ""}${retry?.problems?.length ? `\n\n<previous_reply_problems>\nYour previous reply was rejected as incomplete. Fix exactly these and return the complete corrected JSON:\n${retry.problems.slice(0, 60).map((x) => `- ${x}`).join("\n")}\n</previous_reply_problems>${retry.previous ? `\n\n<previous_reply>\n${cap(retry.previous, 8000)}\n</previous_reply>` : ""}` : ""}`;
138
172
  }
139
173
 
140
174
  /** Pre-filter (#4123): one read-only, single-turn check of an issue a cheap signal flagged as possibly already fixed. */
@@ -141,7 +141,7 @@ export function renderPrBody({ issue, investigation, contract, implementation, r
141
141
 
142
142
  // ---- trajectory ----
143
143
  export function renderTrajectory(T) {
144
- const rows = [["Validate", T.validate], ["Plan", T.plan], ["Implement", T.implement], ["Review", T.review], ["PR", T.pr], ["Base Sync", T.baseSync ?? "not needed"], ["Merge", T.merge], ["Close", T.close]];
144
+ const rows = [["Validate", T.validate], ["Plan", T.plan], ["Implement", T.implement], ...(T.verify ? [["Verify", T.verify]] : []), ["Review", T.review], ["PR", T.pr], ["Base Sync", T.baseSync ?? "not needed"], ["Merge", T.merge], ["Close", T.close]];
145
145
  const cellPair = (v) => {
146
146
  const [res, notes] = Array.isArray(v) ? v : [v, ""];
147
147
  return `${cell(res ?? "not reached")} | ${notes ? cell(notes) : "-"}`;
@@ -0,0 +1,113 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Verify-by-CI (#4167): run the lane's heavy verification on CI runners instead of the host. The engine pushes the
5
+ * branch, dispatches the configured workflow (or waits on the head commit's push-triggered checks), and returns the
6
+ * failed jobs' log tails for the builder's fix loop. All GitHub effects go through the injected io.
7
+ */
8
+ import { ciPollDelayMs } from "../engine.mjs";
9
+
10
+ export const VERIFY_MODES = Object.freeze(["local", "ci", "hybrid"]);
11
+ /** Changed files that never need the CI matrix (docs-only lanes skip CI verification). */
12
+ export const DEFAULT_SKIP_PATHS = Object.freeze(["*.md", "*.mdx", "*.rst", "*.txt", "docs/**", "devdocs/**"]);
13
+ export const DEFAULT_MAX_ROUNDS = 2;
14
+ /** Total characters of failed-job log handed to the builder. */
15
+ export const LOG_CAP = 12000;
16
+ /** No run or check for the head after this long means CI is unavailable (local fallback). */
17
+ export const DEFAULT_GRACE_MS = 5 * 60000;
18
+ const LEAN_PREFIX = ".forge-lean/";
19
+ const OK = ["success", "neutral", "skipped"];
20
+
21
+ /**
22
+ * forge.yaml `verify:` section (block style; parseForgeYaml gives strings) → {mode, workflow, pathsInput, skipPaths,
23
+ * maxRounds}. Invalid values fall back to the defaults; mode defaults to local (today's behaviour).
24
+ */
25
+ export function parseVerify(sec = {}) {
26
+ const s = sec && typeof sec === "object" ? sec : {};
27
+ const str = (v) => (v === undefined || v === null ? "" : String(v).trim());
28
+ const mode = VERIFY_MODES.includes(str(s.mode)) ? str(s.mode) : "local";
29
+ const workflow = /^[A-Za-z0-9._/-]+$/.test(str(s.workflow)) ? str(s.workflow) : null;
30
+ const pathsInput = /^[A-Za-z_][A-Za-z0-9_-]*$/.test(str(s.paths_input)) ? str(s.paths_input) : null;
31
+ const rawSkip = Array.isArray(s.skip_paths) ? s.skip_paths : str(s.skip_paths) ? str(s.skip_paths).split(",") : null;
32
+ const skipPaths = rawSkip ? rawSkip.map((x) => String(x).trim()).filter(Boolean) : [...DEFAULT_SKIP_PATHS];
33
+ const n = Number(str(s.max_rounds));
34
+ const maxRounds = str(s.max_rounds) !== "" && Number.isInteger(n) && n >= 0 && n <= 10 ? n : DEFAULT_MAX_ROUNDS;
35
+ return { mode, workflow, pathsInput, skipPaths, maxRounds };
36
+ }
37
+
38
+ const globRe = (g) => new RegExp(`^${g.split("**").map((p) => p.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")).join(".*")}$`);
39
+ /** A glob without "/" matches the basename anywhere (gitignore style); one with "/" matches the whole path. */
40
+ export function matchesGlob(file, glob) {
41
+ const f = String(file), g = String(glob).replace(/^\.?\//, "");
42
+ return g.includes("/") ? globRe(g).test(f) : globRe(g).test(f.split("/").at(-1));
43
+ }
44
+ /** Changed files that CI must see: everything except the engine's own `.forge-lean/` hand-off files. */
45
+ export const verifiableFiles = (files) => (files ?? []).filter((f) => f && !String(f).startsWith(LEAN_PREFIX));
46
+ /** True when every verifiable changed file matches a skip glob (a docs-only lane), or there is none. */
47
+ export function skipsCi(files, skipPaths = DEFAULT_SKIP_PATHS) {
48
+ const fs = verifiableFiles(files);
49
+ return fs.every((f) => skipPaths.some((g) => matchesGlob(f, g)));
50
+ }
51
+
52
+ /** Tail each log so the total stays under `cap` characters. */
53
+ export function capLogs(failures, cap = LOG_CAP) {
54
+ const per = Math.max(500, Math.floor(cap / Math.max(1, failures.length)));
55
+ return failures.map((f) => { const l = String(f.log ?? ""); return { ...f, log: l.length > per ? `…${l.slice(-per)}` : l }; });
56
+ }
57
+ const runIdOf = (url) => /\/actions\/runs\/(\d+)/.exec(String(url ?? ""))?.[1] ?? null;
58
+ const sameSha = (a, b) => Boolean(a && b) && (String(a).startsWith(b) || String(b).startsWith(a));
59
+
60
+ /**
61
+ * One CI verification of `head` on `branch`. Returns {result: pass|fail|timeout|unavailable|skipped, failures, runUrl,
62
+ * detail, runId}. `push` is called before any CI call (never for a skipped lane); `exclude` lists run ids already judged,
63
+ * so a re-dispatch on the same head never reuses an old run; `onPoll` runs before each wait (lease renew, progress).
64
+ */
65
+ export async function runCiVerify({ io, branch, head, changedFiles, verify, ciMaxMinutes = 360, now = () => Date.now(), sleep, random = Math.random,
66
+ push = async () => {}, exclude = [], onPoll = async () => {}, graceMs = DEFAULT_GRACE_MS }) {
67
+ const v = verify ?? parseVerify({});
68
+ const files = verifiableFiles(changedFiles);
69
+ if (skipsCi(files, v.skipPaths)) return { result: "skipped", failures: [], runUrl: null, detail: `all ${files.length} changed file(s) match skip_paths` };
70
+ await push();
71
+ const startedAt = now();
72
+ const unavailable = (detail) => ({ result: "unavailable", failures: [], runUrl: null, detail });
73
+ if (v.workflow) {
74
+ if (!io.dispatchWorkflow || !io.workflowRuns) return unavailable("no workflow dispatch seam");
75
+ const inputs = v.pathsInput ? { [v.pathsInput]: files.join("\n") } : {};
76
+ try { await io.dispatchWorkflow({ workflow: v.workflow, ref: branch, inputs }); }
77
+ catch (e) { return unavailable(`workflow ${v.workflow} could not be dispatched: ${String(e?.stderr ?? e?.message ?? e).split("\n")[0].slice(0, 200)}`); }
78
+ } else if (!io.commitChecks) return unavailable("no commit-checks seam");
79
+ const skip = new Set(exclude.map(String));
80
+ for (let i = 0; ; i++) {
81
+ if (v.workflow) {
82
+ let runs = [];
83
+ try { runs = (await io.workflowRuns({ workflow: v.workflow, branch })) ?? []; } catch { runs = []; }
84
+ const run = runs.find((r) => sameSha(r?.headSha, head) && !skip.has(String(r?.databaseId)));
85
+ if (run && String(run.status).toLowerCase() === "completed") {
86
+ const c = String(run.conclusion ?? "").toLowerCase();
87
+ if (OK.includes(c)) return { result: "pass", failures: [], runUrl: run.url ?? null, runId: run.databaseId, detail: `${v.workflow} ${c}` };
88
+ let log = "";
89
+ try { log = String((await io.failedLogs?.(run.databaseId)) ?? ""); } catch { log = ""; }
90
+ return { result: "fail", failures: capLogs([{ job: run.name ?? v.workflow, url: run.url ?? null, log }]), runUrl: run.url ?? null, runId: run.databaseId, detail: `${v.workflow} ${c || "failed"}` };
91
+ }
92
+ if (!run && now() - startedAt >= graceMs) return unavailable(`no ${v.workflow} run for ${String(head).slice(0, 12)} appeared within ${Math.round(graceMs / 60000)} min`);
93
+ } else {
94
+ let checks = [];
95
+ try { checks = (await io.commitChecks(head)) ?? []; } catch { checks = []; }
96
+ if (!checks.length && now() - startedAt >= graceMs) return unavailable(`no checks reported for ${String(head).slice(0, 12)} within ${Math.round(graceMs / 60000)} min`);
97
+ if (checks.length && checks.every((c) => c.state !== "pending")) {
98
+ const failed = checks.filter((c) => c.state === "failure");
99
+ if (!failed.length) return { result: "pass", failures: [], runUrl: null, detail: `${checks.length} check(s) green` };
100
+ const logs = {}, out = [];
101
+ for (const c of failed) {
102
+ const id = runIdOf(c.url);
103
+ if (id && !(id in logs)) { try { logs[id] = String((await io.failedLogs?.(id)) ?? ""); } catch { logs[id] = ""; } }
104
+ out.push({ job: c.name, url: c.url ?? null, log: id && !out.some((x) => runIdOf(x.url) === id) ? logs[id] : "" });
105
+ }
106
+ return { result: "fail", failures: capLogs(out), runUrl: failed[0].url ?? null, detail: `${failed.length} check(s) failed: ${failed.map((c) => c.name).join(", ")}` };
107
+ }
108
+ }
109
+ if (now() - startedAt >= ciMaxMinutes * 60000) return { result: "timeout", failures: [], runUrl: null, detail: `CI still pending after ${ciMaxMinutes} min` };
110
+ await onPoll();
111
+ await sleep(ciPollDelayMs(i, random));
112
+ }
113
+ }
@@ -0,0 +1,95 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { parseVerify, matchesGlob, skipsCi, capLogs, runCiVerify, DEFAULT_SKIP_PATHS, LOG_CAP } from "./verify.mjs";
6
+ import { precheck } from "./conformance.mjs";
7
+
8
+ const HEAD = "abc123abc123abc123";
9
+ /** Fake io + clock: `runs` is a list of successive workflowRuns answers (the last repeats); `checks` likewise. */
10
+ function fake(o = {}) {
11
+ const f = { clock: 0, dispatched: [], logsFor: [], pushes: 0, runsQ: [...(o.runs ?? [])], checksQ: [...(o.checks ?? [])] };
12
+ f.io = {
13
+ dispatchWorkflow: async (a) => { if (o.dispatchError) throw Object.assign(new Error("HTTP 422"), { stderr: o.dispatchError }); f.dispatched.push(a); },
14
+ workflowRuns: async () => (f.runsQ.length > 1 ? f.runsQ.shift() : f.runsQ[0] ?? []),
15
+ commitChecks: async () => (f.checksQ.length > 1 ? f.checksQ.shift() : f.checksQ[0] ?? []),
16
+ failedLogs: async (id) => { f.logsFor.push(String(id)); return o.log ?? `log of ${id}`; },
17
+ };
18
+ f.args = (extra = {}) => ({ io: f.io, branch: "feat/issue-7-lean", head: HEAD, changedFiles: ["src/a.ts", ".forge-lean/result.json"], verify: parseVerify({ mode: "ci", workflow: "verify.yml", paths_input: "changed_paths" }),
19
+ ciMaxMinutes: 30, now: () => f.clock, sleep: async (ms) => { f.clock += ms; }, random: () => 0.5, push: async () => { f.pushes++; }, ...extra });
20
+ return f;
21
+ }
22
+
23
+ describe("verify-by-CI config (#4167)", () => {
24
+ it("parseVerify defaults to local and rejects invalid values", () => {
25
+ assert.deepEqual(parseVerify({}), { mode: "local", workflow: null, pathsInput: null, skipPaths: [...DEFAULT_SKIP_PATHS], maxRounds: 2 });
26
+ const v = parseVerify({ mode: "ci", workflow: ".github/workflows/verify.yml", paths_input: "changed_paths", skip_paths: "*.md, docs/** ,", max_rounds: "3" });
27
+ assert.deepEqual(v, { mode: "ci", workflow: ".github/workflows/verify.yml", pathsInput: "changed_paths", skipPaths: ["*.md", "docs/**"], maxRounds: 3 });
28
+ assert.equal(parseVerify({ mode: "remote" }).mode, "local");
29
+ assert.equal(parseVerify({ workflow: "x; rm -rf /" }).workflow, null);
30
+ assert.equal(parseVerify({ max_rounds: "-1" }).maxRounds, 2);
31
+ assert.equal(parseVerify({ max_rounds: "0" }).maxRounds, 0);
32
+ assert.equal(parseVerify({ mode: "hybrid" }).mode, "hybrid");
33
+ });
34
+ it("globs: a pattern without / matches the basename anywhere; ** spans directories", () => {
35
+ assert.ok(matchesGlob("README.md", "*.md")); assert.ok(matchesGlob("pkg/a/CHANGELOG.md", "*.md"));
36
+ assert.ok(matchesGlob("docs/guide/x.png", "docs/**")); assert.ok(!matchesGlob("src/docs/x.ts", "docs/**"));
37
+ assert.ok(!matchesGlob("src/a.ts", "*.md")); assert.ok(!matchesGlob("a.mdx.ts", "*.mdx"));
38
+ });
39
+ it("a docs-only change skips CI; one code file does not; .forge-lean files are ignored", () => {
40
+ assert.ok(skipsCi(["README.md", "docs/a.md", ".forge-lean/result.json"]));
41
+ assert.ok(!skipsCi(["README.md", "src/a.ts"]));
42
+ assert.ok(!skipsCi(["poetry.lock"]), "a lock file is never docs-only");
43
+ });
44
+ it("capLogs keeps the tails within the cap", () => {
45
+ const out = capLogs([{ job: "a", log: "x".repeat(50000) + "END-A" }, { job: "b", log: "short" }]);
46
+ assert.ok(out[0].log.endsWith("END-A")); assert.ok(out[0].log.length <= LOG_CAP / 2 + 1); assert.equal(out[1].log, "short");
47
+ });
48
+ it("precheck: a CI-verified head covers validate ids left to CI; a recorded failure still fails", () => {
49
+ const contract = { requirements: [], deliverables: [{ id: "D1", path: "src/a.ts", change: "x", requirements: [] }], validate: [{ id: "V1", command: "tsc" }, { id: "V2", command: "lint" }] };
50
+ const result = { validation: [{ id: "V1", command: "tsc", ci: true }, { id: "V2", command: "lint", exit: 0 }] };
51
+ const kinds = (r, ciVerified) => precheck({ contract, result: r, changedFiles: ["src/a.ts"], ciVerified }).failures.map((f) => f.kind);
52
+ assert.deepEqual(kinds(result, false), ["validate_missing"]);
53
+ assert.deepEqual(kinds(result, true), []);
54
+ assert.deepEqual(kinds({ validation: [] }, true), []);
55
+ assert.deepEqual(kinds({ validation: [{ id: "V1", exit: 0 }, { id: "V2", exit: 1 }] }, true), ["validate_failed"]);
56
+ });
57
+ });
58
+
59
+ describe("runCiVerify (#4167)", () => {
60
+ it("docs-only: skipped without a push or any CI call", async () => {
61
+ const f = fake();
62
+ const r = await runCiVerify(f.args({ changedFiles: ["README.md", "docs/x.md"] }));
63
+ assert.equal(r.result, "skipped"); assert.equal(f.pushes, 0); assert.equal(f.dispatched.length, 0);
64
+ });
65
+ it("workflow: pushes, dispatches on the branch with the changed paths, waits for the run of this head, passes", async () => {
66
+ const f = fake({ runs: [[], [{ databaseId: 9, headSha: HEAD, status: "in_progress" }], [{ databaseId: 9, headSha: HEAD, status: "completed", conclusion: "success", url: "u9" }]] });
67
+ const r = await runCiVerify(f.args());
68
+ assert.equal(r.result, "pass"); assert.equal(r.runId, 9); assert.equal(f.pushes, 1);
69
+ assert.deepEqual(f.dispatched, [{ workflow: "verify.yml", ref: "feat/issue-7-lean", inputs: { changed_paths: "src/a.ts" } }]);
70
+ });
71
+ it("workflow: a red run returns the failed-job log; excluded (already judged) runs are never reused", async () => {
72
+ const f = fake({ runs: [[{ databaseId: 1, headSha: HEAD, status: "completed", conclusion: "failure" }, { databaseId: 2, headSha: HEAD, status: "completed", conclusion: "failure", name: "verify", url: "u2" }]], log: "poetry.lock is out of date" });
73
+ const r = await runCiVerify(f.args({ exclude: [1] }));
74
+ assert.equal(r.result, "fail"); assert.equal(r.runId, 2); assert.deepEqual(f.logsFor, ["2"]);
75
+ assert.deepEqual(r.failures, [{ job: "verify", url: "u2", log: "poetry.lock is out of date" }]);
76
+ });
77
+ it("workflow: a refused dispatch, or no run within the grace period, is unavailable (local fallback)", async () => {
78
+ assert.equal((await runCiVerify(fake({ dispatchError: "workflow does not have workflow_dispatch trigger" }).args())).result, "unavailable");
79
+ const r = await runCiVerify(fake({ runs: [[{ databaseId: 5, headSha: "other", status: "completed", conclusion: "success" }]] }).args());
80
+ assert.equal(r.result, "unavailable"); assert.match(r.detail, /no verify\.yml run/);
81
+ });
82
+ it("slow CI is a timeout at the budget, never a failure", async () => {
83
+ const f = fake({ runs: [[{ databaseId: 3, headSha: HEAD, status: "queued" }]] });
84
+ const r = await runCiVerify(f.args({ ciMaxMinutes: 10 }));
85
+ assert.equal(r.result, "timeout"); assert.ok(f.clock >= 10 * 60000);
86
+ });
87
+ it("no workflow: waits on the head's push-triggered checks and fetches each failed Actions run's log once", async () => {
88
+ const f = fake({ checks: [[{ name: "lint", state: "pending" }], [{ name: "lint", state: "success" }, { name: "test", state: "failure", url: "https://github.com/o/r/actions/runs/55/job/1" }, { name: "types", state: "failure", url: "https://github.com/o/r/actions/runs/55/job/2" }]] });
89
+ const r = await runCiVerify(f.args({ verify: parseVerify({ mode: "ci" }) }));
90
+ assert.equal(r.result, "fail"); assert.deepEqual(f.logsFor, ["55"]); assert.equal(f.dispatched.length, 0);
91
+ assert.deepEqual(r.failures.map((x) => [x.job, Boolean(x.log)]), [["test", true], ["types", false]]);
92
+ const g = fake({ checks: [[]] });
93
+ assert.equal((await runCiVerify(g.args({ verify: parseVerify({ mode: "ci" }) }))).result, "unavailable");
94
+ });
95
+ });
package/commands/adopt.md CHANGED
@@ -421,6 +421,9 @@ Where `{reason}` is a brief signal: "confirmed bug + file refs", "P1 + acceptanc
421
421
 
422
422
  4. To re-run adoption on newly added issues:
423
423
  \`/adopt\` (already-labeled issues are skipped automatically)
424
+
425
+ {If the repo's boundary config contains an allow-all rule (check as in `/forgedock-init` 2G: eslint-plugin-boundaries `from: [X], allow: ['*']`, dependency-cruiser with no `forbidden` rules, Nx `onlyDependOnLibsWithTags: ['*']`), add once:}
426
+ 5. Your boundary config allows every package to import every package ({FILE}: {RULE}). Consider tightening it per package; core work-on only reports import growth along allowed edges.
424
427
  ```
425
428
 
426
429
  ---
package/commands/audit.md CHANGED
@@ -261,14 +261,9 @@ Classify where the breakdown occurred. Use these categories:
261
261
  - **Root cause**: The single most impactful failure point. If THIS had worked, the problem wouldn't have reached production.
262
262
  - **Contributing factors**: Other pipeline links that could have caught it as a safety net but didn't.
263
263
 
264
- ### 3D: Cross-reference with Pipeline Hardening Principles
264
+ ### 3D: Cross-reference with the hardening layers
265
265
 
266
- Before proposing a fix, check which layer it belongs to (from CLAUDE.md):
267
-
268
- - **Quality gate** → static analysis, grep-catchable patterns
269
- - **Review agents** → semantic reasoning, library API contracts, cross-service interactions
270
- - **Domain detection** → broad category matching, not per-bug-instance
271
- - **Builder rules** → prevention at write time, simple memorable rules
266
+ Before proposing a fix, pick its layer from `NORTHSTAR.md` § Where a fix belongs: the earliest, cheapest layer that would have stopped it (issue spec/plan rule → builder rule → deterministic check → domain detection → review prompt line → new agent, last resort).
272
267
 
273
268
  **Do NOT propose a quality-gate grep check for a reasoning failure. Do NOT propose a review-agent prompt change for a pattern grep could catch.**
274
269
 
@@ -368,7 +363,7 @@ Files that need changes:
368
363
  - If tightening a review agent: describe the reasoning step to add
369
364
  - If adding context/architect guidance: describe the lookup or trace step}
370
365
 
371
- **Hardening layer justification** (per CLAUDE.md Pipeline Hardening Principles):
366
+ **Hardening layer justification** (per `NORTHSTAR.md` § Where a fix belongs):
372
367
  {Explain why this fix belongs in the proposed layer and not another}
373
368
 
374
369
  ### Related Prior Work