forgedock 1.36.0 → 1.37.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 (112) hide show
  1. package/bin/bot-token-status.mjs +26 -0
  2. package/bin/engine/events.mjs +10 -1
  3. package/bin/engine/events.test.mjs +15 -1
  4. package/bin/engine/outcome-codes.mjs +39 -1
  5. package/bin/engine/phases.mjs +65 -5
  6. package/bin/engine/telemetry-seam.mjs +1 -1
  7. package/bin/engine/telemetry-seam.test.mjs +4 -0
  8. package/bin/engine-cli.mjs +268 -37
  9. package/bin/engine.mjs +3 -1
  10. package/bin/forgedock.mjs +108 -5
  11. package/bin/labels.json +50 -0
  12. package/bin/lean/batch.mjs +10 -4
  13. package/bin/lean/batch.test.mjs +75 -1
  14. package/bin/lean/conformance.e2e.test.mjs +1 -1
  15. package/bin/lean/conformance.mjs +125 -11
  16. package/bin/lean/conformance.test.mjs +122 -1
  17. package/bin/lean/decisions.mjs +181 -0
  18. package/bin/lean/decisions.test.mjs +71 -0
  19. package/bin/lean/effects.mjs +29 -14
  20. package/bin/lean/execution.mjs +60 -16
  21. package/bin/lean/execution.test.mjs +59 -2
  22. package/bin/lean/gatewatch.mjs +31 -0
  23. package/bin/lean/gatewatch.test.mjs +59 -0
  24. package/bin/lean/guardgate.mjs +170 -0
  25. package/bin/lean/guardgate.test.mjs +153 -0
  26. package/bin/lean/guards.mjs +243 -0
  27. package/bin/lean/guards.test.mjs +177 -0
  28. package/bin/lean/heavy.mjs +54 -8
  29. package/bin/lean/heavy.test.mjs +74 -1
  30. package/bin/lean/hostkill.mjs +1 -1
  31. package/bin/lean/integration.mjs +276 -0
  32. package/bin/lean/integration.test.mjs +189 -0
  33. package/bin/lean/lean.mjs +134 -28
  34. package/bin/lean/lean.test.mjs +239 -22
  35. package/bin/lean/lifecycle.mjs +129 -0
  36. package/bin/lean/lifecycle.test.mjs +118 -0
  37. package/bin/lean/light.mjs +1 -1
  38. package/bin/lean/milestonesync.mjs +130 -0
  39. package/bin/lean/milestonesync.test.mjs +133 -0
  40. package/bin/lean/orchestrate.mjs +83 -15
  41. package/bin/lean/parked-sweep.mjs +45 -0
  42. package/bin/lean/parked-sweep.test.mjs +71 -0
  43. package/bin/lean/postdeploy-check.mjs +38 -6
  44. package/bin/lean/postdeploy-check.test.mjs +62 -2
  45. package/bin/lean/postdeploy-monitor.mjs +12 -2
  46. package/bin/lean/postdeploy-monitor.test.mjs +27 -1
  47. package/bin/lean/prefilter.mjs +83 -23
  48. package/bin/lean/prefilter.test.mjs +73 -1
  49. package/bin/lean/probe.mjs +2 -2
  50. package/bin/lean/probe.test.mjs +1 -1
  51. package/bin/lean/prompts.mjs +15 -9
  52. package/bin/lean/resume.test.mjs +1 -1
  53. package/bin/lean/retry.mjs +28 -0
  54. package/bin/lean/sandbox-provider.contract.test.mjs +8 -3
  55. package/bin/lean/sandbox-remote.mjs +4 -4
  56. package/bin/lean/schedd.mjs +53 -9
  57. package/bin/lean/schedd.test.mjs +77 -5
  58. package/bin/lean/sequence.mjs +24 -0
  59. package/bin/lean/sequence.test.mjs +31 -1
  60. package/bin/lean/signoff.mjs +66 -0
  61. package/bin/lean/signoff.test.mjs +102 -0
  62. package/bin/lean/train.mjs +177 -0
  63. package/bin/lean/train.test.mjs +113 -0
  64. package/bin/pool/cli.mjs +2 -0
  65. package/bin/pool/health.mjs +30 -0
  66. package/bin/review-context.mjs +298 -0
  67. package/bin/runner.mjs +26 -3
  68. package/bin/stats/compounding.mjs +157 -0
  69. package/bin/stats/compounding.test.mjs +74 -0
  70. package/bin/stats/index.mjs +21 -2
  71. package/commands/bootstrap.md +10 -8
  72. package/commands/deploy-info.md +3 -0
  73. package/commands/issue.md +2 -0
  74. package/commands/milestone.md +43 -54
  75. package/commands/orchestrate/phase-4-execution.md +2 -0
  76. package/commands/orchestrate.md +8 -1
  77. package/commands/pipeline-status.md +20 -0
  78. package/commands/review-pr.md +1 -0
  79. package/commands/work-on/build/implement.md +1 -1
  80. package/commands/work-on/build.md +4 -2
  81. package/commands/work-on/investigate.md +3 -1
  82. package/commands/work-on/remediate.md +1 -1
  83. package/docs/CONFIG.md +71 -2
  84. package/docs/EVENTS.md +1 -4
  85. package/docs/OUTCOME-CODES.md +9 -1
  86. package/docs/STATS.md +20 -0
  87. package/docs/site/start-a-new-project.md +1 -1
  88. package/docs/spec/label-state-machine.md +6 -0
  89. package/package.json +1 -1
  90. package/pool/install/install-agent.sh +4 -2
  91. package/pool/install/install-router.sh +4 -2
  92. package/pool/install/install-schedd.sh +28 -0
  93. package/pool/install/unit-hardening.sh +34 -0
  94. package/scripts/acceptance-red-green.sh +19 -2
  95. package/scripts/acceptance-red-green.test.sh +2 -0
  96. package/scripts/bootstrap-create.mjs +3 -2
  97. package/scripts/bootstrap-validate.mjs +31 -4
  98. package/scripts/file-deferred-followups.sh +285 -77
  99. package/scripts/file-deferred-followups.test.sh +111 -4
  100. package/scripts/hotspots.sh +23 -2
  101. package/scripts/hotspots.test.sh +9 -0
  102. package/scripts/plan-validate-live.mjs +111 -0
  103. package/scripts/readiness.mjs +141 -0
  104. package/scripts/review-prepare.sh +17 -1
  105. package/scripts/review-prepare.test.sh +17 -0
  106. package/scripts/settle-scope.mjs +74 -0
  107. package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
  108. package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
  109. package/scripts/wait-ci-green.sh +14 -3
  110. package/scripts/wait-ci-green.test.sh +3 -1
  111. package/templates/bootstrap/needs-human-issue.md +4 -1
  112. package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
@@ -28,6 +28,7 @@ export const MIN_QUOTE_CHARS = 12;
28
28
  /** A contract larger than this cannot be posted whole in one comment (the comment cap is 60k). */
29
29
  export const MAX_CONTRACT_CHARS = 40000;
30
30
  import { CHECK_ID_RE } from "./postdeploy-check.mjs";
31
+ import { normaliseDecision, deadAnchors } from "./decisions.mjs";
31
32
  const C = OUTCOME_CODES;
32
33
 
33
34
  export const norm = (s) => String(s ?? "").replace(/[`*_>]/g, "").replace(/\s+/g, " ").trim().toLowerCase();
@@ -234,6 +235,8 @@ export function validateContract(c, issueText, { repro = false } = {}) {
234
235
  if (TRACKED.includes(r?.disposition) && blank(r?.why)) problems.push(`${id}: ${r.disposition} needs a why`);
235
236
  if (r?.disposition === "post-deploy" && !CHECK_ID_RE.test(String(r?.check ?? ""))) problems.push(`${id}: post-deploy needs a check id (\`check\`, e.g. "login-smoke") naming an entry of .forge/post-deploy-checks.json on the default branch`);
236
237
  if (r?.disposition === "not-applicable" && blank(r?.evidence)) problems.push(`${id}: not-applicable needs file:line evidence`);
238
+ for (const m of bucketProblems(r)) problems.push(`${id}: ${m}`);
239
+ if (r?.class != null && !FOLLOWUP_CLASSES.includes(r.class)) problems.push(`${id}: class must be one of ${FOLLOWUP_CLASSES.join("|")}`);
237
240
  if (r?.verify != null && !VERIFY_MODES.includes(r.verify)) problems.push(`${id}: verify must be one of ${VERIFY_MODES.join("|")}`);
238
241
  if (isOutOfBand(r) && r?.disposition !== "deliver") problems.push(`${id}: verify out_of_band applies only to a deliver requirement`);
239
242
  else if (isOutOfBand(r) && blank(r?.evidence)) problems.push(`${id}: verify out_of_band needs evidence naming the job or workflow that measures it (or set verify "in_lane" if this change can check it)`);
@@ -268,7 +271,8 @@ export function validateContract(c, issueText, { repro = false } = {}) {
268
271
  /** forge#4519: a shell pattern as in scripts/hotspots.sh `case` (`*` crosses `/`, `?` one character), anchored. */
269
272
  const shellGlobRe = (g) => new RegExp(`^${String(g).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
270
273
 
271
- const AMEND_KINDS = ["add-deliverable", "add-requirement", ...TRACKED, "amend-requirement"];
274
+ const AMEND_KINDS = ["add-deliverable", "add-requirement", ...TRACKED, "amend-requirement", "supersedes", "decision"];
275
+ const GUARD_ID_RE = /^#?\d+$/;
272
276
  const AMEND_SLIPS = { "amend-requirement": "defer", limitation: "defer" };
273
277
 
274
278
  /** forge#4574: common builder slips: `type` for `kind`, amend-requirement/limitation as defer, an `R#` in `id` as the requirement. */
@@ -282,11 +286,26 @@ function normalizeAmendment(a) {
282
286
  return n;
283
287
  }
284
288
 
289
+ const REF_SEP_RE = /\s*(?:[/,;&+]|\band\b)\s*/i, REF_RANGE_RE = /^R(\d+)\s*[-\u2013\u2014]\s*R?(\d+)$/i, REF_MAX = 50;
290
+
291
+ /** forge#4655: a slash/comma-joined or ranged requirement reference (`R2/R3`, `R2, R3`, `R2–R3`) as its individual ids; a single id or anything else is returned as is. */
292
+ function splitRequirementRefs(ref) {
293
+ if (typeof ref !== "string") return [ref];
294
+ const ids = [];
295
+ for (const part of ref.trim().split(REF_SEP_RE)) {
296
+ const m = REF_RANGE_RE.exec(part);
297
+ if (m && +m[1] <= +m[2] && +m[2] - +m[1] < REF_MAX) for (let i = +m[1]; i <= +m[2]; i++) ids.push(`R${i}`);
298
+ else ids.push(part);
299
+ }
300
+ return ids.length > 1 ? ids : [ref];
301
+ }
302
+
285
303
  /** The rejection reason, naming the valid kinds or the missing field so the builder's correction round can fix it. */
286
304
  function amendmentProblem(a, tag) {
287
305
  const kind = a?.kind;
288
306
  const need = kind === "add-deliverable" ? "needs a path" : kind === "add-requirement" ? "needs requirement {id, quote}"
289
- : TRACKED.includes(kind) ? "needs requirement: \"R#\"" : kind === "amend-requirement" ? "needs requirement: \"R#\" and text (the revised wording)" : null;
307
+ : kind === "supersedes" ? "needs guard: \"#N\" (the guarded issue)" : TRACKED.includes(kind) ? "needs requirement: \"R#\"" : kind === "amend-requirement" ? "needs requirement: \"R#\" and text (the revised wording)"
308
+ : kind === "decision" ? "needs decision {choice, anchors: [paths or globs], symbols?, supersedes?: decision id}" : null;
290
309
  const why = need ? `amendment kind '${kind}' ${need}` : `unknown kind '${kind ?? ""}'; valid kinds are ${AMEND_KINDS.join(", ")}`;
291
310
  return `unknown or incomplete amendment: ${why}: ${tag}`;
292
311
  }
@@ -296,14 +315,25 @@ function amendmentProblem(a, tag) {
296
315
  * defer|post-deploy {requirement: id}. Every amendment needs a why. Returns {contract, applied[], toTrack[], problems[]}.
297
316
  * `generatedPaths` (forge#4519, forge.yaml maintenance.generated_paths via scripts/hotspots.sh): an add-deliverable on a
298
317
  * matching path is refused; the repo's generator or bot owns that file.
318
+ * `decision` {decision:{choice, anchors, symbols?, supersedes?}} (#4695): every anchor must match a file of `baseFiles` (the
319
+ * base tree; null skips the check); `supersedes` names the decision whose engine-authored preserve row it replaces and
320
+ * drops that row; returns the records in `decisions`.
299
321
  */
300
- export function applyAmendments(frozen, amendments, issueText = null, { generatedPaths = [] } = {}) {
322
+ export function applyAmendments(frozen, amendments, issueText = null, { generatedPaths = [], baseFiles = null } = {}) {
301
323
  const c = structuredClone(frozen);
302
324
  c.deliverables = c.deliverables ?? [];
303
- const applied = [], toTrack = [], problems = [];
325
+ const applied = [], toTrack = [], problems = [], decisions = [];
304
326
  const genRes = (Array.isArray(generatedPaths) ? generatedPaths : []).filter((g) => typeof g === "string" && g).map(shellGlobRe);
327
+ const rows = [];
305
328
  for (const raw of Array.isArray(amendments) ? amendments : []) {
306
- const a = normalizeAmendment(raw);
329
+ const n = normalizeAmendment(raw);
330
+ if (n && (TRACKED.includes(n.kind) || n.kind === "amend-requirement") && typeof n.requirement === "string") {
331
+ const ids = splitRequirementRefs(n.requirement);
332
+ if (ids.length > 1) { for (const id of ids) rows.push({ a: { ...n, requirement: id }, raw }); continue; }
333
+ }
334
+ rows.push({ a: n, raw });
335
+ }
336
+ for (const { a, raw } of rows) {
307
337
  const tag = JSON.stringify(raw ?? null).slice(0, 400);
308
338
  if (blank(a?.why)) { problems.push(`amendment without why: ${tag}`); continue; }
309
339
  if (a.kind === "add-deliverable" && !blank(a.path)) {
@@ -325,6 +355,12 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
325
355
  if (tagsOutOfBand(r)) r.verify = "out_of_band"; // #4404: same freeze-time tag as the planned requirements
326
356
  c.requirements.push(r);
327
357
  if (TRACKED.includes(r.disposition)) toTrack.push(r);
358
+ } else if (a.kind === "supersedes" && GUARD_ID_RE.test(String(a.guard ?? "").trim())) {
359
+ // #4693: retires one guard's guard_broken for this lane; the reviewer judges it, the engine tells the guarded issue after merge
360
+ const guard = `#${String(a.guard).trim().replace(/^#/, "")}`;
361
+ c.supersedes = [...(c.supersedes ?? []).filter((s) => s.guard !== guard), { guard, why: String(a.why).trim() }];
362
+ applied.push({ kind: a.kind, target: guard, why: String(a.why).trim() });
363
+ continue;
328
364
  } else if (a.kind === "amend-requirement" && !blank(a.requirement)) {
329
365
  // #4594: the requirement stays deliver and keeps its verbatim quote; the reviewer judges it against amendment.text
330
366
  const r = c.requirements.find((x) => x.id === a.requirement);
@@ -334,14 +370,24 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
334
370
  const r = c.requirements.find((x) => x.id === a.requirement);
335
371
  if (!r) { problems.push(`amendment names unknown requirement ${a.requirement}`); continue; }
336
372
  if (!TRACKED.includes(r.disposition)) {
373
+ if (a.bucket != null) r.bucket = a.bucket; // #4724
337
374
  if (a.kind === "post-deploy" && !CHECK_ID_RE.test(String(a.check ?? r.check ?? ""))) { problems.push(`amendment post-deploy ${a.requirement} needs a check id (check)`); continue; }
338
- r.disposition = a.kind; r.why = a.why; r.amended = true; toTrack.push(r);
375
+ r.disposition = a.kind; r.why = a.why; r.amended = true; r.bucket ??= "post-deploy"; toTrack.push(r); // #4724: an amendment without a bucket defaults to post-deploy
339
376
  if (a.kind === "post-deploy") r.check = a.check ?? r.check; // #4626
340
377
  }
378
+ } else if (a.kind === "decision") {
379
+ const d = normaliseDecision({ ...a.decision, why: a.decision?.why ?? a.why });
380
+ if (!d) { problems.push(amendmentProblem(a, tag)); continue; }
381
+ const dead = Array.isArray(baseFiles) ? deadAnchors(d.anchors, baseFiles) : [];
382
+ if (dead.length) { problems.push(`decision anchor(s) ${dead.join(", ")} match no file on the base; anchor a path that exists there`); continue; }
383
+ if (!blank(a.decision?.supersedes)) c.preserve = (c.preserve ?? []).filter((p) => p.decision !== a.decision.supersedes && p.id !== a.decision.supersedes);
384
+ decisions.push(a.decision?.supersedes ? { ...d, supersedes: String(a.decision.supersedes) } : d);
385
+ applied.push({ kind: a.kind, target: d.choice.slice(0, 60), why: String(a.why).trim() });
386
+ continue;
341
387
  } else { problems.push(amendmentProblem(a, tag)); continue; }
342
388
  applied.push({ kind: a.kind, target: a.path ?? a.requirement?.id ?? a.requirement, why: String(a.why).trim() });
343
389
  }
344
- return { contract: c, applied, toTrack, problems };
390
+ return { contract: c, applied, toTrack, problems, decisions };
345
391
  }
346
392
 
347
393
  const PY_MISSING_RE = /ModuleNotFoundError: No module named '([^']+)'|ImportError: No module named '?([\w.]+)'?/;
@@ -393,7 +439,7 @@ export function repoTracks(paths) {
393
439
  * `ciCovered` (#4527): the validate ids accepted only because the head is CI-verified (no local result, or a missing host
394
440
  * tool), for the trajectory; always an array.
395
441
  */
396
- export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null, validateRuns = null, repoHas = () => false, frozen = null, checkIds = null }) {
442
+ export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null, validateRuns = null, repoHas = () => false, frozen = null, checkIds = null, guardFailures = null }) {
397
443
  const failures = [], superseded = [], ciCovered = [];
398
444
  // #4626: a post-deploy check id must be on the default branch or added by this PR (`checkIds`: ids of both checks files; null = not resolved)
399
445
  if (checkIds) for (const r of contract.requirements ?? []) {
@@ -435,6 +481,9 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
435
481
  }
436
482
  failures.push(...hotspotGrowth({ contract, result, hotspots, changedFiles }));
437
483
  if (repro) failures.push(...reproFailures(contract, repro));
484
+ // #4693: a broken guard fails unless the contract carries a supersedes amendment for it
485
+ const retired = new Set((contract.supersedes ?? []).map((s) => s.guard));
486
+ for (const f of guardFailures ?? []) if (!retired.has(f.guard)) failures.push(f);
438
487
  return { ok: failures.length === 0, failures, superseded, ciCovered };
439
488
  }
440
489
 
@@ -632,6 +681,26 @@ export function checkCompleteness(rv, { contract, changedFiles, issueText, pathE
632
681
  return { complete: p.length === 0, problems: p, missing: [...ids] };
633
682
  }
634
683
 
684
+
685
+ /** #4726: a requirement quote that carries a `[type:e2e]` criterion. */
686
+ const wantsE2e = (r) => /\[type:e2e\]/i.test(String(r?.quote ?? ""));
687
+ const E2E_CMD_RE = /\b(?:playwright|cypress|e2e|live[- ]stack|wdio)\b/i;
688
+ /**
689
+ * #4726: whether a validate command runs a real end-to-end check: an entry of `verify.e2e_checks`, a file of the e2e spec set
690
+ * (`e2e.isSpec`), or Playwright/Cypress/e2e/live-stack. A unit test file with stubbed I/O is not one.
691
+ */
692
+ export function isE2eCommand(command, e2e = null) {
693
+ const cmd = String(command ?? "");
694
+ if ((e2e?.checks ?? []).some((c) => c && cmd.includes(c))) return true;
695
+ if (typeof e2e?.isSpec === "function" && cmd.split(/\s+/).some((t) => t && e2e.isSpec(t.replace(/^["']+|["']+$/g, "")))) return true;
696
+ return E2E_CMD_RE.test(cmd);
697
+ }
698
+ /** #4726: true when a V-ref named in the row's `validated_by` resolves to a contract validate command that is a real e2e check. */
699
+ export function e2eVerified(row, contract, e2e = null) {
700
+ const by = String(row?.validated_by ?? "");
701
+ const refs = (contract?.validate ?? []).filter((v) => v?.id && new RegExp(`(?:^|[^A-Za-z0-9_])${v.id}(?![A-Za-z0-9_])`).test(by));
702
+ return refs.some((v) => isE2eCommand(v.command, e2e)) || (!refs.length && isE2eCommand(by, e2e));
703
+ }
635
704
  /**
636
705
  * The engine's decision from the reviewer's rows. state: conforms | nonconforming | review.unparseable | review.incomplete
637
706
  * | review.inconsistent. failures[] are {code, detail}; `inconsistent` marks a CONFORMS verdict whose rows fail;
@@ -653,6 +722,8 @@ export function decide(raw, ctx) {
653
722
  if (row.instrumented !== true) f.push({ code: C.CONFORMANCE_UNIMPLEMENTED, detail: `${r.id} verified out of band${r.evidence ? ` by ${r.evidence}` : ""}: instrumentation missing${row.note ? `: ${row.note}` : ""}${row.evidence ? ` (${row.evidence})` : ""}` });
654
723
  } else if (r.disposition === "deliver" && row.status !== "implemented") {
655
724
  f.push({ code: ["deferred", "post-deploy"].includes(row.status) ? C.CONFORMANCE_UNTRACKED_DEFERRAL : C.CONFORMANCE_UNIMPLEMENTED, detail: `${r.amendment?.text ? `${r.id} amendment not accepted (amended to "${String(r.amendment.text).slice(0, 200)}"): ` : `${r.id} `}${row.status}${row.note ? `: ${row.note}` : ""}${row.evidence ? ` (${row.evidence})` : ""}` });
725
+ } else if (r.disposition === "deliver" && wantsE2e(r) && !e2eVerified(row, ctx.contract, ctx.e2e)) {
726
+ f.push({ code: C.CONFORMANCE_E2E_UNVERIFIED, detail: `${r.id} is a [type:e2e] criterion but ${row.validated_by || "its validated_by"} is not a real end-to-end check (the repo's e2e set, Playwright/browser/live stack); run one, or mark ${r.id} post-deploy with a tracked follow-up` });
656
727
  }
657
728
  if (TRACKED.includes(r.disposition) && row.deferral_ok === false) f.push({ code: C.CONFORMANCE_UNJUSTIFIED_DEFERRAL, detail: `${r.id} ${r.disposition} is not justified${row.note ? `: ${row.note}` : ""}; implement it` });
658
729
  }
@@ -752,11 +823,54 @@ export function formatShadowSummary(rows) {
752
823
  return `conformance: ${sh.length} shadow lane${sh.length === 1 ? "" : "s"}, would block ${would} (${pct(would)}), false-block ${falseB} (${pct(falseB)}: would-block on an incomplete/inconsistent/unparseable review); risk-path enforced: ${risk}`;
753
824
  }
754
825
 
755
- /** One line per requirement for the follow-up script: a stable key (id + quote) and a why. Tabs and newlines removed. */
756
- export function followupItems(reqs) {
826
+ /** #4664: follow-up classes, one issue each per parent (scripts/file-deferred-followups.sh); post-deploy is one rollup per release lane (#4724). */
827
+ export const FOLLOWUP_CLASSES = ["engineering", "post-deploy", "external", "ops"];
828
+ /** #4724: the bucket every deferred row gets at contract time. `date:<YYYY-MM-DD>` and `owner:<action>` carry a value. */
829
+ export const BUCKETS = Object.freeze(["post-deploy", "verify-now", "date", "owner", "new-issue"]);
830
+ const DATE_BUCKET_RE = /^date:(\d{4})-(\d{2})-(\d{2})$/;
831
+ /** The kind of a bucket (`date:2026-12-01` -> `date`), or null when absent or unknown. */
832
+ export const bucketKind = (b) => { const k = String(b ?? "").split(":")[0]; return BUCKETS.includes(k) ? k : null; };
833
+ /** #4724: problems with a deferred row's bucket (a defer or post-deploy row needs exactly one; owner actions are never post-deploy). */
834
+ export function bucketProblems(r) {
835
+ const b = r?.bucket;
836
+ if (!TRACKED.includes(r?.disposition)) return b == null ? [] : ["bucket applies only to a defer or post-deploy requirement"];
837
+ if (b == null || b === "") return [`${r.disposition} needs a bucket (${BUCKETS.join("|")}; \`date:<YYYY-MM-DD>\`, \`owner:<action>\`)`];
838
+ const k = bucketKind(b), out = [];
839
+ if (!k || (k !== "date" && k !== "owner" && b !== k)) out.push(`bucket '${b}' must be post-deploy, verify-now, date:<YYYY-MM-DD>, owner:<action> or new-issue`);
840
+ else if (k === "date") {
841
+ const m = DATE_BUCKET_RE.exec(String(b)), d = m && new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
842
+ if (!m || d.getUTCFullYear() !== +m[1] || d.getUTCMonth() !== +m[2] - 1 || d.getUTCDate() !== +m[3]) out.push(`bucket '${b}' must be an absolute date:<YYYY-MM-DD>, not a relative wait`);
843
+ } else if (k === "owner" && blank(String(b).slice(6))) out.push("bucket owner needs the action: owner:<action>");
844
+ if (r.disposition === "post-deploy" && (k === "owner" || k === "new-issue")) out.push(`a ${k} item is never post-deploy: use disposition defer`);
845
+ if (k === "owner" && r.class != null && r.class !== "external" && r.class !== "ops") out.push("an owner bucket belongs in class external or ops, never post-deploy");
846
+ return out;
847
+ }
848
+ /** The follow-up class of a requirement: owner -> external, new-issue -> engineering, other buckets post-deploy; else its `class`, else post-deploy for that disposition, else engineering. */
849
+ export function followupClass(r) {
850
+ const k = bucketKind(r?.bucket);
851
+ if (k === "owner") return r?.class === "ops" ? "ops" : "external";
852
+ if (k === "new-issue") return r?.class && r.class !== "post-deploy" && FOLLOWUP_CLASSES.includes(r.class) ? r.class : "engineering";
853
+ if (k) return "post-deploy";
854
+ return FOLLOWUP_CLASSES.includes(r?.class) ? r.class : r?.disposition === "post-deploy" ? "post-deploy" : "engineering";
855
+ }
856
+ /** #4724: deferred items per bucket kind (`none` = no bucket), for the batch telemetry line. */
857
+ export const bucketTally = (reqs) => (reqs ?? []).reduce((t, r) => { const k = bucketKind(r?.bucket) ?? "none"; t[k] = (t[k] ?? 0) + 1; return t; }, {});
858
+
859
+ /**
860
+ * One line per requirement for the follow-up script: a stable key (id + quote), a why and a class. Tabs and newlines removed.
861
+ * #4724: a bucketed row's why carries `[bucket:<kind>]` (the script counts and routes on it) and a date bucket's `due:<date>`
862
+ * plus `from:<date>` (the contract-time anchor, `now` ms) that the gate re-anchors when the parent ships later.
863
+ */
864
+ export function followupItems(reqs, { now = Date.now() } = {}) {
757
865
  const flat = (s) => String(s ?? "").replace(/[\t\r\n`]+/g, " ").replace(/\s+/g, " ").trim();
758
866
  const check = (r) => (r.disposition === "post-deploy" && r.check ? `[check:${flat(r.check)}] ` : ""); // #4626: the follow-up item names its check
759
- return reqs.map((r) => ({ id: r.id, key: flat(`${r.id}: ${r.quote}`).slice(0, 240).trim(), why: flat(`${r.disposition}: ${check(r)}${r.why ?? ""}`).slice(0, 400).trim() }));
867
+ return reqs.map((r) => {
868
+ const k = bucketKind(r.bucket);
869
+ const tag = k ? `[bucket:${k}] ` : "";
870
+ const due = k === "date" ? `due:${String(r.bucket).slice(5)} from:${new Date(now).toISOString().slice(0, 10)} ` : "";
871
+ const head = k === "owner" ? `owner: ${flat(String(r.bucket).slice(6))}. ` : k && k !== "new-issue" ? "post-deploy: " : `${r.disposition}: `;
872
+ return { id: r.id, class: followupClass(r), key: flat(`${r.id}: ${r.quote}`).slice(0, 240).trim(), why: flat(`${head}${tag}${check(r)}${due}${r.why ?? ""}`).slice(0, 400).trim() };
873
+ });
760
874
  }
761
875
 
762
876
  /** #4388: the follow-up script's `key\tissue` lines as {[item.key]: issue}, matched on trimmed keys (the script trims what it echoes); unmatched items omitted. */
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
  import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
- import { validateContract, applyAmendments, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, isInvestigation, formatShadowSummary, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, explainFailures, missingTool, repoTracks, tmpdirInputs, isScopeCheck, isPureScopeCheck, dropScopeValidates, tagOutOfBand, outOfBandRequirements, isOutOfBand, followupMap, VERIFY_MODES, OUT_OF_BAND_RE } from "./conformance.mjs";
5
+ import { validateContract, applyAmendments, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, isInvestigation, formatShadowSummary, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, explainFailures, missingTool, repoTracks, tmpdirInputs, isScopeCheck, isPureScopeCheck, dropScopeValidates, tagOutOfBand, outOfBandRequirements, isOutOfBand, followupMap, VERIFY_MODES, OUT_OF_BAND_RE, FOLLOWUP_CLASSES, followupClass, BUCKETS, bucketKind, bucketProblems, bucketTally, isE2eCommand, e2eVerified } from "./conformance.mjs";
6
6
 
7
7
  const ISSUE = "## Requirements\n1. The scrape route must release the spend reservation on 429.\n2. The search route must release the spend reservation on 429.\n\n- [ ] Billing pricing is unchanged.";
8
8
  const Q1 = "The scrape route must release the spend reservation on 429.";
@@ -60,6 +60,21 @@ describe("amendments", () => {
60
60
  });
61
61
  });
62
62
 
63
+ describe("#4655 multi-requirement amendment references", () => {
64
+ it("R1/R2, 'R1, R2' and an en-dash range split into one applied row per id", () => {
65
+ for (const requirement of ["R1/R2", "R1, R2", "R1\u2013R2", "R1 and R2"]) {
66
+ const a = applyAmendments(C, [{ kind: "defer", requirement, why: "w" }]);
67
+ assert.deepEqual(a.problems, [], requirement);
68
+ assert.deepEqual(a.toTrack.map((r) => r.id).sort(), ["R1", "R2"], requirement);
69
+ assert.deepEqual(a.applied.map((x) => x.target), ["R1", "R2"], requirement);
70
+ }
71
+ });
72
+ it("an unknown id inside a list is still a problem; scope-note stays an unknown kind", () => {
73
+ assert.match(applyAmendments(C, [{ kind: "defer", requirement: "R1/R9", why: "w" }]).problems[0], /unknown requirement R9/);
74
+ assert.match(applyAmendments(C, [{ kind: "scope-note", requirement: "R1", why: "w" }]).problems[0], /unknown kind 'scope-note'; valid kinds are/);
75
+ });
76
+ });
77
+
63
78
  describe("#4574 amendment slips", () => {
64
79
  it("type for kind, amend-requirement and limitation apply as defer; an R# id fills requirement", () => {
65
80
  const a = applyAmendments(C, [{ kind: "amend-requirement", id: "R2", why: "stored hashed" }, { id: "A1", requirement: "R1", type: "limitation", why: "plugin limit" }]);
@@ -115,6 +130,11 @@ describe("precheck (deterministic, no reviewer)", () => {
115
130
  const failed = precheck({ contract: C2, result: { ...res, validation: [{ id: "V1", exit: 1 }, { id: "V2", ci: true }, { id: "V3", exit: 0 }] }, changedFiles: files, ciVerified: true });
116
131
  assert.deepEqual(failed.failures.map((f) => f.kind), ["validate_failed"]); assert.deepEqual(failed.ciCovered, ["V2"]);
117
132
  });
133
+ it("#4708 a validate row carried forward across a CI fix round counts as recorded; a row absent outside a CI-verified head still fails", () => {
134
+ const carried = precheck({ contract: C, result: { ...res, validation: [{ id: "V1", exit: 0, carried: true }] }, changedFiles: files });
135
+ assert.deepEqual(codes(carried), []);
136
+ assert.deepEqual(precheck({ contract: C, result: { ...res, validation: [] }, changedFiles: files }).failures.map((f) => f.kind), ["validate_missing"]);
137
+ });
118
138
  it("#4323 missingTool: commands, python and node modules; paths, relative and repo-tracked names are not the environment", () => {
119
139
  assert.deepEqual(missingTool({ rc: 127, output: "sh: 1: jq: not found" }), { tool: "jq", kind: "command" });
120
140
  assert.deepEqual(missingTool({ rc: 127, output: "" }, { command: "uv run pytest -q" }), { tool: "uv", kind: "command" });
@@ -691,8 +711,109 @@ describe("post-deploy check ids (#4626)", () => {
691
711
  assert.ok(!precheck({ contract: c, result: res, changedFiles: files, checkIds: new Set(["login-smoke"]) }).failures.some((f) => f.kind === "check_unknown"));
692
712
  assert.ok(!precheck({ contract: c, result: res, changedFiles: files }).failures.some((f) => f.kind === "check_unknown"));
693
713
  });
714
+ it("#4664 followupItems carries the follow-up class; validateContract rejects an unknown class", () => {
715
+ const cls = (r) => followupItems([{ id: "R1", quote: Q1, why: "w", ...r }])[0].class;
716
+ assert.equal(cls({ disposition: "defer" }), "engineering");
717
+ assert.equal(cls({ disposition: "post-deploy", check: "x" }), "post-deploy");
718
+ assert.equal(cls({ disposition: "defer", class: "external" }), "external");
719
+ assert.equal(cls({ disposition: "defer", class: "bogus" }), "engineering");
720
+ assert.deepEqual(FOLLOWUP_CLASSES, ["engineering", "post-deploy", "external", "ops"]);
721
+ assert.equal(followupClass({ disposition: "post-deploy" }), "post-deploy"); assert.equal(followupClass({ class: "ops" }), "ops");
722
+ const v = validateContract({ requirements: [{ id: "R1", quote: Q1, disposition: "defer", why: "w", class: "bogus" }], deliverables: [], preserve: [], validate: [{ id: "V1", command: "true" }] }, Q1);
723
+ assert.ok(v.problems.some((x) => /R1: class must be one of/.test(x)), v.problems.join(";"));
724
+ });
694
725
  it("followupItems names the check in a post-deploy item", () => {
695
726
  const [i] = followupItems([{ id: "R1", quote: Q1, disposition: "post-deploy", why: "w", check: "login-smoke" }]);
696
727
  assert.match(i.why, /^post-deploy: \[check:login-smoke\] w$/);
697
728
  });
729
+ it("#4724 a deferred row needs exactly one valid bucket; owner actions are never post-deploy; dates are absolute", () => {
730
+ const contract = (r) => ({ requirements: [{ id: "R1", quote: Q1, why: "w", ...r }], deliverables: [], preserve: [], validate: [{ id: "V1", command: "true" }] });
731
+ const probs = (r) => validateContract(contract(r), Q1).problems.filter((x) => /^R1: .*(bucket|never post-deploy|owner)/.test(x));
732
+ assert.deepEqual(BUCKETS, ["post-deploy", "verify-now", "date", "owner", "new-issue"]);
733
+ assert.equal(probs({ disposition: "defer" }).length, 1); // missing bucket
734
+ for (const bucket of ["post-deploy", "verify-now", "new-issue", "date:2026-12-01", "owner:rotate the key"]) assert.deepEqual(probs({ disposition: "defer", bucket }), [], bucket);
735
+ for (const bucket of ["date:5 weeks after deploy", "date:2026-02-30", "date", "owner:", "owner", "later", "post-deploy:x"]) assert.equal(probs({ disposition: "defer", bucket }).length, 1, bucket);
736
+ assert.match(probs({ disposition: "post-deploy", check: "a-smoke", bucket: "owner:spend" })[0], /never post-deploy/);
737
+ assert.equal(probs({ disposition: "deliver", bucket: "post-deploy" }).length, 1);
738
+ assert.deepEqual(probs({ disposition: "deliver" }), []);
739
+ assert.deepEqual(bucketProblems({ disposition: "defer", bucket: "owner:x", class: "post-deploy" }).length, 1);
740
+ assert.equal(bucketKind("date:2026-12-01"), "date"); assert.equal(bucketKind("nope"), null);
741
+ });
742
+ it("#4724 the bucket decides the follow-up class and tags the item for the script and the gate", () => {
743
+ const one = (r) => followupItems([{ id: "R1", quote: Q1, disposition: "defer", why: "w", ...r }], { now: Date.UTC(2026, 9, 27) })[0];
744
+ assert.equal(followupClass({ disposition: "defer", bucket: "owner:spend" }), "external");
745
+ assert.equal(followupClass({ disposition: "defer", bucket: "owner:spend", class: "ops" }), "ops");
746
+ assert.equal(followupClass({ disposition: "defer", bucket: "owner:x", class: "post-deploy" }), "external");
747
+ assert.equal(followupClass({ disposition: "defer", bucket: "new-issue", class: "post-deploy" }), "engineering");
748
+ for (const bucket of ["post-deploy", "verify-now", "date:2026-12-01"]) assert.equal(followupClass({ disposition: "defer", bucket }), "post-deploy");
749
+ assert.equal(one({ bucket: "owner:rotate the key" }).why, "owner: rotate the key. [bucket:owner] w");
750
+ assert.equal(one({ bucket: "verify-now" }).why, "post-deploy: [bucket:verify-now] w");
751
+ assert.equal(one({ bucket: "date:2026-12-01" }).why, "post-deploy: [bucket:date] due:2026-12-01 from:2026-10-27 w");
752
+ assert.equal(one({ bucket: "new-issue" }).why, "defer: [bucket:new-issue] w");
753
+ assert.equal(one({}).why, "defer: w"); // an unbucketed (older) row is unchanged
754
+ });
755
+ it("#4724 bucketTally counts deferred items per bucket kind; an amendment may carry the bucket", () => {
756
+ assert.deepEqual(bucketTally([{ bucket: "date:2026-12-01" }, { bucket: "date:2026-12-02" }, { bucket: "owner:x" }, {}]), { date: 2, owner: 1, none: 1 });
757
+ const c = { requirements: [{ id: "R1", quote: Q1, disposition: "deliver" }], deliverables: [], preserve: [], validate: [] };
758
+ const r = applyAmendments(c, [{ kind: "defer", requirement: "R1", why: "w", bucket: "verify-now" }]);
759
+ assert.equal(r.contract.requirements[0].bucket, "verify-now"); assert.equal(r.toTrack.length, 1);
760
+ });
761
+ });
762
+
763
+ describe("#4726 a [type:e2e] requirement needs a real end-to-end validate", () => {
764
+ const Q = "Users can sign up and sign in with an emailed code alone [type:e2e]";
765
+ const mk = (cmd, quote = Q, extra = {}) => {
766
+ const contract = { ...C, requirements: [{ id: "R1", quote, disposition: "deliver", ...extra }, C.requirements[1]], validate: [{ id: "V1", command: cmd }] };
767
+ return { contract, ctx: { ...ctx, contract } };
768
+ };
769
+ const rv = (contract) => ({ ...good, issue_coverage: [{ quote: contract.requirements[0].quote, status: "covered" }, ...good.issue_coverage.slice(1)] });
770
+ const run = (cmd, quote, extra, e2e) => { const m = mk(cmd, quote, extra); return decide(rv(m.contract), { ...m.ctx, issueText: `${ISSUE}\n${m.contract.requirements[0].quote}`, e2e }); };
771
+ it("e2e requirement verified only by a unit test fails conformance (the #36414 shape)", () => {
772
+ const d = run("pytest tests/test_email_code_auth.py");
773
+ assert.equal(d.state, "nonconforming");
774
+ assert.deepEqual(codes(d), ["conformance.e2e_unverified"]);
775
+ });
776
+ it("a Playwright, configured e2e check or e2e spec file validate passes", () => {
777
+ assert.equal(run("npx playwright test e2e/signup.spec.ts").state, "conforms");
778
+ assert.equal(run("make smoke-live", Q, {}, { checks: ["make smoke-live"] }).state, "conforms");
779
+ assert.equal(run("node --test tests/signup.js", Q, {}, { isSpec: (f) => f === "tests/signup.js" }).state, "conforms");
780
+ });
781
+ it("isE2eCommand and e2eVerified resolve a V-ref to its contract command", () => {
782
+ assert.equal(isE2eCommand("npx playwright test"), true);
783
+ assert.equal(isE2eCommand("pytest tests/test_x.py"), false);
784
+ const contract = { validate: [{ id: "V1", command: "pytest tests/a.py" }, { id: "V2", command: "npx cypress run" }] };
785
+ assert.equal(e2eVerified({ validated_by: "V1" }, contract), false);
786
+ assert.equal(e2eVerified({ validated_by: "V1, V2" }, contract), true);
787
+ });
788
+ it("[type:unit] and [type:api] requirements keep their evidence rules; not-applicable is untouched", () => {
789
+ assert.equal(run("pytest tests/test_x.py", "Users can sign up with an emailed code [type:unit]").state, "conforms");
790
+ assert.equal(run("pytest tests/test_x.py", "Users can sign up with an emailed code [type:api]").state, "conforms");
791
+ const na = { ...C.requirements[0], quote: Q, disposition: "not-applicable", evidence: "src/a.js:1" };
792
+ const contract = { ...C, requirements: [na, C.requirements[1]] };
793
+ const r = { ...good, issue_coverage: [{ quote: Q, status: "covered" }, ...good.issue_coverage.slice(1)], requirements: [{ id: "R1", status: "not-applicable" }, good.requirements[1]] };
794
+ assert.notEqual(decide(r, { ...ctx, contract, issueText: `${ISSUE}\n${Q}` }).failures.some((f) => f.code === "conformance.e2e_unverified"), true);
795
+ });
796
+ });
797
+
798
+ describe("decision amendments (#4695)", () => {
799
+ const withRow = { ...C, preserve: [...C.preserve, { id: "H1", what: "use A", source: "decision", decision: "d9-1" }] };
800
+ const amend = (extra, opts = { baseFiles: ["src/scrape.js", "src/search.js"] }) => applyAmendments(withRow, [{ kind: "decision", why: "w", decision: { choice: "use B", anchors: ["src/**"], ...extra } }], null, opts);
801
+ it("applies a decision whose anchors exist on the base and returns the record", () => {
802
+ const a = amend({});
803
+ assert.deepEqual(a.problems, []);
804
+ assert.equal(a.decisions[0].choice, "use B");
805
+ assert.ok(a.contract.preserve.some((p) => p.id === "H1"));
806
+ });
807
+ it("rejects an anchor that matches no base file", () => {
808
+ const a = amend({ anchors: ["nope/**"] });
809
+ assert.match(a.problems[0], /match no file on the base/);
810
+ assert.equal(a.decisions.length, 0);
811
+ });
812
+ it("supersedes drops the engine-authored preserve row; without it the row stays", () => {
813
+ assert.ok(!amend({ supersedes: "d9-1" }).contract.preserve.some((p) => p.id === "H1"));
814
+ assert.ok(amend({}).contract.preserve.some((p) => p.id === "H1"));
815
+ });
816
+ it("a decision without choice or anchors is an incomplete amendment", () => {
817
+ assert.match(amend({ choice: "" }).problems[0], /needs decision/);
818
+ });
698
819
  });
@@ -0,0 +1,181 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * #4695: anchored decisions. A settled choice carries the paths/globs (and optional symbols) it governs and an optional
5
+ * `supersedes` link. The record of truth is a `<!-- FORGE:DECISION {json} -->` issue comment (the JSON sits on the
6
+ * marker line; prose FORGE:DECISION comments carry none and are ignored); `~/.forge/decisions/<repo>.json` indexes it.
7
+ * Supersession appends a fresh record of the old decision (status superseded, superseded_by) on the old issue: the
8
+ * latest record per id wins, nothing is edited. When a lane's deliverables hit an anchor the decision becomes an
9
+ * engine-authored preserve row `{id:"H<n>", what, source:"decision"}` (same shape as #4694), capped at HISTORY_ROWS_MAX.
10
+ * Pure except the index read/write; no LLM, gh or git here.
11
+ */
12
+ import * as nodeFs from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { noComments } from "./render.mjs";
16
+
17
+ export const DECISION_MARKER = "FORGE:DECISION";
18
+ /** Preserve rows the engine adds from history per lane; guards (#4694) share this cap. */
19
+ export const HISTORY_ROWS_MAX = 5;
20
+ const TEXT_MAX = 200;
21
+ const ANCHORS_MAX = 10;
22
+ const MARKER_RE = /^<!--\s*FORGE:DECISION\s+(\{.*\})\s*-->/;
23
+
24
+ const one = (v, max = TEXT_MAX) => {
25
+ const t = typeof v === "string" ? noComments(v, " ").replace(/\s+/g, " ").trim() : "";
26
+ return t.length > max ? `${t.slice(0, max - 1)}…` : t;
27
+ };
28
+ const strs = (v, max = ANCHORS_MAX, len = 200) => [...new Set((Array.isArray(v) ? v : []).map((x) => one(x, len)).filter(Boolean))].slice(0, max);
29
+ const cleanAnchor = (a) => String(a).replace(/^\.\//, "").replace(/^\/+/, "");
30
+
31
+ /** A bounded decision record from raw input, or null when it has no choice or no anchor. `issue` is the register issue. */
32
+ export function normaliseDecision(raw, { issue = null, n = 1 } = {}) {
33
+ const r = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
34
+ const choice = one(r.choice), anchors = strs(r.anchors).map(cleanAnchor);
35
+ if (!choice || !anchors.length) return null;
36
+ const iss = Number.isInteger(r.issue) ? r.issue : issue;
37
+ const status = r.status === "superseded" ? "superseded" : "active";
38
+ const rec = { v: 1, id: one(r.id, 60) || `d${iss ?? 0}-${n}`, choice, why: one(r.why), anchors, issue: iss, status };
39
+ const symbols = strs(r.symbols, ANCHORS_MAX, 80);
40
+ if (symbols.length) rec.symbols = symbols;
41
+ if (one(r.supersedes, 60)) rec.supersedes = one(r.supersedes, 60);
42
+ if (status === "superseded" && one(r.superseded_by, 60)) rec.superseded_by = one(r.superseded_by, 60);
43
+ return rec;
44
+ }
45
+
46
+ /** The marker (for post(): `<!-- FORGE:DECISION {json} -->`) and readable body of a decision record. */
47
+ export function renderDecision(rec) {
48
+ const json = JSON.stringify(rec).replace(/>/g, "\\u003e").replace(/</g, "\\u003c"); // can never close the marker comment
49
+ const tail = [rec.supersedes ? `Supersedes \`${rec.supersedes}\`.` : "", rec.status === "superseded" ? `Superseded by \`${rec.superseded_by}\`.` : ""].filter(Boolean).join(" ");
50
+ return {
51
+ marker: `${DECISION_MARKER} ${json}`,
52
+ body: `## Decision \`${rec.id}\`${rec.status === "superseded" ? " (superseded)" : ""}\n**What**: ${rec.choice}\n**Why**: ${rec.why || "-"}\n**Governs**: ${rec.anchors.map((a) => `\`${a}\``).join(", ")}${tail ? `\n${tail}` : ""}`,
53
+ };
54
+ }
55
+
56
+ /** The record from a comment body whose first line is `<!-- FORGE:DECISION {json} -->`; null for prose or malformed. */
57
+ export function parseDecisionComment(body, { issue = null } = {}) {
58
+ const m = MARKER_RE.exec(String(body ?? "").split("\n", 1)[0]);
59
+ if (!m) return null;
60
+ try {
61
+ const raw = JSON.parse(m[1]);
62
+ return normaliseDecision({ ...raw, issue: Number.isInteger(raw?.issue) ? raw.issue : issue });
63
+ } catch { return null; }
64
+ }
65
+
66
+ /** One record per id, the latest wins (input in posting order); ids ordered by first appearance. */
67
+ export function foldDecisions(records) {
68
+ const by = new Map();
69
+ for (const r of records ?? []) if (r?.id) by.set(r.id, { ...(by.get(r.id) ?? {}), ...r });
70
+ return [...by.values()];
71
+ }
72
+
73
+ const globRe = (g) => new RegExp(`^${String(g).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*\/?/g, "\u0000").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]").replace(/\u0000/g, ".*")}$`);
74
+ /** True when `anchor` (a file, a `dir/` prefix or a glob) covers `path`. */
75
+ export function anchorMatches(anchor, path) {
76
+ const a = cleanAnchor(anchor), p = cleanAnchor(path);
77
+ if (!a || !p) return false;
78
+ if (a.endsWith("/")) return p.startsWith(a);
79
+ return a === p || globRe(a).test(p) || p.startsWith(`${a}/`);
80
+ }
81
+
82
+ /** The anchors that match no file of `files`. */
83
+ export const deadAnchors = (anchors, files) => (anchors ?? []).filter((a) => !(files ?? []).some((f) => anchorMatches(a, f)));
84
+
85
+ /** Active decisions with at least one dead anchor on the base file list: [{decision, dead}]. Never applied. */
86
+ export function staleDecisions(records, baseFiles) {
87
+ const out = [];
88
+ for (const d of foldDecisions(records)) {
89
+ if (d.status === "superseded") continue;
90
+ const dead = deadAnchors(d.anchors, baseFiles);
91
+ if (dead.length) out.push({ decision: d, dead });
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /**
97
+ * Preserve rows for the active decisions an anchor of which covers a deliverable path: most specific anchor first, then
98
+ * newest issue, then id; at most `cap` rows, ids H<start>… Deterministic: the same inputs give the same rows.
99
+ */
100
+ export function historyRows(records, deliverablePaths, { cap = HISTORY_ROWS_MAX, start = 1 } = {}) {
101
+ const paths = (deliverablePaths ?? []).filter(Boolean);
102
+ if (!paths.length || cap <= 0) return [];
103
+ const hits = [];
104
+ for (const d of foldDecisions(records)) {
105
+ if (d.status === "superseded") continue;
106
+ const spec = Math.max(-1, ...d.anchors.filter((a) => paths.some((p) => anchorMatches(a, p))).map((a) => cleanAnchor(a).length));
107
+ if (spec >= 0) hits.push({ d, spec });
108
+ }
109
+ hits.sort((x, y) => y.spec - x.spec || (y.d.issue ?? 0) - (x.d.issue ?? 0) || (x.d.id < y.d.id ? -1 : x.d.id > y.d.id ? 1 : 0));
110
+ return hits.slice(0, cap).map(({ d }, i) => ({
111
+ id: `H${start + i}`,
112
+ what: `${d.choice}${d.why ? ` (${d.why})` : ""} [decision ${d.id}${d.issue ? `, #${d.issue}` : ""}; to change it, amend {kind:"decision", decision:{choice, anchors, supersedes:"${d.id}"}, why}]`,
113
+ source: "decision",
114
+ decision: d.id,
115
+ }));
116
+ }
117
+
118
+ /** The contract with history rows appended to preserve; the same object when none apply (the hash stays byte-identical). */
119
+ export function withDecisionRows(contract, records) {
120
+ const have = new Set((contract?.preserve ?? []).map((p) => p.id));
121
+ let start = 1;
122
+ while (have.has(`H${start}`)) start++;
123
+ const room = HISTORY_ROWS_MAX - (contract?.preserve ?? []).filter((p) => /^H\d+$/.test(String(p.id))).length;
124
+ const rows = historyRows(records, (contract?.deliverables ?? []).map((d) => d.path), { cap: room, start });
125
+ return rows.length ? { ...contract, preserve: [...(contract.preserve ?? []), ...rows] } : contract;
126
+ }
127
+
128
+ /** {old, next}: the old record marked superseded by the new id, the new one linked back with `supersedes`. */
129
+ export function supersede(old, next) {
130
+ return { old: { ...old, status: "superseded", superseded_by: next.id }, next: { ...next, supersedes: old.id } };
131
+ }
132
+
133
+ export const decisionsDir = () => process.env.FORGEDOCK_DECISIONS_DIR || join(homedir(), ".forge", "decisions");
134
+ export const indexFile = (repo) => join(decisionsDir(), `${String(repo ?? "repo").replace(/[^A-Za-z0-9._-]/g, "_")}.json`);
135
+
136
+ /** The indexed records (posting order); [] when the index is absent or unreadable. One file read. */
137
+ export function readDecisionIndex(repo, { fs = nodeFs } = {}) {
138
+ try {
139
+ const j = JSON.parse(fs.readFileSync(indexFile(repo), "utf8"));
140
+ return (Array.isArray(j?.records) ? j.records : []).map((r) => normaliseDecision(r)).filter(Boolean);
141
+ } catch { return []; }
142
+ }
143
+
144
+ /** Replaces the index (written atomically); best effort, the register of record is GitHub. */
145
+ export function writeDecisionIndex(repo, records, { fs = nodeFs } = {}) {
146
+ try {
147
+ const file = indexFile(repo);
148
+ fs.mkdirSync(decisionsDir(), { recursive: true });
149
+ fs.writeFileSync(`${file}.tmp`, JSON.stringify({ v: 1, records: foldDecisions(records) }));
150
+ fs.renameSync(`${file}.tmp`, file);
151
+ return true;
152
+ } catch { return false; }
153
+ }
154
+
155
+ /**
156
+ * Posts the lane's decision records as FORGE:DECISION on `issue` and, for a `supersedes`, the old record marked
157
+ * superseded on its own issue (both records updated); then updates the index. `post(marker, body, onIssue)` posts a
158
+ * comment. Returns the records posted. Best effort per record: a failed post is skipped, never fails the lane.
159
+ */
160
+ export async function recordDecisions(list, { issue, repo, post, fs = nodeFs }) {
161
+ const index = foldDecisions(readDecisionIndex(repo, { fs })), out = [];
162
+ let n = 0;
163
+ for (const raw of list ?? []) {
164
+ n++;
165
+ let next = normaliseDecision({ ...raw, id: undefined, issue }, { issue, n });
166
+ if (!next) continue;
167
+ const old = raw.supersedes ? index.find((d) => d.id === raw.supersedes) : null;
168
+ let oldRec = null;
169
+ if (old) ({ old: oldRec, next } = supersede(old, next));
170
+ else if (raw.supersedes) next = { ...next, supersedes: String(raw.supersedes) };
171
+ try {
172
+ const m = renderDecision(next);
173
+ await post(m.marker, m.body, issue);
174
+ if (oldRec?.issue) { const o = renderDecision(oldRec); await post(o.marker, o.body, oldRec.issue); }
175
+ } catch { continue; }
176
+ index.push(...[oldRec, next].filter(Boolean));
177
+ out.push(next);
178
+ }
179
+ if (out.length) writeDecisionIndex(repo, index, { fs });
180
+ return out;
181
+ }