mandrel 1.87.0 → 1.89.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 (140) hide show
  1. package/.agents/README.md +18 -13
  2. package/.agents/audit-checklists/architecture.md +24 -0
  3. package/.agents/audit-checklists/clean-code.md +24 -0
  4. package/.agents/audit-checklists/dependencies.md +14 -0
  5. package/.agents/audit-checklists/devops.md +17 -0
  6. package/.agents/audit-checklists/documentation.md +22 -0
  7. package/.agents/audit-checklists/lighthouse.md +15 -0
  8. package/.agents/audit-checklists/navigability.md +14 -0
  9. package/.agents/audit-checklists/performance.md +22 -0
  10. package/.agents/audit-checklists/privacy.md +21 -0
  11. package/.agents/audit-checklists/quality.md +18 -0
  12. package/.agents/audit-checklists/security.md +22 -0
  13. package/.agents/audit-checklists/seo.md +16 -0
  14. package/.agents/audit-checklists/sre.md +24 -0
  15. package/.agents/audit-checklists/ux-ui.md +21 -0
  16. package/.agents/docs/SDLC.md +63 -16
  17. package/.agents/docs/configuration.md +5 -3
  18. package/.agents/instructions.md +51 -21
  19. package/.agents/personas/architect.md +10 -7
  20. package/.agents/personas/engineer.md +4 -3
  21. package/.agents/personas/project-manager.md +5 -2
  22. package/.agents/personas/refactorer.md +5 -3
  23. package/.agents/rules/git-conventions.md +77 -0
  24. package/.agents/schemas/agentrc.schema.json +16 -4
  25. package/.agents/schemas/audit-rules.json +16 -2
  26. package/.agents/schemas/audit-rules.schema.json +7 -6
  27. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +38 -0
  28. package/.agents/schemas/signal-event.schema.json +28 -13
  29. package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
  30. package/.agents/scripts/check-context-budget.js +320 -0
  31. package/.agents/scripts/diagnose-friction.js +4 -4
  32. package/.agents/scripts/epic-audit-prepare.js +30 -2
  33. package/.agents/scripts/epic-audit-recheck.js +46 -13
  34. package/.agents/scripts/epic-deliver-prepare.js +80 -8
  35. package/.agents/scripts/epic-plan-spec.js +4 -8
  36. package/.agents/scripts/generate-lens-checklists.js +180 -0
  37. package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
  38. package/.agents/scripts/lib/audit-suite/findings.js +27 -0
  39. package/.agents/scripts/lib/audit-suite/index.js +9 -0
  40. package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
  41. package/.agents/scripts/lib/audit-suite/selector.js +136 -5
  42. package/.agents/scripts/lib/checks/loop-health.js +340 -0
  43. package/.agents/scripts/lib/cli-args.js +8 -0
  44. package/.agents/scripts/lib/config/explain.js +4 -0
  45. package/.agents/scripts/lib/config/runners.js +21 -2
  46. package/.agents/scripts/lib/config/temp-paths.js +24 -0
  47. package/.agents/scripts/lib/config-settings-schema-delivery.js +23 -3
  48. package/.agents/scripts/lib/config-settings-schema-quality.js +7 -0
  49. package/.agents/scripts/lib/doc-tiers.js +291 -0
  50. package/.agents/scripts/lib/epic-body-sections.js +5 -2
  51. package/.agents/scripts/lib/epic-merge-lock.js +83 -0
  52. package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
  53. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +66 -20
  54. package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
  55. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
  56. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
  57. package/.agents/scripts/lib/gates/friction.js +15 -5
  58. package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
  59. package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
  60. package/.agents/scripts/lib/observability/signal-validator.js +204 -0
  61. package/.agents/scripts/lib/observability/signals-writer.js +157 -54
  62. package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
  63. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
  64. package/.agents/scripts/lib/orchestration/code-review.js +74 -4
  65. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
  66. package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
  67. package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
  68. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
  69. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
  70. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +188 -0
  71. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
  72. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +69 -8
  73. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +11 -2
  74. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
  75. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +39 -3
  76. package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
  77. package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
  78. package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
  79. package/.agents/scripts/lib/orchestration/merge-block-class.js +218 -0
  80. package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
  81. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
  82. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
  83. package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
  84. package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
  85. package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
  86. package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
  87. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
  88. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
  89. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
  90. package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
  91. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
  92. package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
  93. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
  94. package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
  95. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
  96. package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
  97. package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
  98. package/.agents/scripts/lib/planning-corpus.js +306 -0
  99. package/.agents/scripts/lib/signals/detectors/common.js +10 -10
  100. package/.agents/scripts/lib/signals/detectors/index.js +4 -4
  101. package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
  102. package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
  103. package/.agents/scripts/lib/signals/schema.js +56 -81
  104. package/.agents/scripts/lib/signals/span-tree.js +6 -5
  105. package/.agents/scripts/lib/story-plan.js +3 -0
  106. package/.agents/scripts/lib/wave-runner/tick.js +10 -2
  107. package/.agents/scripts/lifecycle-emit.js +39 -8
  108. package/.agents/scripts/providers/github/issues.js +12 -1
  109. package/.agents/scripts/resolve-doc-tiers.js +83 -0
  110. package/.agents/scripts/retro-run.js +51 -0
  111. package/.agents/scripts/signals-view.js +1 -1
  112. package/.agents/scripts/single-story-close.js +20 -1
  113. package/.agents/scripts/standalone-feedback-rollup.js +188 -0
  114. package/.agents/scripts/story-close.js +48 -0
  115. package/.agents/scripts/story-plan.js +51 -12
  116. package/.agents/scripts/validate-docs-freshness.js +69 -15
  117. package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
  118. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
  119. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
  120. package/.agents/skills/core/scope-triage/SKILL.md +61 -0
  121. package/.agents/skills/skills.index.json +3 -3
  122. package/.agents/workflows/audit-documentation.md +82 -2
  123. package/.agents/workflows/helpers/code-review.md +193 -44
  124. package/.agents/workflows/helpers/deliver-epic.md +128 -39
  125. package/.agents/workflows/helpers/deliver-stories.md +26 -0
  126. package/.agents/workflows/helpers/epic-audit.md +116 -283
  127. package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
  128. package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
  129. package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
  130. package/.agents/workflows/helpers/plan-epic.md +141 -105
  131. package/.agents/workflows/helpers/plan-story.md +32 -0
  132. package/.agents/workflows/helpers/single-story-deliver.md +43 -0
  133. package/.agents/workflows/loops/nightly-audit.md +9 -7
  134. package/docs/CHANGELOG.md +29 -0
  135. package/lib/cli/doctor.js +44 -0
  136. package/package.json +4 -3
  137. package/.agents/scripts/epic-plan-spec-validate.js +0 -111
  138. package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -207
  139. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
  140. package/.agents/scripts/lib/signals/detectors/hotspot.js +0 -292
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Doc-tier resolver (Story #4438, Epic #4430 — Context Economy).
3
+ *
4
+ * Pure functions that classify the repository's documentation into the four
5
+ * read-tiers Mandrel's context model recognizes, each entry carrying its
6
+ * on-disk byte size so a byte budget can ratchet against it:
7
+ *
8
+ * - `alwaysLoaded` — `CLAUDE.md` plus the transitive closure of its
9
+ * `@`-import references (`@AGENTS.md`,
10
+ * `@.agents/instructions.md`, the persona, the always-on
11
+ * rules, …). This is the context every session re-pays
12
+ * on every subagent spawn (instructions.md § 4), so it is
13
+ * the primary budget the context-budget ratchet gates.
14
+ * - `mandatoryRead` — the resolved `project.docsContextFiles` set (prefixed
15
+ * by `project.paths.docsRoot`), existing files only. This
16
+ * is the `docsContextFiles` half the Epic AC gates; it
17
+ * skips silently when the set is unconfigured or its
18
+ * files are absent.
19
+ * - `digestVisible` — the situational **Conditional Reads** docs from
20
+ * instructions.md § 3 (`docs/style-guide.md`,
21
+ * `docs/web-routes.md`) — surfaced only when a task
22
+ * touches UI/routing, i.e. visible through the docs
23
+ * digest rather than always read. Existing files only.
24
+ * - `onDemand` — the on-demand `.agents/rules/*.md` set (instructions.md
25
+ * § 1.F): every rule file that is **not** part of the
26
+ * always-on core already captured in `alwaysLoaded`.
27
+ *
28
+ * A file that could appear in more than one tier is kept in its **highest**
29
+ * tier only (alwaysLoaded > mandatoryRead > digestVisible > onDemand), so the
30
+ * four arrays partition the doc set with no double-counting.
31
+ *
32
+ * The closure is discovered by parsing `@`-import references and following
33
+ * them recursively (cycle-safe via a visited set). A candidate `@`-token only
34
+ * counts as an import when it resolves to an existing repo file, which
35
+ * naturally filters prose mentions (`@[USERNAME]`, `noreply@example.com`,
36
+ * backtick-wrapped `` `@`-imported `` phrasing).
37
+ *
38
+ * Security (security-baseline § 5 — Data Leakage & Logging): every function
39
+ * emits only repo-relative paths and byte counts — never file contents.
40
+ */
41
+
42
+ import nodeFs from 'node:fs';
43
+ import path from 'node:path';
44
+
45
+ /**
46
+ * Basename of the always-loaded entry document (the root of the closure).
47
+ * @type {string}
48
+ */
49
+ const ENTRY_DOC = 'CLAUDE.md';
50
+
51
+ /**
52
+ * Always-on core rule files (instructions.md § 1.F). These live in the
53
+ * `alwaysLoaded` closure (imported by `CLAUDE.md`); every other
54
+ * `.agents/rules/*.md` file is `onDemand`.
55
+ * @type {string[]}
56
+ */
57
+ const ALWAYS_ON_RULES = ['security-baseline.md', 'git-conventions.md'];
58
+
59
+ /**
60
+ * Conditional-read docs (instructions.md § 3 — "Conditional Reads"), resolved
61
+ * against `project.paths.docsRoot`. Present in the `digestVisible` tier when
62
+ * they exist.
63
+ * @type {string[]}
64
+ */
65
+ const CONDITIONAL_DOCS = ['style-guide.md', 'web-routes.md'];
66
+
67
+ /**
68
+ * Match `@`-import tokens: an `@` at start-of-line or after whitespace,
69
+ * followed by a path token that stops at whitespace or common closing
70
+ * punctuation. The resolved-file existence check downstream is the real
71
+ * filter; this regex only harvests candidates.
72
+ */
73
+ const IMPORT_RE = /(?:^|\s)@([^\s'"`)\]}>,]+)/gm;
74
+
75
+ /**
76
+ * Default fs surface — a small subset of `node:fs` so callers can inject a
77
+ * fixture double in tests without touching the real filesystem.
78
+ * @typedef {{
79
+ * existsSync: (p: string) => boolean,
80
+ * readFileSync: (p: string, enc: string) => string,
81
+ * statSync: (p: string) => { size: number },
82
+ * }} FsLike
83
+ */
84
+
85
+ /**
86
+ * Parse the raw `@`-import specifiers from a source document. A trailing `.`
87
+ * or `:` (sentence punctuation) is trimmed so `@AGENTS.md.` still resolves.
88
+ *
89
+ * @param {string} source
90
+ * @returns {string[]} raw specifiers in first-seen order
91
+ */
92
+ export function parseImportSpecifiers(source) {
93
+ const specs = [];
94
+ for (const m of String(source ?? '').matchAll(IMPORT_RE)) {
95
+ let spec = m[1];
96
+ // Strip trailing sentence punctuation that the greedy class allowed in.
97
+ while (spec.length > 0 && (spec.endsWith('.') || spec.endsWith(':'))) {
98
+ spec = spec.slice(0, -1);
99
+ }
100
+ if (spec.length > 0) specs.push(spec);
101
+ }
102
+ return specs;
103
+ }
104
+
105
+ /**
106
+ * Convert an absolute path to a repo-relative, posix-separated id.
107
+ *
108
+ * @param {string} root absolute repo root
109
+ * @param {string} abs absolute path
110
+ * @returns {string}
111
+ */
112
+ function toRepoRel(root, abs) {
113
+ return path.relative(root, abs).split(path.sep).join('/');
114
+ }
115
+
116
+ /**
117
+ * Build a `{ path, bytes }` entry for a repo file, or `null` when it does not
118
+ * exist. `path` is repo-relative posix; `bytes` is the on-disk byte size.
119
+ *
120
+ * @param {string} root absolute repo root
121
+ * @param {string} rel repo-relative path
122
+ * @param {FsLike} fs
123
+ * @returns {{ path: string, bytes: number } | null}
124
+ */
125
+ function fileEntry(root, rel, fs) {
126
+ const abs = path.resolve(root, rel);
127
+ if (!fs.existsSync(abs)) return null;
128
+ let bytes = 0;
129
+ try {
130
+ bytes = fs.statSync(abs).size;
131
+ } catch {
132
+ return null;
133
+ }
134
+ return { path: toRepoRel(root, abs), bytes };
135
+ }
136
+
137
+ /**
138
+ * Resolve the always-loaded closure: `CLAUDE.md` plus every file reachable by
139
+ * recursively parsing `@`-import references. Cycle-safe (a visited set keyed
140
+ * by repo-relative path). Nested imports resolve relative to the importing
141
+ * file's directory (Claude Code `@`-import semantics). Non-resolving `@`-tokens
142
+ * are ignored, so prose mentions never pollute the closure.
143
+ *
144
+ * @param {string} root absolute repo root (where `CLAUDE.md` lives)
145
+ * @param {{ fs?: FsLike }} [opts]
146
+ * @returns {Array<{ path: string, bytes: number }>} sorted by path; empty when
147
+ * `CLAUDE.md` is absent
148
+ */
149
+ export function resolveAlwaysLoadedClosure(root, { fs = nodeFs } = {}) {
150
+ const entryAbs = path.resolve(root, ENTRY_DOC);
151
+ if (!fs.existsSync(entryAbs)) return [];
152
+
153
+ const visited = new Set();
154
+ const entries = new Map();
155
+ const queue = [entryAbs];
156
+
157
+ while (queue.length > 0) {
158
+ const abs = queue.shift();
159
+ const rel = toRepoRel(root, abs);
160
+ if (visited.has(rel)) continue;
161
+ visited.add(rel);
162
+
163
+ if (!fs.existsSync(abs)) continue;
164
+ let bytes = 0;
165
+ let source = '';
166
+ try {
167
+ bytes = fs.statSync(abs).size;
168
+ source = fs.readFileSync(abs, 'utf8');
169
+ } catch {
170
+ continue;
171
+ }
172
+ entries.set(rel, { path: rel, bytes });
173
+
174
+ const dir = path.dirname(abs);
175
+ for (const spec of parseImportSpecifiers(source)) {
176
+ const targetAbs = path.resolve(dir, spec);
177
+ const targetRel = toRepoRel(root, targetAbs);
178
+ if (!visited.has(targetRel) && fs.existsSync(targetAbs)) {
179
+ queue.push(targetAbs);
180
+ }
181
+ }
182
+ }
183
+
184
+ return [...entries.values()].sort((a, b) => a.path.localeCompare(b.path));
185
+ }
186
+
187
+ /**
188
+ * Read the resolved `project.docsContextFiles` list from a config object,
189
+ * prefixed by `project.paths.docsRoot`. Mirrors the `contextDocs` half of
190
+ * `resolveDocList` in `validate-docs-freshness.js` (new-shape first, legacy
191
+ * top-level fallback).
192
+ *
193
+ * @param {object} config resolved config (`resolveConfig()` output)
194
+ * @returns {string[]} repo-relative posix doc paths (existence not yet checked)
195
+ */
196
+ export function docsContextPaths(config) {
197
+ const project = config?.project ?? config;
198
+ const contextDocs = Array.isArray(project?.docsContextFiles)
199
+ ? project.docsContextFiles
200
+ : Array.isArray(config?.docsContextFiles)
201
+ ? config.docsContextFiles
202
+ : [];
203
+ const docsRoot =
204
+ project?.paths?.docsRoot ?? config?.paths?.docsRoot ?? 'docs';
205
+ return contextDocs.map((f) => path.posix.join(docsRoot, f));
206
+ }
207
+
208
+ /**
209
+ * Resolve the four documentation read-tiers, each entry `{ path, bytes }`,
210
+ * partitioned so no path appears in more than one tier (highest tier wins).
211
+ *
212
+ * @param {object} config resolved config (`resolveConfig()` output)
213
+ * @param {{ root?: string, fs?: FsLike }} [opts]
214
+ * @returns {{ tiers: {
215
+ * alwaysLoaded: Array<{ path: string, bytes: number }>,
216
+ * mandatoryRead: Array<{ path: string, bytes: number }>,
217
+ * digestVisible: Array<{ path: string, bytes: number }>,
218
+ * onDemand: Array<{ path: string, bytes: number }>,
219
+ * } }}
220
+ */
221
+ export function resolveDocTiers(
222
+ config,
223
+ { root = process.cwd(), fs = nodeFs } = {},
224
+ ) {
225
+ const claimed = new Set();
226
+ const collect = (relPaths) => {
227
+ const out = [];
228
+ for (const rel of relPaths) {
229
+ const entry = fileEntry(root, rel, fs);
230
+ if (!entry) continue;
231
+ if (claimed.has(entry.path)) continue;
232
+ claimed.add(entry.path);
233
+ out.push(entry);
234
+ }
235
+ return out.sort((a, b) => a.path.localeCompare(b.path));
236
+ };
237
+
238
+ // 1. always-loaded: CLAUDE.md @-import closure. Pre-claim its paths first so
239
+ // a lower tier never re-lists a closure member.
240
+ const alwaysLoaded = resolveAlwaysLoadedClosure(root, { fs });
241
+ for (const e of alwaysLoaded) claimed.add(e.path);
242
+
243
+ // 2. mandatory-read: resolved docsContextFiles (existing files only).
244
+ const docsRoot =
245
+ config?.project?.paths?.docsRoot ?? config?.paths?.docsRoot ?? 'docs';
246
+ const mandatoryRead = collect(docsContextPaths(config));
247
+
248
+ // 3. digest-visible: situational Conditional-Read docs.
249
+ const digestVisible = collect(
250
+ CONDITIONAL_DOCS.map((f) => path.posix.join(docsRoot, f)),
251
+ );
252
+
253
+ // 4. on-demand: every .agents/rules/*.md that is not an always-on core rule
254
+ // (the always-on ones already live in the alwaysLoaded closure).
255
+ const onDemand = collect(listOnDemandRules(root, fs));
256
+
257
+ return { tiers: { alwaysLoaded, mandatoryRead, digestVisible, onDemand } };
258
+ }
259
+
260
+ /**
261
+ * List the on-demand rule files (repo-relative posix): every `.agents/rules/
262
+ * *.md` whose basename is not an always-on core rule. Returns [] when the
263
+ * rules directory is absent.
264
+ *
265
+ * @param {string} root absolute repo root
266
+ * @param {FsLike} fs
267
+ * @returns {string[]}
268
+ */
269
+ function listOnDemandRules(root, fs) {
270
+ const rulesDir = path.resolve(root, '.agents', 'rules');
271
+ let names;
272
+ try {
273
+ names = fs.readdirSync(rulesDir);
274
+ } catch {
275
+ return [];
276
+ }
277
+ return names
278
+ .filter((n) => n.endsWith('.md') && !ALWAYS_ON_RULES.includes(n))
279
+ .map((n) => path.posix.join('.agents', 'rules', n))
280
+ .sort();
281
+ }
282
+
283
+ /**
284
+ * Sum the `bytes` of every entry in a tier array.
285
+ *
286
+ * @param {Array<{ bytes: number }>} entries
287
+ * @returns {number}
288
+ */
289
+ export function tierTotalBytes(entries) {
290
+ return (entries ?? []).reduce((sum, e) => sum + (e?.bytes ?? 0), 0);
291
+ }
@@ -54,9 +54,12 @@ export const ACCEPTANCE_TABLE_HEADING = '## Acceptance Table';
54
54
 
55
55
  /**
56
56
  * Regex matching the Tech Spec's required opening heading (same variants
57
- * `spec-section-validator.js` accepts).
57
+ * `spec-section-validator.js` accepts). Exported so it is the single
58
+ * source of truth for this pattern — `spec-section-validator.js`,
59
+ * `epic-plan-clarity.js`, and `consolidation-precondition.js` all import
60
+ * it rather than each carrying their own copy.
58
61
  */
59
- const DELIVERY_SLICING_RE = /^##\s+(?:Delivery\s+)?Slicing\s*$/im;
62
+ export const DELIVERY_SLICING_RE = /^##\s+(?:Delivery\s+)?Slicing\s*$/im;
60
63
 
61
64
  /**
62
65
  * @param {'techSpec'|'acceptanceTable'} kind
@@ -237,3 +237,86 @@ export function releaseEpicMergeLock(handle, fsImpl = fs) {
237
237
  if (err.code !== 'ENOENT') throw err;
238
238
  }
239
239
  }
240
+
241
+ const LOCK_FILE_PREFIX = 'epic-';
242
+ const LOCK_FILE_SUFFIX = '.merge.lock';
243
+
244
+ // Extract the epic id from a lock filename, or `null` when `entry` isn't a
245
+ // `epic-*.merge.lock` file. Split out of `findForeignActiveEpicLock` so
246
+ // that function stays a flat filter+map instead of a nested-conditional
247
+ // loop body (Story #4460).
248
+ function parseLockFileEpicId(entry) {
249
+ if (
250
+ !entry.startsWith(LOCK_FILE_PREFIX) ||
251
+ !entry.endsWith(LOCK_FILE_SUFFIX)
252
+ ) {
253
+ return null;
254
+ }
255
+ return entry.slice(
256
+ LOCK_FILE_PREFIX.length,
257
+ entry.length - LOCK_FILE_SUFFIX.length,
258
+ );
259
+ }
260
+
261
+ // Read `<dir>/<entry>`'s lock meta and report it only when the recorded
262
+ // pid is still alive — a foreign lock whose pid is dead (or whose meta is
263
+ // unreadable/corrupt) is stale debris, not an active holder, mirroring the
264
+ // pid-liveness half of `tryStealStale`'s heuristic.
265
+ function readLiveLock(dir, entry, otherEpicId, fsImpl, killFn) {
266
+ const filePath = path.join(dir, entry);
267
+ const meta = readLockMeta(filePath, fsImpl);
268
+ if (!meta || !isProcessRunning(meta.pid, killFn)) return null;
269
+ return {
270
+ epicId: otherEpicId,
271
+ filePath,
272
+ pid: meta.pid,
273
+ acquiredAt: meta.acquiredAt,
274
+ };
275
+ }
276
+
277
+ /**
278
+ * Scan the shared common `.git/` dir for a *different* epic's live
279
+ * merge lock (Story #4460 — cross-epic shared-checkout guard).
280
+ *
281
+ * `acquireEpicMergeLock` only ever contends against locks for the *same*
282
+ * `epicId` (its own lock filename). This helper is the cross-epic
283
+ * counterpart: it lists every `epic-*.merge.lock` file in the common
284
+ * gitdir, skips the caller's own `epicId` namespace, and returns the
285
+ * first foreign lock whose recorded `pid` is still alive.
286
+ *
287
+ * @param {number|string} epicId Caller's own epic id (excluded from the scan).
288
+ * @param {{
289
+ * repoRoot: string,
290
+ * fsImpl?: object,
291
+ * killFn?: (pid:number, signal:number)=>void,
292
+ * }} opts
293
+ * @returns {{ epicId: string, filePath: string, pid: number, acquiredAt: number }|null}
294
+ */
295
+ export function findForeignActiveEpicLock(
296
+ epicId,
297
+ { repoRoot, fsImpl = fs, killFn = process.kill.bind(process) } = {},
298
+ ) {
299
+ if (!repoRoot) {
300
+ throw new Error('findForeignActiveEpicLock: repoRoot is required');
301
+ }
302
+ const dir = resolveGitCommonDir(repoRoot, fsImpl);
303
+ let entries;
304
+ try {
305
+ entries = fsImpl.readdirSync(dir);
306
+ } catch {
307
+ return null;
308
+ }
309
+
310
+ const foreignEntries = entries
311
+ .map((entry) => ({ entry, otherEpicId: parseLockFileEpicId(entry) }))
312
+ .filter(
313
+ ({ otherEpicId }) =>
314
+ otherEpicId !== null && String(otherEpicId) !== String(epicId),
315
+ );
316
+
317
+ for (const { entry, otherEpicId } of foreignEntries) {
318
+ const live = readLiveLock(dir, entry, otherEpicId, fsImpl, killFn);
319
+ if (live) return live;
320
+ }
321
+ return null;
322
+ }
@@ -40,6 +40,8 @@
40
40
  * Pure ESM, no I/O.
41
41
  */
42
42
 
43
+ import { DELIVERY_SLICING_RE } from './epic-body-sections.js';
44
+
43
45
  const SECTION_RE = {
44
46
  context:
45
47
  /^##\s+(?:Context(?:\s+&\s+Problem)?|Background|Problem(?:\s+Statement)?)\s*$/im,
@@ -69,7 +71,7 @@ export const SECTION_NAMES = Object.freeze([
69
71
  * `spec-section-validator.js`.
70
72
  */
71
73
  const PLANNING_SECTION_RE = {
72
- deliverySlicing: /^##\s+(?:Delivery\s+)?Slicing\s*$/im,
74
+ deliverySlicing: DELIVERY_SLICING_RE,
73
75
  acceptanceTable: /^##\s+Acceptance\s+Table\s*$/im,
74
76
  };
75
77
 
@@ -1,7 +1,15 @@
1
1
  /**
2
2
  * audit-results-graduator.js — Auto-graduate non-blocking audit findings
3
- * from the Epic's `audit-results` structured comment into routed GitHub
4
- * follow-up issues. Story #2615 / Epic #2586.
3
+ * from the Epic's unified `verification-results` structured comment into
4
+ * routed GitHub follow-up issues. Story #2615 / Epic #2586.
5
+ *
6
+ * Story #4411 (Epic #4405) unified the former `audit-results` and
7
+ * `code-review` structured-comment contracts into one `verification-results`
8
+ * contract: this graduator now reads the shared
9
+ * {@link VERIFICATION_RESULTS_MARKER} comment rather than the retired
10
+ * audit-results structured-comment marker. The lens-aware finding
11
+ * parser, the `audit-results::<severity>` + `domain::<lens>` label shape,
12
+ * and the audit idempotency marker are unchanged.
5
13
  *
6
14
  * As of Story #3845 / Epic #3823 the spawn helper, the path/idempotency
7
15
  * probes, the `gh issue create` filer, the toggle reader, and the
@@ -12,8 +20,8 @@
12
20
  * title / body shape, and the audit idempotency marker. Behaviour is
13
21
  * identical to the pre-consolidation graduator.
14
22
  *
15
- * - Read the `audit-results` structured comment off the Epic ticket
16
- * via the injected provider (`getTicketComments`).
23
+ * - Read the unified `verification-results` structured comment off the
24
+ * Epic ticket via the injected provider (`getTicketComments`).
17
25
  * - For each non-blocking finding (severity high/medium/low/suggestion
18
26
  * — i.e. anything that is NOT a 🔴 Critical Blocker), check that
19
27
  * the cited file still exists in the merged tree.
@@ -38,23 +46,22 @@
38
46
  */
39
47
 
40
48
  import {
49
+ contentFingerprint,
41
50
  graduate,
42
51
  makeIsAutoFileEnabled,
43
- probePathExists,
52
+ NO_VERIFICATION_RESULTS_COMMENT_REASON,
53
+ VERIFICATION_RESULTS_MARKER,
44
54
  } from './graduator-core.js';
45
55
 
46
56
  /**
47
57
  * Resolve the toggle from the resolved agentrc config. Defaults to `true`
48
- * — the feature is opt-out, not opt-in (mirrors codeReviewAutoFile).
58
+ * — the feature is opt-out, not opt-in.
49
59
  *
50
60
  * @param {object|undefined|null} config
51
61
  * @returns {boolean}
52
62
  */
53
63
  export const isAutoFileEnabled = makeIsAutoFileEnabled('auditResultsAutoFile');
54
64
 
55
- // Re-export the shared path probe so existing importers keep working.
56
- export { probePathExists };
57
-
58
65
  /**
59
66
  * Severity → label mapping. Only non-blocking severities have a route;
60
67
  * 🔴 Critical Blocker is explicitly filtered out upstream.
@@ -79,9 +86,12 @@ function metaSourceLabel(source) {
79
86
  }
80
87
 
81
88
  /**
82
- * Build the idempotency marker for a given epicId / finding index. An
83
- * HTML comment so it survives markdown rendering without leaking into
84
- * the visible body, but stays indexable via `gh search`.
89
+ * Build the **legacy** idempotency marker for a given epicId / finding
90
+ * index. Superseded by the content-hash marker
91
+ * ({@link buildContentMarker}) at the Story #4415 cutover, but still
92
+ * probed for so follow-ups filed before the cutover are recognized and
93
+ * not re-filed. An HTML comment so it survives markdown rendering without
94
+ * leaking into the visible body, but stays indexable via `gh search`.
85
95
  *
86
96
  * @param {number} epicId
87
97
  * @param {number} index — zero-based finding ordinal within the Epic.
@@ -91,6 +101,26 @@ export function buildIdempotencyMarker(epicId, index) {
91
101
  return `<!-- audit-results-followup: epic-${epicId}-finding-${index} -->`;
92
102
  }
93
103
 
104
+ /**
105
+ * Build the content-hash idempotency marker embedded in freshly filed
106
+ * follow-up bodies. Derived from the finding's `lens|path|summary` triple
107
+ * so the marker is stable across sibling insert/remove/reorder churn in
108
+ * the source `audit-results` comment (Story #4415). An HTML comment so it
109
+ * survives markdown rendering but stays indexable via `gh search`.
110
+ *
111
+ * @param {number} epicId
112
+ * @param {{ lens?: string, path?: string, summary?: string }} finding
113
+ * @returns {string}
114
+ */
115
+ export function buildContentMarker(epicId, finding) {
116
+ const fp = contentFingerprint({
117
+ category: finding.lens,
118
+ path: finding.path,
119
+ title: finding.summary,
120
+ });
121
+ return `<!-- audit-results-followup: epic-${epicId}-${fp} -->`;
122
+ }
123
+
94
124
  /**
95
125
  * Parse the rendered audit-results markdown into a list of findings.
96
126
  * The format produced by `epic-audit.md` Step 4 groups findings under
@@ -98,6 +128,13 @@ export function buildIdempotencyMarker(epicId, index) {
98
128
  * emoji and embeds the cited path inside backticks. 🔴 critical findings
99
129
  * are filtered out (they're blocking — the Epic stops on those).
100
130
  *
131
+ * Findings the Phase 4 remediation loop already fixed on-branch are
132
+ * rendered under a **"Fixed on-branch"** heading (Story #4399) with a ✅
133
+ * prefix so they no longer parse as open findings. As a belt-and-suspenders
134
+ * guard the parser also skips every line inside a Fixed-on-branch section
135
+ * outright, so a remediated 🟡 Medium never spawns a ghost follow-up issue
136
+ * even if its line retains its original severity emoji.
137
+ *
101
138
  * Pure. Exported so the parser can be unit-tested in isolation.
102
139
  *
103
140
  * @param {string} body
@@ -109,18 +146,25 @@ export function parseFindings(body) {
109
146
  const lines = body.split(/\r?\n/);
110
147
  let idx = 0;
111
148
  let lens = 'unknown';
149
+ let inFixedSection = false;
112
150
  for (const rawLine of lines) {
113
151
  const trimmed = rawLine.trim();
114
152
  if (trimmed.length === 0) continue;
115
153
 
116
- // Detect a lens heading. We accept any heading-prefixed line that
117
- // names a known audit family (`audit-*`).
118
- const lensMatch = trimmed.match(/^#{2,6}\s+(audit-[a-z0-9-]+)/i);
119
- if (lensMatch) {
120
- lens = lensMatch[1];
154
+ // Any markdown heading resets the Fixed-on-branch guard and, when it
155
+ // names a known audit family (`audit-*`), sets the active lens. A
156
+ // "Fixed on-branch" heading opens a section whose entries never
157
+ // graduate (Story #4399).
158
+ const headingMatch = trimmed.match(/^#{2,6}\s+(.+)$/);
159
+ if (headingMatch) {
160
+ inFixedSection = /fixed on-branch/i.test(headingMatch[1]);
161
+ const lensMatch = headingMatch[1].match(/^(audit-[a-z0-9-]+)/i);
162
+ if (lensMatch) lens = lensMatch[1];
121
163
  continue;
122
164
  }
123
165
 
166
+ if (inFixedSection) continue;
167
+
124
168
  let severity = null;
125
169
  if (trimmed.startsWith('🔴')) {
126
170
  // Critical Blocker — skip; never graduates.
@@ -180,10 +224,12 @@ export async function graduateAuditResults(opts = {}) {
180
224
  spec: {
181
225
  fnName: 'graduateAuditResults',
182
226
  isAutoFileEnabled,
183
- commentMarker: '<!-- claude-managed: audit-results -->',
184
- noCommentReason: 'no-audit-results-comment',
227
+ commentMarker: VERIFICATION_RESULTS_MARKER,
228
+ noCommentReason: NO_VERIFICATION_RESULTS_COMMENT_REASON,
185
229
  parseFindings,
186
- buildIdempotencyMarker,
230
+ buildContentMarker,
231
+ buildLegacyMarker: buildIdempotencyMarker,
232
+ crossRepoCommentAttrs: { graduator: 'audit-results' },
187
233
  decorateRecord: (record, finding) => {
188
234
  record.lens = finding.lens;
189
235
  return record;