mandrel 2.16.0 → 2.18.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 (69) hide show
  1. package/.agents/docs/agentrc-reference.json +10 -0
  2. package/.agents/docs/configuration.md +9 -0
  3. package/.agents/docs/quality-gates.md +137 -0
  4. package/.agents/schemas/agentrc.schema.json +48 -0
  5. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  6. package/.agents/schemas/baselines/crap.schema.json +4 -0
  7. package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
  8. package/.agents/scripts/acceptance-eval.js +52 -12
  9. package/.agents/scripts/audit-to-stories.js +92 -25
  10. package/.agents/scripts/boot-sweep.js +67 -8
  11. package/.agents/scripts/check-baseline-drift.js +138 -0
  12. package/.agents/scripts/coverage-capture.js +74 -25
  13. package/.agents/scripts/deliver-recover.js +45 -18
  14. package/.agents/scripts/drain-pending-cleanup.js +67 -23
  15. package/.agents/scripts/generate-lens-checklists.js +81 -30
  16. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +88 -17
  17. package/.agents/scripts/lib/baselines/drift-detector.js +351 -0
  18. package/.agents/scripts/lib/baselines/envelope.js +7 -0
  19. package/.agents/scripts/lib/baselines/kernel.js +31 -0
  20. package/.agents/scripts/lib/baselines/kinds/crap.js +76 -0
  21. package/.agents/scripts/lib/baselines/reader.js +12 -1
  22. package/.agents/scripts/lib/baselines/refresh-service.js +7 -1
  23. package/.agents/scripts/lib/baselines/writer.js +10 -0
  24. package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +23 -8
  25. package/.agents/scripts/lib/cli-utils.js +48 -13
  26. package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
  27. package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
  28. package/.agents/scripts/lib/close-validation/runner.js +68 -0
  29. package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
  30. package/.agents/scripts/lib/config/quality.js +40 -0
  31. package/.agents/scripts/lib/config/temp-paths.js +27 -0
  32. package/.agents/scripts/lib/config-settings-schema-delivery.js +69 -0
  33. package/.agents/scripts/lib/coverage-utils.js +92 -9
  34. package/.agents/scripts/lib/crap-engine.js +113 -23
  35. package/.agents/scripts/lib/crap-utils.js +159 -93
  36. package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
  37. package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
  38. package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
  39. package/.agents/scripts/lib/observability/terse-result.js +7 -3
  40. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
  41. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
  42. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
  43. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +19 -41
  44. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
  45. package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +9 -5
  46. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +15 -1
  47. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +31 -1
  48. package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
  49. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
  50. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
  51. package/.agents/scripts/lib/single-story-sweep.js +11 -0
  52. package/.agents/scripts/lib/stdio-flush.js +71 -0
  53. package/.agents/scripts/lib/temp-retention.js +559 -0
  54. package/.agents/scripts/lib/transpile.js +133 -6
  55. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
  56. package/.agents/scripts/lib/workers/crap-worker.js +49 -76
  57. package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
  58. package/.agents/scripts/nav-registry-diff.js +30 -8
  59. package/.agents/scripts/plan-run-epilogue.js +27 -11
  60. package/.agents/scripts/resolve-doc-tiers.js +18 -8
  61. package/.agents/scripts/single-story-close.js +9 -92
  62. package/.agents/scripts/single-story-init.js +1 -1
  63. package/.agents/scripts/sync-branch-from-base.js +6 -1
  64. package/.agents/scripts/update-crap-baseline.js +13 -0
  65. package/README.md +14 -6
  66. package/docs/CHANGELOG.md +36 -0
  67. package/lib/cli/version-helpers.js +7 -0
  68. package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +15 -8
  69. package/package.json +5 -1
@@ -39,8 +39,44 @@ const progress = Logger.createProgress('drain-pending-cleanup', {
39
39
  stderr: false,
40
40
  });
41
41
 
42
- async function main() {
42
+ /**
43
+ * The drain core, extracted from the CLI shell so the whole decision table —
44
+ * empty manifest, dry-run report, drain + escalate reporting — is reachable
45
+ * without a real worktree ledger or a real process kill.
46
+ *
47
+ * Every seam on the optional final `deps` parameter defaults to the real
48
+ * implementation (`.agents/rules/test-seams.md` rules 1-2, 4), so `main` and
49
+ * every production caller are unchanged.
50
+ *
51
+ * @param {string[]} [argv]
52
+ * @param {{
53
+ * resolveConfigImpl?: typeof resolveConfig,
54
+ * readManifestImpl?: typeof readManifest,
55
+ * findHoldersInPathImpl?: typeof findHoldersInPath,
56
+ * forceDrainImpl?: typeof forceDrainPendingCleanup,
57
+ * gitImpl?: typeof gitUtils,
58
+ * projectRoot?: string,
59
+ * progressImpl?: (phase: string, message: string) => void,
60
+ * logger?: { error: Function },
61
+ * }} [deps]
62
+ * @returns {Promise<{ drained: number[], remaining: number } | { remaining: number }>}
63
+ */
64
+ export async function runDrainPendingCleanup(
65
+ argv = process.argv.slice(2),
66
+ deps = {},
67
+ ) {
68
+ const {
69
+ resolveConfigImpl = resolveConfig,
70
+ readManifestImpl = readManifest,
71
+ findHoldersInPathImpl = findHoldersInPath,
72
+ forceDrainImpl = forceDrainPendingCleanup,
73
+ gitImpl = gitUtils,
74
+ projectRoot = PROJECT_ROOT,
75
+ progressImpl = progress,
76
+ logger = Logger,
77
+ } = deps;
43
78
  const { values } = parseArgs({
79
+ args: argv,
44
80
  options: {
45
81
  escalate: { type: 'boolean', default: true },
46
82
  'dry-run': { type: 'boolean', default: false },
@@ -49,20 +85,23 @@ async function main() {
49
85
  strict: false,
50
86
  });
51
87
 
52
- const config = resolveConfig();
88
+ const config = resolveConfigImpl();
53
89
  const wtConfig = config.delivery?.worktreeIsolation;
54
90
  const worktreeRoot = path.resolve(
55
- PROJECT_ROOT,
91
+ projectRoot,
56
92
  values['worktree-root'] ?? wtConfig?.root ?? '.worktrees',
57
93
  );
58
94
 
59
- const before = readManifest(worktreeRoot);
95
+ const before = readManifestImpl(worktreeRoot);
60
96
  if (before.length === 0) {
61
- progress('SCAN', 'pending-cleanup manifest is empty — nothing to drain.');
62
- return;
97
+ progressImpl(
98
+ 'SCAN',
99
+ 'pending-cleanup manifest is empty — nothing to drain.',
100
+ );
101
+ return { remaining: 0 };
63
102
  }
64
103
 
65
- progress(
104
+ progressImpl(
66
105
  'SCAN',
67
106
  `pending-cleanup manifest has ${before.length} entry(ies): ${before
68
107
  .map((e) => `story-${e.storyId}(attempts=${e.attempts ?? 0})`)
@@ -71,8 +110,8 @@ async function main() {
71
110
 
72
111
  if (values['dry-run']) {
73
112
  for (const entry of before) {
74
- const holders = findHoldersInPath(entry.path);
75
- progress(
113
+ const holders = findHoldersInPathImpl(entry.path);
114
+ progressImpl(
76
115
  'DRY-RUN',
77
116
  `story-${entry.storyId} path=${entry.path} holders=${holders.length}` +
78
117
  (holders.length > 0
@@ -80,23 +119,23 @@ async function main() {
80
119
  : ''),
81
120
  );
82
121
  }
83
- return;
122
+ return { remaining: before.length };
84
123
  }
85
124
 
86
- const result = await forceDrainPendingCleanup({
87
- repoRoot: PROJECT_ROOT,
125
+ const result = await forceDrainImpl({
126
+ repoRoot: projectRoot,
88
127
  worktreeRoot,
89
- git: gitUtils,
128
+ git: gitImpl,
90
129
  escalate: values.escalate,
91
130
  logger: {
92
- info: (m) => progress('DRAIN', m),
93
- warn: (m) => progress('DRAIN', `⚠️ ${m}`),
94
- error: (m) => Logger.error(`[drain-pending-cleanup] ${m}`),
131
+ info: (m) => progressImpl('DRAIN', m),
132
+ warn: (m) => progressImpl('DRAIN', `⚠️ ${m}`),
133
+ error: (m) => logger.error(`[drain-pending-cleanup] ${m}`),
95
134
  },
96
135
  });
97
136
 
98
137
  if (result.drained.length > 0) {
99
- progress(
138
+ progressImpl(
100
139
  'DRAIN',
101
140
  `✅ drained ${result.drained.length} entry(ies): ${result.drained
102
141
  .map((id) => `story-${id}`)
@@ -107,10 +146,10 @@ async function main() {
107
146
  const summary = result.escalated
108
147
  .map((id) => `story-${id}=[${(result.killedPids[id] ?? []).join(',')}]`)
109
148
  .join(', ');
110
- progress('ESCALATE', `terminated holders: ${summary}`);
149
+ progressImpl('ESCALATE', `terminated holders: ${summary}`);
111
150
  }
112
151
  if (result.noHolders && result.noHolders.length > 0) {
113
- progress(
152
+ progressImpl(
114
153
  'ESCALATE',
115
154
  `⚠️ no user-mode holders for: ${result.noHolders
116
155
  .map((id) => `story-${id}`)
@@ -120,7 +159,7 @@ async function main() {
120
159
  );
121
160
  }
122
161
  if (result.persistent.length > 0) {
123
- progress(
162
+ progressImpl(
124
163
  'PERSIST',
125
164
  `⚠️ persistent-lock remains on: ${result.persistent
126
165
  .map((id) => `story-${id}`)
@@ -128,7 +167,7 @@ async function main() {
128
167
  );
129
168
  }
130
169
  if (result.stillPending.length > 0) {
131
- progress(
170
+ progressImpl(
132
171
  'STILL-PENDING',
133
172
  `⚠️ still-pending (below threshold): ${result.stillPending
134
173
  .map((id) => `story-${id}`)
@@ -136,12 +175,17 @@ async function main() {
136
175
  );
137
176
  }
138
177
 
139
- const after = readManifest(worktreeRoot);
140
- progress(
178
+ const after = readManifestImpl(worktreeRoot);
179
+ progressImpl(
141
180
  'DONE',
142
181
  `pending-cleanup manifest now has ${after.length} entry(ies). ` +
143
182
  `Drained=${result.drained.length}, escalated=${result.escalated.length}, persistent=${result.persistent.length}.`,
144
183
  );
184
+ return { drained: result.drained, remaining: after.length };
185
+ }
186
+
187
+ async function main() {
188
+ await runDrainPendingCleanup();
145
189
  }
146
190
 
147
191
  runAsCli(import.meta.url, main, {
@@ -83,16 +83,21 @@ export function planChecklists(lenses, workflowExists, readWorkflow) {
83
83
  * content; `missing` lists lenses with no `audit-<lens>.md`; `strays` lists
84
84
  * on-disk checklist basenames that map to no current lens.
85
85
  */
86
- export function buildExpected() {
87
- const workflowPath = (lens) => path.join(WORKFLOWS_DIR, `audit-${lens}.md`);
86
+ export function buildExpected({
87
+ fsImpl = fs,
88
+ lenses = AUDIT_LENSES,
89
+ workflowsDir = WORKFLOWS_DIR,
90
+ checklistsDir = CHECKLISTS_DIR,
91
+ } = {}) {
92
+ const workflowPath = (lens) => path.join(workflowsDir, `audit-${lens}.md`);
88
93
  const { expected, missing } = planChecklists(
89
- AUDIT_LENSES,
90
- (lens) => fs.existsSync(workflowPath(lens)),
91
- (lens) => fs.readFileSync(workflowPath(lens), 'utf8'),
94
+ lenses,
95
+ (lens) => fsImpl.existsSync(workflowPath(lens)),
96
+ (lens) => fsImpl.readFileSync(workflowPath(lens), 'utf8'),
92
97
  );
93
98
 
94
- const onDisk = fs.existsSync(CHECKLISTS_DIR)
95
- ? fs.readdirSync(CHECKLISTS_DIR).filter((name) => name.endsWith('.md'))
99
+ const onDisk = fsImpl.existsSync(checklistsDir)
100
+ ? fsImpl.readdirSync(checklistsDir).filter((name) => name.endsWith('.md'))
96
101
  : [];
97
102
  const strays = onDisk.filter((name) => !expected.has(name));
98
103
 
@@ -101,29 +106,69 @@ export function buildExpected() {
101
106
 
102
107
  /**
103
108
  * @param {string} basename — e.g. `security.md`
109
+ * @param {string} [checklistsDir]
110
+ * @param {string} [projectRoot]
104
111
  * @returns {string} repo-relative POSIX path for messages.
105
112
  */
106
- function relChecklist(basename) {
113
+ function relChecklist(
114
+ basename,
115
+ checklistsDir = CHECKLISTS_DIR,
116
+ projectRoot = PROJECT_ROOT,
117
+ ) {
107
118
  return path
108
- .relative(PROJECT_ROOT, path.join(CHECKLISTS_DIR, basename))
119
+ .relative(projectRoot, path.join(checklistsDir, basename))
109
120
  .split(path.sep)
110
121
  .join('/');
111
122
  }
112
123
 
113
124
  /**
114
- * @param {string[]} argv
125
+ * The generator core, extracted from the CLI shell so both modes — the
126
+ * `--check` drift gate and the write/prune pass — are reachable without
127
+ * touching the real `.agents/audit-checklists` tree.
128
+ *
129
+ * Every seam on the optional final `deps` parameter defaults to the real
130
+ * implementation (`.agents/rules/test-seams.md` rules 1-2, 4), so `main` and
131
+ * `npm run docs:check` are unchanged.
132
+ *
133
+ * @param {string[]} [argv]
134
+ * @param {{
135
+ * fsImpl?: typeof fs,
136
+ * lenses?: ReadonlyArray<string>,
137
+ * workflowsDir?: string,
138
+ * checklistsDir?: string,
139
+ * projectRoot?: string,
140
+ * logger?: { info: Function },
141
+ * }} [deps]
142
+ * @returns {Promise<{ wrote: number, pruned: number, checked?: boolean }>}
115
143
  */
116
- async function main(argv = process.argv.slice(2)) {
144
+ export async function runGenerateLensChecklists(
145
+ argv = process.argv.slice(2),
146
+ deps = {},
147
+ ) {
148
+ const {
149
+ fsImpl = fs,
150
+ lenses = AUDIT_LENSES,
151
+ workflowsDir = WORKFLOWS_DIR,
152
+ checklistsDir = CHECKLISTS_DIR,
153
+ projectRoot = PROJECT_ROOT,
154
+ logger = Logger,
155
+ } = deps;
117
156
  const { values } = parseArgs({
118
157
  args: argv,
119
158
  options: { check: { type: 'boolean', default: false } },
120
159
  allowPositionals: false,
121
160
  });
122
161
 
123
- const { expected, missing, strays } = buildExpected();
162
+ const { expected, missing, strays } = buildExpected({
163
+ fsImpl,
164
+ lenses,
165
+ workflowsDir,
166
+ checklistsDir,
167
+ });
168
+ const rel = (basename) => relChecklist(basename, checklistsDir, projectRoot);
124
169
 
125
170
  if (missing.length > 0) {
126
- Logger.info(
171
+ logger.info(
127
172
  `generate-lens-checklists: no audit-<lens>.md for: ${missing.join(', ')} — no checklist emitted.`,
128
173
  );
129
174
  }
@@ -131,21 +176,21 @@ async function main(argv = process.argv.slice(2)) {
131
176
  if (values.check) {
132
177
  const drifted = [];
133
178
  for (const [basename, content] of expected) {
134
- const target = path.join(CHECKLISTS_DIR, basename);
135
- const original = fs.existsSync(target)
136
- ? fs.readFileSync(target, 'utf8')
179
+ const target = path.join(checklistsDir, basename);
180
+ const original = fsImpl.existsSync(target)
181
+ ? fsImpl.readFileSync(target, 'utf8')
137
182
  : null;
138
- if (original !== content) drifted.push(relChecklist(basename));
183
+ if (original !== content) drifted.push(rel(basename));
139
184
  }
140
185
  if (drifted.length === 0 && strays.length === 0) {
141
- Logger.info(
186
+ logger.info(
142
187
  `generate-lens-checklists: ${expected.size} checklist(s) up to date.`,
143
188
  );
144
- return;
189
+ return { wrote: 0, pruned: 0, checked: true };
145
190
  }
146
191
  const problems = [
147
192
  ...drifted.map((p) => `out of date: ${p}`),
148
- ...strays.map((s) => `stray (no lens): ${relChecklist(s)}`),
193
+ ...strays.map((s) => `stray (no lens): ${rel(s)}`),
149
194
  ];
150
195
  throw new Error(
151
196
  `Lens checklists are out of sync:\n ${problems.join('\n ')}\n` +
@@ -153,26 +198,32 @@ async function main(argv = process.argv.slice(2)) {
153
198
  );
154
199
  }
155
200
 
156
- fs.mkdirSync(CHECKLISTS_DIR, { recursive: true });
201
+ fsImpl.mkdirSync(checklistsDir, { recursive: true });
157
202
  let wrote = 0;
158
203
  for (const [basename, content] of expected) {
159
- const target = path.join(CHECKLISTS_DIR, basename);
160
- const original = fs.existsSync(target)
161
- ? fs.readFileSync(target, 'utf8')
204
+ const target = path.join(checklistsDir, basename);
205
+ const original = fsImpl.existsSync(target)
206
+ ? fsImpl.readFileSync(target, 'utf8')
162
207
  : null;
163
208
  if (original === content) continue;
164
- fs.writeFileSync(target, content, 'utf8');
209
+ fsImpl.writeFileSync(target, content, 'utf8');
165
210
  wrote += 1;
166
211
  }
167
212
  for (const stray of strays) {
168
- fs.rmSync(path.join(CHECKLISTS_DIR, stray));
169
- Logger.info(
170
- `generate-lens-checklists: pruned stray ${relChecklist(stray)}`,
171
- );
213
+ fsImpl.rmSync(path.join(checklistsDir, stray));
214
+ logger.info(`generate-lens-checklists: pruned stray ${rel(stray)}`);
172
215
  }
173
- Logger.info(
216
+ logger.info(
174
217
  `generate-lens-checklists: wrote ${wrote} of ${expected.size} checklist(s) (${strays.length} pruned).`,
175
218
  );
219
+ return { wrote, pruned: strays.length };
220
+ }
221
+
222
+ /**
223
+ * @param {string[]} [argv]
224
+ */
225
+ async function main(argv = process.argv.slice(2)) {
226
+ await runGenerateLensChecklists(argv);
176
227
  }
177
228
 
178
229
  export { CHECKLISTS_DIR, WORKFLOWS_DIR };
@@ -31,6 +31,9 @@ const HEADING_FINDING = /^###\s+(.+?)\s*$/;
31
31
  const HEADING_SECTION = /^##\s+(.+?)\s*$/;
32
32
  const PATH_HINT =
33
33
  /(?<![\w/])([A-Za-z0-9_./\\@-]+\.(?:js|ts|tsx|jsx|mjs|cjs|md|json|yaml|yml|css|scss|html|py|go|rs|java|kt|rb|sh|ps1|tf|env))(?![\w])/g;
34
+ const FILE_EXT =
35
+ /\.(?:js|ts|tsx|jsx|mjs|cjs|md|json|yaml|yml|css|scss|html|py|go|rs|java|kt|rb|sh|ps1|tf|env)$/;
36
+ const TITLE_ANCHOR = /^\s*`([^`]+)`/;
34
37
 
35
38
  function unwrapInlineCode(value) {
36
39
  if (typeof value !== 'string') return '';
@@ -81,17 +84,81 @@ function deriveDimension(fields, fallbackDimension) {
81
84
  * @param {Record<string, string>} fields
82
85
  * @returns {string[]}
83
86
  */
84
- function deriveLocationFiles(fields) {
87
+ /**
88
+ * Normalise a raw path token to a repo-relative path, or `null` when the
89
+ * token is not a usable file reference.
90
+ *
91
+ * Three shapes historically leaked through and poisoned grouping downstream:
92
+ * an **absolute** path (which can never match a repo-relative group key), a
93
+ * **degenerate** token such as a bare `/`, and — most damagingly — a
94
+ * **root-level** file such as `AGENTS.md`, which the old slash-only guard
95
+ * discarded outright even when it was the finding's explicit `Location:`.
96
+ *
97
+ * `requireSeparator` is what keeps that third fix from over-reaching. In the
98
+ * **structured** fields — the title anchor and `Location:` — a bare
99
+ * `AGENTS.md` is unambiguously a file, so the separator is not required. In
100
+ * free **prose**, a bare `description.md` is far more likely to be a word than
101
+ * a path, so the separator stays mandatory there.
102
+ *
103
+ * @param {string} raw — the token as it appeared in the report.
104
+ * @param {string} [repoRoot] — absolute repo root; absolute tokens beneath it
105
+ * are relativised. Omitted (the pure default) leaves absolutes to be dropped.
106
+ * @param {{ requireSeparator?: boolean }} [options]
107
+ * @returns {string|null}
108
+ */
109
+ function normalisePathToken(raw, repoRoot, { requireSeparator = false } = {}) {
110
+ if (typeof raw !== 'string') return null;
111
+ const stripped = raw
112
+ .replace(/^[`'"([]+/, '')
113
+ .replace(/[`'")\].,;]+$/, '')
114
+ .trim();
115
+ // `Location:` anchors appear as `:line`, `:line:col`, and `:start-end`.
116
+ const cleaned = stripped.replace(/:\d+(?:-\d+)?(?::\d+)?$/, '');
117
+ if (!cleaned || cleaned === '.' || cleaned === '..') return null;
118
+
119
+ let out = cleaned;
120
+ if (typeof repoRoot === 'string' && repoRoot.length > 0) {
121
+ for (const sep of ['/', '\\']) {
122
+ const root = repoRoot.endsWith(sep) ? repoRoot : `${repoRoot}${sep}`;
123
+ if (out.startsWith(root)) {
124
+ out = out.slice(root.length);
125
+ break;
126
+ }
127
+ }
128
+ }
129
+ // A path that is still absolute lives outside the repo — it can never be a
130
+ // valid group key, so drop it rather than let it become one.
131
+ if (out.startsWith('/') || out.startsWith('\\') || /^[A-Za-z]:/.test(out)) {
132
+ return null;
133
+ }
134
+ const hasSeparator = out.includes('/') || out.includes('\\');
135
+ if (requireSeparator && !hasSeparator) return null;
136
+ if (!hasSeparator && !FILE_EXT.test(out)) return null;
137
+ return out;
138
+ }
139
+
140
+ /**
141
+ * The finding-block skeleton mandates that a title lead with the primary file
142
+ * the finding lives in (``### `path/to/file.ext` — title``). That anchor is
143
+ * the most reliable primary-file signal a block carries, so it seeds `files[]`
144
+ * ahead of `Location:` and prose scraping — `pickPrimaryFile` takes `files[0]`.
145
+ */
146
+ function deriveTitleFile(title, repoRoot) {
147
+ const match = TITLE_ANCHOR.exec(typeof title === 'string' ? title : '');
148
+ if (!match) return [];
149
+ const normalised = normalisePathToken(match[1], repoRoot);
150
+ return normalised ? [normalised] : [];
151
+ }
152
+
153
+ function deriveLocationFiles(fields, repoRoot) {
85
154
  const raw = fields.location;
86
155
  if (typeof raw !== 'string' || raw.trim().length === 0) return [];
87
156
  const cleaned = raw.replace(/[`[\]]/g, ' ');
88
157
  const out = [];
89
158
  for (const token of cleaned.split(/[\s,]+/)) {
90
159
  if (!token) continue;
91
- const withoutLine = token.replace(/:\d+(?::\d+)?$/, '');
92
- if (withoutLine.includes('/') || withoutLine.includes('\\')) {
93
- out.push(withoutLine);
94
- }
160
+ const normalised = normalisePathToken(token, repoRoot);
161
+ if (normalised) out.push(normalised);
95
162
  }
96
163
  return out;
97
164
  }
@@ -113,14 +180,15 @@ function normaliseTitle(title) {
113
180
  .trim();
114
181
  }
115
182
 
116
- function extractFilePaths(text) {
183
+ function extractFilePaths(text, repoRoot) {
117
184
  if (typeof text !== 'string') return [];
118
185
  const seen = new Set();
119
186
  for (const match of text.matchAll(PATH_HINT)) {
120
- const candidate = match[1].replace(/^[`'"]+|[`'"]+$/g, '');
121
- if (candidate.includes('/') || candidate.includes('\\')) {
122
- seen.add(candidate);
123
- }
187
+ // Prose: a bare `description.md` is more likely a word than a path.
188
+ const normalised = normalisePathToken(match[1], repoRoot, {
189
+ requireSeparator: true,
190
+ });
191
+ if (normalised) seen.add(normalised);
124
192
  }
125
193
  return [...seen];
126
194
  }
@@ -211,7 +279,7 @@ function parseBlockFields(bodyLines) {
211
279
  * sourceReport: string,
212
280
  * }>}
213
281
  */
214
- export function parseAuditReport({ markdown, sourceReport }) {
282
+ export function parseAuditReport({ markdown, sourceReport, repoRoot }) {
215
283
  if (typeof markdown !== 'string') {
216
284
  throw new Error('parseAuditReport: markdown must be a string');
217
285
  }
@@ -231,10 +299,11 @@ export function parseAuditReport({ markdown, sourceReport }) {
231
299
  fields['recommendation & rationale'] ?? fields.recommendation ?? '';
232
300
  const agentPrompt = fields['agent prompt'] ?? '';
233
301
  const fileSet = new Set([
234
- ...deriveLocationFiles(fields),
235
- ...extractFilePaths(currentState),
236
- ...extractFilePaths(recommendation),
237
- ...extractFilePaths(agentPrompt),
302
+ ...deriveTitleFile(block.title, repoRoot),
303
+ ...deriveLocationFiles(fields, repoRoot),
304
+ ...extractFilePaths(currentState, repoRoot),
305
+ ...extractFilePaths(recommendation, repoRoot),
306
+ ...extractFilePaths(agentPrompt, repoRoot),
238
307
  ]);
239
308
 
240
309
  return {
@@ -258,15 +327,17 @@ export function parseAuditReport({ markdown, sourceReport }) {
258
327
  * an audit can legitimately come back empty.
259
328
  *
260
329
  * @param {Array<{ markdown: string, sourceReport: string }>} reports
330
+ * @param {{ repoRoot?: string }} [options] — `repoRoot` relativises absolute
331
+ * paths quoted in a report so they can match repo-relative group keys.
261
332
  * @returns {Array<ReturnType<typeof parseAuditReport>[number]>}
262
333
  */
263
- export function parseAuditReports(reports) {
334
+ export function parseAuditReports(reports, { repoRoot } = {}) {
264
335
  if (!Array.isArray(reports)) {
265
336
  throw new Error('parseAuditReports: reports must be an array');
266
337
  }
267
338
  const out = [];
268
339
  for (const report of reports) {
269
- out.push(...parseAuditReport(report));
340
+ out.push(...parseAuditReport({ ...report, repoRoot }));
270
341
  }
271
342
  return out;
272
343
  }