mandrel 2.24.0 → 2.26.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 (189) hide show
  1. package/.agents/agents/acceptance-critic.md +19 -21
  2. package/.agents/agents/auditor.md +16 -19
  3. package/.agents/agents/story-worker.md +41 -54
  4. package/.agents/audit-checklists/architecture.md +5 -1
  5. package/.agents/audit-checklists/baselines.md +21 -0
  6. package/.agents/audit-checklists/quality.md +3 -0
  7. package/.agents/docs/agentrc-reference.json +2 -1
  8. package/.agents/docs/configuration.md +2 -1
  9. package/.agents/docs/quality-gates.md +80 -18
  10. package/.agents/docs/workflows.md +4 -2
  11. package/.agents/instructions.md +1 -1
  12. package/.agents/rules/ci-remediation.md +68 -3
  13. package/.agents/schemas/agentrc.schema.json +6 -1
  14. package/.agents/schemas/audit-rules.json +15 -0
  15. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  16. package/.agents/schemas/baselines/baseline-envelope.schema.json +8 -0
  17. package/.agents/schemas/baselines/crap.schema.json +13 -1
  18. package/.agents/schemas/crap-report.schema.json +37 -0
  19. package/.agents/schemas/model-attribution.schema.json +4 -0
  20. package/.agents/scripts/acceptance-eval.js +124 -15
  21. package/.agents/scripts/audit-baselines.js +136 -0
  22. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  23. package/.agents/scripts/audit-to-stories.js +33 -3
  24. package/.agents/scripts/check-arch-cycles.js +12 -93
  25. package/.agents/scripts/check-baseline-drift.js +16 -3
  26. package/.agents/scripts/check-baselines.js +19 -3
  27. package/.agents/scripts/check-context-budget.js +95 -14
  28. package/.agents/scripts/check-cyclomatic.js +214 -0
  29. package/.agents/scripts/check-schema-references.js +392 -0
  30. package/.agents/scripts/check-test-temp-hygiene.js +121 -1
  31. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  32. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  33. package/.agents/scripts/deliver-light.js +3 -4
  34. package/.agents/scripts/deliver-recover.js +13 -0
  35. package/.agents/scripts/diagnose-friction.js +85 -19
  36. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  37. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  38. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  39. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  40. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  41. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  42. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  43. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  44. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  45. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  46. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  47. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  48. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  49. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  50. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  51. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  52. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  53. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  54. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  55. package/.agents/scripts/lib/baseline-loader.js +0 -0
  56. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  57. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  58. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  59. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  60. package/.agents/scripts/lib/baselines/kinds/crap.js +499 -57
  61. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  62. package/.agents/scripts/lib/baselines/reader.js +10 -0
  63. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  64. package/.agents/scripts/lib/baselines/scope.js +39 -90
  65. package/.agents/scripts/lib/baselines/writer.js +16 -7
  66. package/.agents/scripts/lib/changed-files.js +8 -1
  67. package/.agents/scripts/lib/cli-args.js +115 -1
  68. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  69. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  70. package/.agents/scripts/lib/crap-engine.js +124 -27
  71. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  72. package/.agents/scripts/lib/crap-utils.js +86 -13
  73. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  74. package/.agents/scripts/lib/env-loader.js +46 -16
  75. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  76. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  77. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  78. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  79. package/.agents/scripts/lib/findings/severity.js +80 -2
  80. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  81. package/.agents/scripts/lib/git-utils.js +136 -80
  82. package/.agents/scripts/lib/import-graph.js +156 -0
  83. package/.agents/scripts/lib/label-constants.js +17 -0
  84. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  85. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  86. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  87. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  88. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  89. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  90. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  91. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  92. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  93. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  94. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  95. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  96. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  97. package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
  98. package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
  99. package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
  100. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  101. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  102. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +216 -109
  103. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +61 -13
  104. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  105. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  106. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  107. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  108. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  109. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  110. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  111. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  112. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  113. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +125 -37
  114. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  115. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  116. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  117. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  118. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +267 -125
  119. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  120. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  121. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  122. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  123. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  124. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  125. package/.agents/scripts/lib/story-adjacency.js +3 -3
  126. package/.agents/scripts/lib/story-plan.js +137 -42
  127. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  128. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  129. package/.agents/scripts/lib/test-tiers.js +11 -2
  130. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  131. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  132. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  133. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  134. package/.agents/scripts/lib/wave-runner/live-probe.js +29 -13
  135. package/.agents/scripts/lib/wave-runner/ready-set.js +254 -43
  136. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +10 -13
  137. package/.agents/scripts/lib/workers/crap-worker.js +8 -13
  138. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  139. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  140. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  141. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  142. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  143. package/.agents/scripts/lib/worktree-manager.js +14 -0
  144. package/.agents/scripts/plan-run-epilogue.js +17 -5
  145. package/.agents/scripts/pr-watch-with-update.js +568 -213
  146. package/.agents/scripts/providers/github/tickets.js +33 -10
  147. package/.agents/scripts/provision-git-hooks.js +85 -0
  148. package/.agents/scripts/quality-preview.js +137 -28
  149. package/.agents/scripts/resolve-stories.js +4 -1
  150. package/.agents/scripts/run-coverage.js +86 -35
  151. package/.agents/scripts/run-lint.js +20 -0
  152. package/.agents/scripts/run-tests.js +36 -36
  153. package/.agents/scripts/single-story-close.js +34 -2
  154. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  155. package/.agents/scripts/single-story-init.js +7 -0
  156. package/.agents/scripts/stories-wave-tick.js +308 -47
  157. package/.agents/scripts/story-plan.js +65 -9
  158. package/.agents/scripts/update-coverage-baseline.js +34 -4
  159. package/.agents/scripts/update-crap-baseline.js +42 -4
  160. package/.agents/scripts/update-duplication-baseline.js +209 -83
  161. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  162. package/.agents/scripts/validate-docs-freshness.js +1 -0
  163. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  164. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  165. package/.agents/skills/skills.index.json +2 -2
  166. package/.agents/templates/single-story-body.md +16 -8
  167. package/.agents/workflows/audit-architecture.md +69 -13
  168. package/.agents/workflows/audit-baselines.md +289 -0
  169. package/.agents/workflows/audit-navigability.md +5 -4
  170. package/.agents/workflows/audit-quality.md +26 -0
  171. package/.agents/workflows/audit-to-stories.md +30 -4
  172. package/.agents/workflows/deliver.md +92 -97
  173. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  174. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  175. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  176. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  177. package/.agents/workflows/helpers/deliver-reference.md +95 -4
  178. package/.agents/workflows/helpers/deliver-story-reference.md +174 -52
  179. package/.agents/workflows/helpers/deliver-story.md +104 -119
  180. package/.agents/workflows/helpers/plan-reference.md +49 -5
  181. package/.agents/workflows/memory-consolidate.md +116 -0
  182. package/.agents/workflows/plan.md +62 -75
  183. package/README.md +13 -6
  184. package/docs/CHANGELOG.md +93 -0
  185. package/lib/cli/update.js +14 -11
  186. package/lib/cli/version-check.js +9 -1
  187. package/package.json +9 -4
  188. package/.agents/schemas/friction-event.schema.json +0 -56
  189. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -9,6 +9,7 @@
9
9
  * @module lib/orchestration/check-baselines/phases/compare
10
10
  */
11
11
 
12
+ import { EXIT_CONFIG } from '../../../baselines/exit-codes.js';
12
13
  import { readBaseFromGit } from '../../../baselines/git-base.js';
13
14
  import { getKindModule } from '../../../baselines/kernel.js';
14
15
  import { resolveScope } from '../../../baselines/scope.js';
@@ -30,10 +31,8 @@ export function resolveDispatchScope({ kind, quality, env }) {
30
31
  kind,
31
32
  configScope: cfg.scope,
32
33
  configRef: cfg.diffRef,
33
- cliFlags: {
34
- envScope: env?.BASELINE_SCOPE,
35
- envRef: env?.BASELINE_REF,
36
- },
34
+ envScope: env?.BASELINE_SCOPE,
35
+ envRef: env?.BASELINE_REF,
37
36
  });
38
37
  }
39
38
 
@@ -41,13 +40,44 @@ function emptyCompareResult(baseRef) {
41
40
  return { baseRef, baseRead: false };
42
41
  }
43
42
 
43
+ /**
44
+ * Story #4914 — a base read that FAILS is not a base that is ABSENT.
45
+ *
46
+ * `readBaseFromGit` already draws that line itself: it returns `null` only
47
+ * for git exit 128 ("path does not exist in this revision") and throws on
48
+ * everything else. Swallowing the throw conflated the two, so a broken read
49
+ * silently emptied the whole head-vs-base arm — regressions AND additions —
50
+ * while the floors arm kept the run at exit 0. A gate that fails open is
51
+ * worse than no gate, because it is trusted.
52
+ *
53
+ * So the read failure fails CLOSED as `EXIT_CONFIG` (3) — "the gate could
54
+ * not even start", the same code `assertFloorAxesExist` uses for a
55
+ * misconfigured floor axis. `check-baselines.js#main` maps any throw out of
56
+ * the pipeline onto that code.
57
+ */
58
+ function buildBaseReadError({ kind, ref, file, cause }) {
59
+ const detail = cause?.message ?? String(cause);
60
+ const err = new Error(
61
+ `[check-baselines:${kind}] could not read the base baseline at ` +
62
+ `${ref}:${file} — the head-vs-base compare arm cannot run, so the gate ` +
63
+ `fails closed rather than reporting zero regressions: ${detail}`,
64
+ );
65
+ err.code = 'EXIT_CONFIG';
66
+ err.exitCode = EXIT_CONFIG;
67
+ err.kind = kind;
68
+ err.baseRef = ref;
69
+ err.baselinePath = file;
70
+ err.cause = cause;
71
+ return err;
72
+ }
73
+
44
74
  function readBaseBaselinePayload(scope, kind, gateBlock, cwd) {
45
75
  const rel = baselineRelativePath(kind, gateBlock);
46
76
  let raw;
47
77
  try {
48
78
  raw = readBaseFromGit(scope.ref, rel, { cwd });
49
- } catch {
50
- return null;
79
+ } catch (cause) {
80
+ throw buildBaseReadError({ kind, ref: scope.ref, file: rel, cause });
51
81
  }
52
82
  if (raw === null) return null;
53
83
  try {
@@ -206,6 +206,11 @@ function buildGateReport({
206
206
  additions: compareOutput.additions ?? [],
207
207
  regressionCount: compareOutput.regressions.length,
208
208
  baseRef: cmp.baseRef ?? null,
209
+ // Story #4914 — the compare arm's read status was internal to compare.js,
210
+ // which is why a dead compare arm looked byte-identical to a clean run.
211
+ // Surfacing it makes "the head-vs-base arm did not run" diagnosable from
212
+ // the JSON report alone.
213
+ baseRead: cmp.baseRead === true,
209
214
  generatedAt: baseline.generatedAt,
210
215
  acknowledged,
211
216
  };
@@ -10,7 +10,18 @@
10
10
 
11
11
  import { EXIT_CONFIG } from '../../../baselines/exit-codes.js';
12
12
 
13
- function axisDirection(kind, axis) {
13
+ /**
14
+ * Which way a floor axis is compared: `gte` means the measured value must be
15
+ * at or above the floor, `lte` at or below it. Exported (Story #4902) so the
16
+ * baseline hotspot engine reports floor headroom with the same polarity the
17
+ * gate enforces — a second copy of this table would let the two disagree
18
+ * about which direction is "better" for a given axis.
19
+ *
20
+ * @param {string} kind
21
+ * @param {string} axis
22
+ * @returns {'gte' | 'lte'}
23
+ */
24
+ export function axisDirection(kind, axis) {
14
25
  if (kind === 'lint') return 'lte';
15
26
  if (kind === 'crap') return 'lte';
16
27
  if (kind === 'bundle-size') return 'lte';
@@ -27,8 +27,15 @@ function formatGateLine(g) {
27
27
  ? ''
28
28
  : ` [kernel drift ${g.kernelBaseline} → ${g.kernelCurrent}]`;
29
29
  const baseRef = g.baseRef ? ` [baseRef=${g.baseRef}]` : '';
30
+ // Story #4914 — a compare arm that never read its base reports zero
31
+ // regressions and zero additions, which is indistinguishable from a clean
32
+ // run unless the text report says so out loud.
33
+ const baseRead =
34
+ g.baseRef && g.baseRead === false
35
+ ? ' [baseRead=false — compare skipped]'
36
+ : '';
30
37
  const ack = g.acknowledged ? ' [ACKNOWLEDGED — this run only]' : '';
31
- return ` - ${g.kind}: ${status}${drift}${baseRef}${ack}`;
38
+ return ` - ${g.kind}: ${status}${drift}${baseRef}${baseRead}${ack}`;
32
39
  }
33
40
 
34
41
  function formatViolationLine(component, v) {
@@ -0,0 +1,548 @@
1
+ // .agents/scripts/lib/orchestration/ci-rerun-guard.js
2
+ /**
3
+ * ci-rerun-guard.js — the machine enforcement behind the no-rerun MUST in
4
+ * [`rules/ci-remediation.md`](../../../rules/ci-remediation.md) § Verifier
5
+ * (Story #4865). Owns the CI failure digest (write / read / retire), the
6
+ * head-SHA discriminator, the auto-merge disarm, and the blocked-delivery
7
+ * escalation. `pr-watch-with-update.js` is the enforcement point; this
8
+ * module is the mechanism it drives.
9
+ *
10
+ * **Why the first red, and not the rerun-green.** The obvious design —
11
+ * observe a green that arrives on a re-run of the same commit, then disarm
12
+ * auto-merge and block — cannot enforce anything. GitHub's native
13
+ * auto-merge fires server-side the moment branch protection is satisfied,
14
+ * so it races the watcher's next poll: by the time the rerun-green is
15
+ * observed, the PR may already be merged. The only race-free observation
16
+ * point is the **first red**, which necessarily precedes any green. So the
17
+ * watcher disarms on red and records the PR head SHA; the head SHA is then
18
+ * read at **re-arm** time, not at merge time.
19
+ *
20
+ * **Head SHA is the discriminator.** A green on a *different* head SHA is a
21
+ * fix at source — legal, and it retires the digest. A green on the *same*
22
+ * head SHA is a re-run of a failed job, which the rule forbids: the
23
+ * delivery hard-stops at `agent::blocked` and a `meta::framework-gap` issue
24
+ * (carrying the run link and failure signature the digest already holds) is
25
+ * required before it can proceed.
26
+ *
27
+ * **Fail closed on an unverifiable green.** A digest whose head SHA is
28
+ * missing, or a current head SHA that `gh` could not resolve, leaves no
29
+ * evidence that the red was fixed at source. An unresolved red plus no
30
+ * evidence of a new commit is treated as a violation rather than waved
31
+ * through — the same unknown-is-blocking posture the arming probe takes on
32
+ * an unrecognized check state.
33
+ */
34
+
35
+ import { spawnSync } from 'node:child_process';
36
+ import {
37
+ existsSync,
38
+ mkdirSync,
39
+ readFileSync,
40
+ rmSync,
41
+ writeFileSync,
42
+ } from 'node:fs';
43
+ import path from 'node:path';
44
+ import { resolveConfig } from '../config-resolver.js';
45
+ import { Logger } from '../Logger.js';
46
+ import { createProvider } from '../provider-factory.js';
47
+ import {
48
+ STATE_LABELS,
49
+ transitionTicketState,
50
+ upsertStructuredComment,
51
+ } from './ticketing.js';
52
+
53
+ /** How many superseded unresolved reds a digest carries before the oldest is dropped. */
54
+ const MAX_PRIOR_REDS = 10;
55
+
56
+ /**
57
+ * Resolve which ticket the digest is keyed to. Story #4539: the digest used
58
+ * to be Epic-scoped by filename and returned `null` without an epic id — so
59
+ * on the v2 Story path (which has no Epic and invokes the watch with `--pr`
60
+ * alone) a red check wrote no digest at all, despite the module header
61
+ * advertising one. v2.0.0 removed the Epic tier; Story scope is the only
62
+ * scope.
63
+ *
64
+ * @param {{ storyId?: number|string|null }} opts
65
+ * @returns {{ kind: 'story', id: number } | null}
66
+ */
67
+ export function resolveDigestScope({ storyId = null } = {}) {
68
+ if (storyId == null || String(storyId).length === 0) return null;
69
+ const parsed = Number.parseInt(String(storyId), 10);
70
+ return Number.isInteger(parsed) && parsed > 0
71
+ ? { kind: 'story', id: parsed }
72
+ : null;
73
+ }
74
+
75
+ /**
76
+ * Coarse failure classification from a failing-check name. Pure —
77
+ * exported for tests. Deliberately shallow: it steers the operator's
78
+ * next move (which `/loop` unit to reach for), not a root-cause verdict.
79
+ *
80
+ * @param {string} name failing required-check name.
81
+ * @returns {'test'|'lint'|'baseline'|'build'|'unknown'}
82
+ */
83
+ export function classifyFailure(name) {
84
+ const n = String(name ?? '').toLowerCase();
85
+ if (/lint|format|biome|markdownlint/.test(n)) return 'lint';
86
+ if (/baseline|coverage|crap|maintainab|duplicat/.test(n)) return 'baseline';
87
+ if (/build|compile|typecheck|bundle/.test(n)) return 'build';
88
+ if (/test|spec|validate|ci|check/.test(n)) return 'test';
89
+ return 'unknown';
90
+ }
91
+
92
+ /**
93
+ * Resolve the `.json` / `.md` digest paths for a scope. Returns `null` when
94
+ * no scope can be keyed (the digest is scoped by filename and has nothing
95
+ * to key on).
96
+ *
97
+ * Module-private: `writeCiDigest` / `readCiDigest` / `retireCiDigest` are the
98
+ * only callers and are the surface worth pinning, so the keying is asserted
99
+ * through them rather than through an export nothing in production reaches.
100
+ *
101
+ * @param {{ storyId?: number|string|null, tempRoot: string, cwd: string }} opts
102
+ * @returns {{ scope: { kind: 'story', id: number }, jsonPath: string, mdPath: string } | null}
103
+ */
104
+ function ciDigestPaths({ storyId = null, tempRoot, cwd }) {
105
+ const scope = resolveDigestScope({ storyId });
106
+ if (!scope) return null;
107
+ const dir = path.isAbsolute(tempRoot) ? tempRoot : path.join(cwd, tempRoot);
108
+ const base = `${scope.kind}-${scope.id}-ci-digest`;
109
+ return {
110
+ scope,
111
+ jsonPath: path.join(dir, `${base}.json`),
112
+ mdPath: path.join(dir, `${base}.md`),
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Default `gh run view --log-failed` spawn — pulls the tail of the failed
118
+ * job log so the digest carries an actionable excerpt. Best-effort:
119
+ * returns an empty tail when the run id is unknown or `gh` errors.
120
+ * Injected into `writeCiDigest` so tests never shell out.
121
+ */
122
+ function ghRunLogTail({ runId, cwd, spawnFn = spawnSync, maxLines = 40 }) {
123
+ if (!runId) return '';
124
+ const result = spawnFn('gh', ['run', 'view', String(runId), '--log-failed'], {
125
+ cwd,
126
+ encoding: 'utf-8',
127
+ shell: false,
128
+ maxBuffer: 10 * 1024 * 1024,
129
+ });
130
+ const out = (result.stdout ?? '').trim();
131
+ if (out.length === 0) return '';
132
+ const lines = out.split('\n');
133
+ return lines.slice(-maxLines).join('\n');
134
+ }
135
+
136
+ /**
137
+ * Resolve the failing check's run identity — the GitHub Actions run id AND
138
+ * the run URL. Best-effort via `gh pr checks --json name,link`: the `link`
139
+ * field carries the run URL whose trailing path segment is the run id. The
140
+ * URL matters as much as the id, because the `meta::framework-gap` issue a
141
+ * rerun violation demands must carry a run **link**.
142
+ *
143
+ * @returns {{ runId: string|null, url: string|null }}
144
+ */
145
+ function resolveFailingCheckRun({
146
+ prRef,
147
+ checkName,
148
+ cwd,
149
+ spawnFn = spawnSync,
150
+ }) {
151
+ const result = spawnFn('gh', ['pr', 'checks', prRef, '--json', 'name,link'], {
152
+ cwd,
153
+ encoding: 'utf-8',
154
+ shell: false,
155
+ });
156
+ try {
157
+ const parsed = JSON.parse((result.stdout ?? '').trim() || '[]');
158
+ const entry = Array.isArray(parsed)
159
+ ? parsed.find((e) => e?.name === checkName)
160
+ : null;
161
+ const link = entry?.link ? String(entry.link) : null;
162
+ const m = link ? /\/runs\/(\d+)/.exec(link) : null;
163
+ return { runId: m ? m[1] : null, url: link };
164
+ } catch {
165
+ return { runId: null, url: null };
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Resolve the PR's current head SHA (`headRefOid`) — the discriminator
171
+ * between a legal fix-at-source green and a forbidden rerun green. Returns
172
+ * `null` when `gh` fails or the payload is unparseable; callers treat an
173
+ * unresolvable head as unverifiable, never as "changed".
174
+ *
175
+ * @param {{ prRef: string, cwd: string, spawnFn?: typeof spawnSync }} opts
176
+ * @returns {string|null}
177
+ */
178
+ export function resolvePrHeadSha({ prRef, cwd, spawnFn = spawnSync }) {
179
+ const result = spawnFn('gh', ['pr', 'view', prRef, '--json', 'headRefOid'], {
180
+ cwd,
181
+ encoding: 'utf-8',
182
+ shell: false,
183
+ });
184
+ if ((result?.status ?? 1) !== 0) return null;
185
+ try {
186
+ const parsed = JSON.parse((result.stdout ?? '').trim() || '{}');
187
+ const sha = parsed?.headRefOid;
188
+ return typeof sha === 'string' && sha.length > 0 ? sha : null;
189
+ } catch {
190
+ return null;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * A `gh` refusal that means auto-merge was never armed in the first place —
196
+ * there is nothing to disarm, so the PR is already in the un-armed posture
197
+ * the guard wants. Distinguishing this from a genuine disarm failure is
198
+ * load-bearing: an armed PR that could not be disarmed is a blocker, while
199
+ * a never-armed PR is the desired end state.
200
+ */
201
+ const NOT_ARMED = /not enabled|isn't enabled|is not set|no auto-?merge/i;
202
+
203
+ /**
204
+ * Disarm GitHub native auto-merge for the PR — the race-free response to the
205
+ * first red. Never throws.
206
+ *
207
+ * @param {{ prRef: string, cwd: string, spawnFn?: typeof spawnSync }} opts
208
+ * @returns {{ disarmed: boolean, alreadyUnarmed: boolean, detail: string }}
209
+ * `disarmed` is true when the PR is (now) un-armed; `alreadyUnarmed`
210
+ * distinguishes "there was nothing armed" from an executed disarm.
211
+ */
212
+ export function disarmAutoMerge({ prRef, cwd, spawnFn = spawnSync }) {
213
+ let result;
214
+ try {
215
+ result = spawnFn('gh', ['pr', 'merge', prRef, '--disable-auto'], {
216
+ cwd,
217
+ encoding: 'utf-8',
218
+ shell: false,
219
+ });
220
+ } catch (err) {
221
+ return {
222
+ disarmed: false,
223
+ alreadyUnarmed: false,
224
+ detail: `gh-spawn-error: ${err?.message ?? err}`,
225
+ };
226
+ }
227
+ const status = result?.status ?? 1;
228
+ const stderr = String(result?.stderr ?? '').trim();
229
+ if (status === 0) {
230
+ return { disarmed: true, alreadyUnarmed: false, detail: 'disarmed' };
231
+ }
232
+ if (NOT_ARMED.test(stderr)) {
233
+ return {
234
+ disarmed: true,
235
+ alreadyUnarmed: true,
236
+ detail: `auto-merge was not armed: ${stderr.slice(0, 160)}`,
237
+ };
238
+ }
239
+ return {
240
+ disarmed: false,
241
+ alreadyUnarmed: false,
242
+ detail: `gh-exit-${status}: ${stderr.slice(0, 200)}`,
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Read the CI failure digest for a scope, or `null` when none exists (or it
248
+ * is unreadable / malformed — an unparseable digest carries no enforceable
249
+ * evidence, so it is treated as absent).
250
+ *
251
+ * @param {{ storyId?: number|string|null, tempRoot: string, cwd: string }} opts
252
+ * @returns {object|null}
253
+ */
254
+ export function readCiDigest({ storyId = null, tempRoot, cwd }) {
255
+ const paths = ciDigestPaths({ storyId, tempRoot, cwd });
256
+ if (!paths || !existsSync(paths.jsonPath)) return null;
257
+ try {
258
+ const parsed = JSON.parse(readFileSync(paths.jsonPath, 'utf8'));
259
+ return parsed && typeof parsed === 'object' ? parsed : null;
260
+ } catch {
261
+ return null;
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Retire the digest for a scope — the red it recorded was resolved at
267
+ * source. Best-effort; returns the paths removed (or `null`).
268
+ *
269
+ * @param {{ storyId?: number|string|null, tempRoot: string, cwd: string }} opts
270
+ * @returns {{ jsonPath: string, mdPath: string } | null}
271
+ */
272
+ export function retireCiDigest({ storyId = null, tempRoot, cwd }) {
273
+ const paths = ciDigestPaths({ storyId, tempRoot, cwd });
274
+ if (!paths) return null;
275
+ rmSync(paths.jsonPath, { force: true });
276
+ rmSync(paths.mdPath, { force: true });
277
+ return { jsonPath: paths.jsonPath, mdPath: paths.mdPath };
278
+ }
279
+
280
+ /**
281
+ * Condense a digest into the record kept in a successor digest's
282
+ * `priorReds`, so a second red on a new head never silently erases the
283
+ * evidence of an unresolved earlier one.
284
+ */
285
+ function summarizeRed(digest) {
286
+ return {
287
+ headSha: digest?.headSha ?? null,
288
+ failingCheck: digest?.failingCheck ?? null,
289
+ runId: digest?.runId ?? null,
290
+ runUrl: digest?.runUrl ?? null,
291
+ generatedAt: digest?.generatedAt ?? null,
292
+ };
293
+ }
294
+
295
+ /**
296
+ * Carry forward the unresolved reds an incoming digest must not lose. A
297
+ * re-red on the SAME head is the same unresolved red observed again, so it
298
+ * is not duplicated into the history.
299
+ */
300
+ function carryPriorReds(previous, headSha) {
301
+ if (!previous) return [];
302
+ const inherited = Array.isArray(previous.priorReds) ? previous.priorReds : [];
303
+ const history =
304
+ previous.headSha && previous.headSha === headSha
305
+ ? inherited
306
+ : [...inherited, summarizeRed(previous)];
307
+ return history.slice(-MAX_PRIOR_REDS);
308
+ }
309
+
310
+ /**
311
+ * Render the human-readable digest. Names the head SHA, because that is the
312
+ * field the green path adjudicates on.
313
+ */
314
+ function renderDigestMarkdown(digest, failures) {
315
+ return [
316
+ `# CI failure digest — Story #${digest.storyId} (PR #${digest.prNumber})`,
317
+ '',
318
+ `- **Failing check:** \`${digest.failingCheck}\` (${digest.failingOutcome})`,
319
+ `- **Head SHA:** ${digest.headSha ?? 'unresolved'}`,
320
+ `- **Run id:** ${digest.runId ?? 'unresolved'}`,
321
+ `- **Run link:** ${digest.runUrl ?? 'unresolved'}`,
322
+ `- **Classification:** ${digest.classification}`,
323
+ `- **Generated:** ${digest.generatedAt}`,
324
+ '',
325
+ failures.length > 1
326
+ ? `Other non-green checks: ${failures
327
+ .slice(1)
328
+ .map((f) => `\`${f.name}\`=${f.outcome}`)
329
+ .join(', ')}`
330
+ : '',
331
+ digest.priorReds.length > 0
332
+ ? `Unresolved earlier red(s): ${digest.priorReds
333
+ .map((r) => `\`${r.failingCheck}\`@${r.headSha ?? 'unknown'}`)
334
+ .join(', ')}`
335
+ : '',
336
+ '',
337
+ 'A green on THIS head SHA is a re-run of a failed job and is forbidden',
338
+ '(`.agents/rules/ci-remediation.md` § Verifier). Fix at source and push a',
339
+ 'new commit — the head SHA moving is what clears this digest.',
340
+ '',
341
+ '## `gh run view --log-failed` tail',
342
+ '',
343
+ '```text',
344
+ digest.logTail || '(no failed-log output available)',
345
+ '```',
346
+ '',
347
+ ].join('\n');
348
+ }
349
+
350
+ /**
351
+ * Write the CI failure digest (`.json` + `.md`) for a red watch. Returns
352
+ * the two paths written, or `null` when no story id was supplied (the
353
+ * digest is scoped by filename and has nothing to key on).
354
+ *
355
+ * @param {object} opts
356
+ * @param {number|string|null} [opts.storyId] The v2 delivery scope.
357
+ * @param {number} opts.prNumber
358
+ * @param {string|null} [opts.headSha] PR head SHA at the moment of the red.
359
+ * @param {Array<{name:string, outcome:string}>} opts.failures
360
+ * @param {string} opts.tempRoot
361
+ * @param {string} opts.cwd
362
+ * @param {string} opts.prRef
363
+ * @param {Function} [opts.checkRunFn]
364
+ * @param {Function} [opts.logTailFn]
365
+ * @returns {{ jsonPath: string, mdPath: string } | null}
366
+ */
367
+ export function writeCiDigest({
368
+ storyId = null,
369
+ prNumber,
370
+ headSha = null,
371
+ failures,
372
+ tempRoot,
373
+ cwd,
374
+ prRef,
375
+ checkRunFn = resolveFailingCheckRun,
376
+ logTailFn = ghRunLogTail,
377
+ }) {
378
+ const paths = ciDigestPaths({ storyId, tempRoot, cwd });
379
+ if (!paths) return null;
380
+ const primary = failures[0] ?? { name: 'unknown', outcome: 'failure' };
381
+ const checkRun = checkRunFn({ prRef, checkName: primary.name, cwd }) ?? {};
382
+ const logTail = logTailFn({ runId: checkRun.runId, cwd });
383
+ const previous = readCiDigest({ storyId, tempRoot, cwd });
384
+ const digest = {
385
+ storyId: paths.scope.id,
386
+ prNumber,
387
+ headSha,
388
+ failingCheck: primary.name,
389
+ failingOutcome: primary.outcome,
390
+ runId: checkRun.runId ?? null,
391
+ runUrl: checkRun.url ?? null,
392
+ failingCheckRun: {
393
+ name: primary.name,
394
+ outcome: primary.outcome,
395
+ runId: checkRun.runId ?? null,
396
+ url: checkRun.url ?? null,
397
+ },
398
+ classification: classifyFailure(primary.name),
399
+ allFailures: failures,
400
+ priorReds: carryPriorReds(previous, headSha),
401
+ logTail,
402
+ generatedAt: new Date().toISOString(),
403
+ };
404
+ mkdirSync(path.dirname(paths.jsonPath), { recursive: true });
405
+ writeFileSync(paths.jsonPath, `${JSON.stringify(digest, null, 2)}\n`);
406
+ writeFileSync(paths.mdPath, renderDigestMarkdown(digest, failures));
407
+ return { jsonPath: paths.jsonPath, mdPath: paths.mdPath };
408
+ }
409
+
410
+ /**
411
+ * Adjudicate an all-green watch against any digest recorded for the scope.
412
+ *
413
+ * @param {{ digest: object|null, headSha: string|null }} opts
414
+ * @returns {{ verdict: 'clean'|'fix-at-source'|'rerun'|'unverifiable', reason: string }}
415
+ * - `clean` — no digest: this delivery never went red.
416
+ * - `fix-at-source` — the head SHA moved since the red; legal.
417
+ * - `rerun` — green on the SAME head SHA; forbidden.
418
+ * - `unverifiable` — an unresolved red with no head-SHA evidence either
419
+ * side; fail closed and treat it as a rerun.
420
+ */
421
+ export function classifyGreenVerdict({ digest, headSha }) {
422
+ if (!digest) return { verdict: 'clean', reason: 'no digest for this scope' };
423
+ const recorded = digest.headSha ?? null;
424
+ if (!recorded || !headSha) {
425
+ return {
426
+ verdict: 'unverifiable',
427
+ reason: `cannot prove the red was fixed at source (digest head=${recorded ?? 'unknown'}, current head=${headSha ?? 'unresolved'})`,
428
+ };
429
+ }
430
+ if (recorded === headSha) {
431
+ return {
432
+ verdict: 'rerun',
433
+ reason: `green on the SAME head SHA the red was recorded against (${headSha})`,
434
+ };
435
+ }
436
+ return {
437
+ verdict: 'fix-at-source',
438
+ reason: `head SHA moved ${recorded} → ${headSha}`,
439
+ };
440
+ }
441
+
442
+ /**
443
+ * The failure signature a `meta::framework-gap` issue must carry: the first
444
+ * distinctive line of the captured failed-job log.
445
+ */
446
+ function failureSignature(digest) {
447
+ const tail = String(digest?.logTail ?? '');
448
+ const line = tail
449
+ .split('\n')
450
+ .map((l) => l.trim())
451
+ .find((l) => l.length > 0);
452
+ return line ?? '(no failed-log output captured)';
453
+ }
454
+
455
+ /**
456
+ * Render the rerun-violation report — used verbatim as the friction comment
457
+ * body and, line by line, as the watcher's stderr report.
458
+ *
459
+ * @param {{ digest: object, headSha: string|null, prNumber: number, reason: string }} opts
460
+ * @returns {string}
461
+ */
462
+ export function formatRerunViolation({ digest, headSha, prNumber, reason }) {
463
+ return [
464
+ '### Forbidden CI re-run detected — delivery blocked',
465
+ '',
466
+ `Required check \`${digest.failingCheck}\` went red on PR #${prNumber}, and the checks are`,
467
+ `now green with no new commit: ${reason}.`,
468
+ '',
469
+ `- **Head SHA:** ${headSha ?? 'unresolved'}`,
470
+ `- **Run link:** ${digest.runUrl ?? `run id ${digest.runId ?? 'unresolved'}`}`,
471
+ `- **Failure signature:** \`${failureSignature(digest)}\``,
472
+ `- **Classification:** ${digest.classification ?? 'unknown'}`,
473
+ '',
474
+ 'A green reached by re-running a failed job masks the defect and is',
475
+ 'prohibited by `.agents/rules/ci-remediation.md` § Verifier. Native',
476
+ 'auto-merge was disarmed when the check first went red, so nothing merged.',
477
+ '',
478
+ '**To proceed**, do exactly one of:',
479
+ '',
480
+ '1. Fix the root cause on `story-<id>` and push a new commit — the head SHA',
481
+ ' moving is what clears the block.',
482
+ '2. File a `meta::framework-gap` issue carrying the run link and failure',
483
+ ' signature above when the root cause is outside this delivery, then',
484
+ ' resume.',
485
+ ].join('\n');
486
+ }
487
+
488
+ /**
489
+ * Hard-stop the delivery: post the `friction` comment and flip the Story to
490
+ * `agent::blocked`. Both halves are best-effort — a failure to reach GitHub
491
+ * must not turn the block into a crash, and the watcher's non-zero exit is
492
+ * what actually stops the delivery.
493
+ *
494
+ * Routed through `transitionTicketState` rather than a bare label write so
495
+ * the Projects v2 column sync runs (Story #2548 / #4539).
496
+ *
497
+ * @param {{
498
+ * storyId: number|string,
499
+ * body: string,
500
+ * provider?: object,
501
+ * config?: object,
502
+ * logger?: object,
503
+ * }} opts
504
+ * @returns {Promise<{ blocked: boolean, commented: boolean }>}
505
+ */
506
+ export async function blockStoryDelivery({
507
+ storyId,
508
+ body,
509
+ provider,
510
+ config,
511
+ logger = Logger,
512
+ }) {
513
+ const scope = resolveDigestScope({ storyId });
514
+ if (!scope) {
515
+ logger?.error?.(
516
+ '[ci-rerun-guard] no Story id — cannot flip agent::blocked; the non-zero exit is the only stop.',
517
+ );
518
+ return { blocked: false, commented: false };
519
+ }
520
+ let ticketing;
521
+ try {
522
+ ticketing = provider ?? createProvider(config ?? resolveConfig());
523
+ } catch (err) {
524
+ logger?.error?.(
525
+ `[ci-rerun-guard] could not resolve the ticketing provider: ${err?.message ?? err}`,
526
+ );
527
+ return { blocked: false, commented: false };
528
+ }
529
+ let commented = false;
530
+ try {
531
+ await upsertStructuredComment(ticketing, scope.id, 'friction', body);
532
+ commented = true;
533
+ } catch (err) {
534
+ logger?.error?.(
535
+ `[ci-rerun-guard] failed to post the friction comment: ${err?.message ?? err}`,
536
+ );
537
+ }
538
+ let blocked = false;
539
+ try {
540
+ await transitionTicketState(ticketing, scope.id, STATE_LABELS.BLOCKED, {});
541
+ blocked = true;
542
+ } catch (err) {
543
+ logger?.error?.(
544
+ `[ci-rerun-guard] failed to flip Story #${scope.id} to blocked: ${err?.message ?? err}`,
545
+ );
546
+ }
547
+ return { blocked, commented };
548
+ }
@@ -406,8 +406,8 @@ export function decideRecovery({
406
406
  nextCommand: NEXT_COMMANDS.watchCi(storyId, pr.number),
407
407
  detail:
408
408
  `PR #${pr.number} has a red required check. Waiting cannot help — fix the ` +
409
- `failure and push a new commit on \`story-${storyId}\`; auto-merge stays armed ` +
410
- `across retries.`,
409
+ `failure and push a new commit on \`story-${storyId}\`; the red disarmed ` +
410
+ `auto-merge, and only a green on a new head SHA re-arms it.`,
411
411
  evidence,
412
412
  };
413
413
  }