mandrel 2.19.0 → 2.20.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "2.19.0",
3
+ "version": "2.20.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, skills, rules, and SDLC workflows that govern AI coding assistants.",
5
5
  "files": [
6
6
  ".agents/",
@@ -1,513 +0,0 @@
1
- /**
2
- * codebase-snapshot.js — Bounded structural view of the consumer repo.
3
- *
4
- * Story #2634 (sibling to #2635 spec-freshness). `/plan` Phase 7
5
- * authors the Tech Spec from documentation alone — `architecture.md`,
6
- * `data-dictionary.md`, `decisions.md`, `patterns.md`. When those docs
7
- * drift from the real source tree, the Architect persona cites modules
8
- * and paths that no longer exist, and the mismatch only surfaces at
9
- * delivery time.
10
- *
11
- * `buildCodebaseSnapshot` produces a deterministic JSON view of the repo
12
- * that gets threaded into the spec-author's authoring context alongside
13
- * `docsContext`, so the Architect can prefer real module names over
14
- * doc-only ones.
15
- *
16
- * Two tiers, picked from `planning.codebaseSnapshot.tier`:
17
- *
18
- * - `skinny` (default) — file tree (paths only) of the configured
19
- * include globs, `package.json` exports + bin entries, the detected
20
- * test runner, the BDD feature root, and the list of directories
21
- * touched in the most recent `recentCommitWindow` commits.
22
- *
23
- * - `medium` — skinny + a single-line export signature per public
24
- * `.js` / `.ts` / `.mjs` file in the include set. Signatures are
25
- * extracted from a regex pass over the file body — deliberately not
26
- * a full AST so the snapshot stays cheap to generate on every plan
27
- * run. The regex catches the common shapes (`export function foo`,
28
- * `export class Bar`, `export const baz`, `export default`).
29
- *
30
- * The snapshot never throws on a probe failure. A missing git ref,
31
- * unreadable directory, or absent BDD root all degrade to an empty /
32
- * sentinel value in the corresponding field so Phase 7 stays
33
- * non-blocking.
34
- */
35
-
36
- import fs from 'node:fs';
37
- import path from 'node:path';
38
-
39
- import { gitSpawn } from './git-utils.js';
40
- import { Logger } from './Logger.js';
41
-
42
- /**
43
- * Allowed tier values for `planning.codebaseSnapshot.tier`. Mirrored
44
- * in `agentrc.schema.json` so a typo lands as a schema-validation
45
- * error before the runner is reached.
46
- */
47
- export const CODEBASE_SNAPSHOT_TIERS = Object.freeze(['skinny', 'medium']);
48
-
49
- const DEFAULT_INCLUDE = Object.freeze([
50
- '.agents/scripts/**',
51
- 'src/**',
52
- 'lib/**',
53
- 'app/**',
54
- 'packages/**',
55
- ]);
56
-
57
- const DEFAULT_EXCLUDE = Object.freeze([
58
- '**/node_modules/**',
59
- '**/dist/**',
60
- '**/build/**',
61
- '**/.next/**',
62
- '**/.turbo/**',
63
- '**/coverage/**',
64
- '**/*.test.*',
65
- '**/*.spec.*',
66
- ]);
67
-
68
- const DEFAULT_RECENT_COMMIT_WINDOW = 30;
69
-
70
- const SIGNATURE_FILE_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx']);
71
-
72
- /**
73
- * Hard cap on the file list in the skinny tier. When the include set
74
- * matches more than `MAX_FILES_SKINNY` tracked files, the result is
75
- * truncated and `truncated: true` is set on the envelope so the
76
- * Architect (and the Phase 7 authoring-context warning) sees that the
77
- * snapshot is incomplete and can ask for `medium` (or narrow the
78
- * include globs). 250 entries is roughly 4–6k tokens of paths on
79
- * typical repos — the upper edge of the Phase 7 budget.
80
- */
81
- const MAX_FILES_SKINNY = 250;
82
-
83
- /**
84
- * Group a sorted file list by its top-level directory. The top-level
85
- * segment is the first path component (`.agents/scripts/lib/foo.js` →
86
- * `.agents`, `src/index.ts` → `src`). Returns an insertion-ordered Map
87
- * keyed by top-level dir whose values are the files under it, preserving
88
- * the input order within each group. Because the input is pre-sorted
89
- * lexicographically and we never reorder, group keys appear in sorted
90
- * order and per-group file order is sorted too — the whole operation
91
- * is deterministic for a given tree.
92
- *
93
- * @param {string[]} files - Lexicographically sorted relative paths.
94
- * @returns {Map<string, string[]>}
95
- */
96
- function groupByTopLevel(files) {
97
- const groups = new Map();
98
- for (const file of files) {
99
- const slash = file.indexOf('/');
100
- const top = slash === -1 ? file : file.slice(0, slash);
101
- let bucket = groups.get(top);
102
- if (!bucket) {
103
- bucket = [];
104
- groups.set(top, bucket);
105
- }
106
- bucket.push(file);
107
- }
108
- return groups;
109
- }
110
-
111
- /**
112
- * Apply the skinny-tier cap with **per-top-level-dir proportional
113
- * budgeting** so a large, dot-prefixed tree (e.g. `.agents/scripts/**`)
114
- * can no longer monopolise the budget and truncate away the consumer's
115
- * own source. The flat `filtered.slice(0, cap)` it replaces kept only
116
- * the lexicographically-first `cap` paths, and dot-prefixed paths sort
117
- * ahead of every consumer path — so in any consumer repo whose include
118
- * set exceeds the cap, the snapshot devolved to mostly `.agents/**`.
119
- *
120
- * Algorithm (deterministic, no time/randomness):
121
- * 1. Group the sorted file list by top-level directory.
122
- * 2. Round-robin across the groups (in sorted key order), taking one
123
- * file from each group per pass, until the cap is filled or every
124
- * group is exhausted. Round-robin gives each top-level tree a fair,
125
- * proportional share of the budget regardless of its absolute size.
126
- * 3. Re-sort the selected subset so the emitted `files` array stays in
127
- * stable lexicographic order (same shape the rest of the pipeline
128
- * and the golden tests expect).
129
- *
130
- * When only one top-level group matches (the Mandrel-repo dogfood case,
131
- * where `.agents/scripts/**` is the only matching tree), round-robin
132
- * degenerates to "take the first `cap` from that one group" — identical
133
- * to the old behaviour, so the Mandrel snapshot stays useful.
134
- *
135
- * @param {string[]} sortedFiles - Lexicographically sorted relative paths.
136
- * @param {number} cap - Maximum number of files to keep.
137
- * @returns {string[]} The kept subset, lexicographically sorted, length ≤ cap.
138
- */
139
- function proportionalCap(sortedFiles, cap) {
140
- if (sortedFiles.length <= cap) return sortedFiles;
141
- const groups = [...groupByTopLevel(sortedFiles).values()];
142
- const kept = [];
143
- let exhausted = false;
144
- for (let pass = 0; !exhausted && kept.length < cap; pass += 1) {
145
- exhausted = true;
146
- for (const bucket of groups) {
147
- if (pass >= bucket.length) continue;
148
- exhausted = false;
149
- kept.push(bucket[pass]);
150
- if (kept.length >= cap) break;
151
- }
152
- }
153
- kept.sort();
154
- return kept;
155
- }
156
-
157
- /**
158
- * Resolve the snapshot configuration from a raw `.agentrc.json` block.
159
- * Defaults fill in every absent field so callers can hand the result
160
- * straight to `buildCodebaseSnapshot` without further normalisation.
161
- *
162
- * @param {object} [raw] - `planning.codebaseSnapshot` block (may be undefined).
163
- * @returns {{ tier: string, include: string[], exclude: string[], recentCommitWindow: number }}
164
- */
165
- export function resolveSnapshotConfig(raw) {
166
- const cfg = raw && typeof raw === 'object' ? raw : {};
167
- const tier = CODEBASE_SNAPSHOT_TIERS.includes(cfg.tier) ? cfg.tier : 'skinny';
168
- const include =
169
- Array.isArray(cfg.include) && cfg.include.length > 0
170
- ? cfg.include.map(String)
171
- : [...DEFAULT_INCLUDE];
172
- const exclude =
173
- Array.isArray(cfg.exclude) && cfg.exclude.length > 0
174
- ? cfg.exclude.map(String)
175
- : [...DEFAULT_EXCLUDE];
176
- const recentCommitWindow =
177
- Number.isInteger(cfg.recentCommitWindow) && cfg.recentCommitWindow > 0
178
- ? cfg.recentCommitWindow
179
- : DEFAULT_RECENT_COMMIT_WINDOW;
180
- return { tier, include, exclude, recentCommitWindow };
181
- }
182
-
183
- /**
184
- * Compile one glob entry into a per-segment regex. Supports `**`,
185
- * `*`, and literal segments. Deliberately small — we don't pull
186
- * `micromatch` so the snapshot stays self-contained.
187
- *
188
- * @param {string} glob
189
- * @returns {RegExp}
190
- */
191
- function compileGlob(glob) {
192
- const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
193
- // `**` becomes `.*` (cross-segment), `*` becomes `[^/]*` (single segment).
194
- // We replace `**` first via a placeholder so the single-`*` rule doesn't
195
- // chew on its inner stars.
196
- const pattern = escaped
197
- .replace(/\*\*/g, 'DOUBLESTAR')
198
- .replace(/\*/g, '[^/]*')
199
- .replace(/DOUBLESTAR/g, '.*');
200
- return new RegExp(`^${pattern}$`);
201
- }
202
-
203
- /**
204
- * Compile a list of glob entries to RegExp once. Callers hoist this out
205
- * of the per-file loop so the snapshot pays the (cheap but non-zero)
206
- * compilation cost a fixed number of times per `buildCodebaseSnapshot`
207
- * call — ~13 globs total — rather than recompiling every glob for every
208
- * tracked file (thousands of RegExp constructions on a real repo).
209
- *
210
- * @param {string[]} globs
211
- * @returns {RegExp[]}
212
- */
213
- function compileGlobs(globs) {
214
- return globs.map(compileGlob);
215
- }
216
-
217
- /**
218
- * Match `path` against any precompiled glob RegExp. Both the candidate
219
- * and the globs are normalized to forward slashes (the globs at compile
220
- * time, the candidate here) so Windows callsites see the same shape as
221
- * POSIX ones.
222
- *
223
- * @param {string} candidate
224
- * @param {RegExp[]} compiledGlobs
225
- * @returns {boolean}
226
- */
227
- function matchesAny(candidate, compiledGlobs) {
228
- const normalised = candidate.replace(/\\/g, '/');
229
- for (const re of compiledGlobs) {
230
- if (re.test(normalised)) return true;
231
- }
232
- return false;
233
- }
234
-
235
- /**
236
- * List every tracked file under the repo via `git ls-files`. We avoid
237
- * a node-level filesystem walk so `.gitignore` is naturally honoured
238
- * and the snapshot is bounded by what's actually in the repo.
239
- *
240
- * Returns an empty array if git fails — non-blocking by design.
241
- *
242
- * @param {string} cwd
243
- * @returns {string[]}
244
- */
245
- function listTrackedFiles(cwd) {
246
- const result = gitSpawn(cwd, 'ls-files');
247
- if (result.status !== 0 || typeof result.stdout !== 'string') return [];
248
- return result.stdout
249
- .split(/\r?\n/)
250
- .map((line) => line.trim())
251
- .filter((line) => line.length > 0);
252
- }
253
-
254
- /**
255
- * List directories touched in the most recent `window` commits. Uses
256
- * `git log -<N> --name-only` and reduces the result to a unique set
257
- * of top-two-level directories (so `.agents/scripts/lib/foo.js` →
258
- * `.agents/scripts/lib`). Caps the response at 25 entries so the
259
- * field stays readable in the spec-author envelope.
260
- *
261
- * @param {string} cwd
262
- * @param {number} window
263
- * @returns {string[]}
264
- */
265
- function listRecentlyTouchedDirs(cwd, window) {
266
- const result = gitSpawn(
267
- cwd,
268
- 'log',
269
- `-${window}`,
270
- '--name-only',
271
- '--pretty=format:',
272
- );
273
- if (result.status !== 0 || typeof result.stdout !== 'string') return [];
274
- const dirs = new Set();
275
- for (const raw of result.stdout.split(/\r?\n/)) {
276
- const file = raw.trim();
277
- if (file.length === 0) continue;
278
- const parts = file.replace(/\\/g, '/').split('/');
279
- if (parts.length < 2) {
280
- dirs.add(parts[0]);
281
- continue;
282
- }
283
- // Top-two-level directory keeps the field useful for a planner
284
- // ("activity recently concentrated in `.agents/scripts/lib/`")
285
- // without devolving into a per-file list.
286
- dirs.add(parts.slice(0, Math.min(3, parts.length - 1)).join('/'));
287
- }
288
- const out = [...dirs];
289
- out.sort();
290
- return out.slice(0, 25);
291
- }
292
-
293
- /**
294
- * Read `package.json` and pull the entries Phase 7 actually needs:
295
- * declared `exports`, `bin` keys, the `main` entry, and `scripts`
296
- * names. Returns `{}` when the file is missing or unparsable.
297
- *
298
- * @param {string} cwd
299
- * @returns {object}
300
- */
301
- function readPackageManifestSurface(cwd, logger) {
302
- const pkgPath = path.join(cwd, 'package.json');
303
- if (!fs.existsSync(pkgPath)) return {};
304
- let parsed;
305
- try {
306
- parsed = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
307
- } catch (err) {
308
- logger.debug(
309
- `[codebase-snapshot] package.json read/parse failed for ${pkgPath}: ${err?.message ?? err}`,
310
- );
311
- return {};
312
- }
313
- const exportsField = parsed.exports;
314
- const exportPaths =
315
- typeof exportsField === 'string'
316
- ? [exportsField]
317
- : exportsField && typeof exportsField === 'object'
318
- ? Object.keys(exportsField)
319
- : [];
320
- const binField = parsed.bin;
321
- const binNames =
322
- typeof binField === 'string'
323
- ? [path.basename(binField)]
324
- : binField && typeof binField === 'object'
325
- ? Object.keys(binField)
326
- : [];
327
- return {
328
- name: typeof parsed.name === 'string' ? parsed.name : null,
329
- main: typeof parsed.main === 'string' ? parsed.main : null,
330
- exports: exportPaths,
331
- bin: binNames,
332
- scripts:
333
- parsed.scripts && typeof parsed.scripts === 'object'
334
- ? Object.keys(parsed.scripts)
335
- : [],
336
- };
337
- }
338
-
339
- const EXPORT_SIGNATURE_RE =
340
- /^\s*export\s+(?:default\s+(?:async\s+)?(?:function\s*\*?\s*([A-Za-z_$][\w$]*)|class\s+([A-Za-z_$][\w$]*))|(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)|class\s+([A-Za-z_$][\w$]*)|(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=)/gm;
341
-
342
- /**
343
- * Extract a sorted, deduped list of exported identifiers from one file.
344
- * Deliberately regex-based (not an AST parse) so the snapshot is cheap.
345
- *
346
- * @param {string} body
347
- * @returns {string[]}
348
- */
349
- function extractExportSignatures(body) {
350
- const names = new Set();
351
- EXPORT_SIGNATURE_RE.lastIndex = 0;
352
- let match = EXPORT_SIGNATURE_RE.exec(body);
353
- while (match !== null) {
354
- for (let i = 1; i < match.length; i += 1) {
355
- if (match[i]) names.add(match[i]);
356
- }
357
- match = EXPORT_SIGNATURE_RE.exec(body);
358
- }
359
- // Also catch `export { a, b }` patterns separately — the omnibus
360
- // regex above is busy enough without trying to absorb braces.
361
- const braceRe = /export\s*\{\s*([^}]+)\s*\}/g;
362
- let braceMatch = braceRe.exec(body);
363
- while (braceMatch !== null) {
364
- for (const piece of braceMatch[1].split(',')) {
365
- const name = piece.trim().split(/\s+as\s+/)[0];
366
- if (name && /^[A-Za-z_$][\w$]*$/.test(name)) names.add(name);
367
- }
368
- braceMatch = braceRe.exec(body);
369
- }
370
- return [...names].sort();
371
- }
372
-
373
- /**
374
- * Build the medium-tier signature map. Walks every tracked file that
375
- * matches the include/exclude globs and pulls export names from each
376
- * `.js` / `.ts` body. Skips files larger than 64 KiB so an accidentally
377
- * checked-in bundle can't blow the snapshot budget.
378
- *
379
- * @param {string} cwd
380
- * @param {string[]} tracked
381
- * @returns {Array<{ path: string, exports: string[] }>}
382
- */
383
- function collectSignatures(cwd, tracked, logger) {
384
- const out = [];
385
- for (const rel of tracked) {
386
- const ext = path.extname(rel).toLowerCase();
387
- if (!SIGNATURE_FILE_EXTS.has(ext)) continue;
388
- const abs = path.join(cwd, rel);
389
- let stat;
390
- try {
391
- stat = fs.statSync(abs);
392
- } catch (err) {
393
- logger.debug(
394
- `[codebase-snapshot] stat failed for ${abs}: ${err?.message ?? err}`,
395
- );
396
- continue;
397
- }
398
- if (stat.size > 64 * 1024) continue;
399
- let body;
400
- try {
401
- body = fs.readFileSync(abs, 'utf8');
402
- } catch (err) {
403
- logger.debug(
404
- `[codebase-snapshot] readFile failed for ${abs}: ${err?.message ?? err}`,
405
- );
406
- continue;
407
- }
408
- const exports = extractExportSignatures(body);
409
- if (exports.length === 0) continue;
410
- out.push({ path: rel.replace(/\\/g, '/'), exports });
411
- }
412
- return out;
413
- }
414
-
415
- /**
416
- * Detect the project's test framework + BDD feature root by reading
417
- * `package.json` scripts and probing well-known directories. Returns
418
- * a `{ runner, featureRoots }` envelope that the Architect can cite
419
- * directly when authoring acceptance criteria.
420
- *
421
- * @param {string} cwd
422
- * @param {{ scripts: string[] }} pkg
423
- * @returns {{ runner: string|null, featureRoots: string[] }}
424
- */
425
- function detectTestSurface(cwd, pkg) {
426
- let runner = null;
427
- if (Array.isArray(pkg.scripts)) {
428
- if (pkg.scripts.includes('test')) runner = 'npm test';
429
- }
430
- // Probe canonical BDD feature locations. The actual scanner that
431
- // Story #2637 will land does a more thorough scan; the snapshot
432
- // just records the root so the Architect can cite it.
433
- const featureRoots = [];
434
- for (const candidate of ['tests/features', 'features']) {
435
- if (fs.existsSync(path.join(cwd, candidate))) featureRoots.push(candidate);
436
- }
437
- return { runner, featureRoots };
438
- }
439
-
440
- /**
441
- * Build the codebase snapshot. Pure with respect to the args (no
442
- * environment reads beyond the explicit `cwd`); never throws.
443
- *
444
- * @param {object} opts
445
- * @param {string} [opts.cwd] - Repo root (defaults to process.cwd()).
446
- * @param {string} [opts.tier] - `skinny` | `medium`.
447
- * @param {string[]} [opts.include] - Glob allowlist.
448
- * @param {string[]} [opts.exclude] - Glob blocklist (applied after include).
449
- * @param {number} [opts.recentCommitWindow] - How many recent commits to scan.
450
- * @returns {{
451
- * tier: string,
452
- * generatedAt: string,
453
- * pkg: object,
454
- * files: string[],
455
- * recentlyTouched: string[],
456
- * testSurface: { runner: string|null, featureRoots: string[] },
457
- * signatures: Array<{ path: string, exports: string[] }> | null,
458
- * }}
459
- */
460
- export function buildCodebaseSnapshot(opts = {}) {
461
- const cwd = opts.cwd ?? process.cwd();
462
- const logger = opts.logger ?? Logger;
463
- const cfg = resolveSnapshotConfig({
464
- tier: opts.tier,
465
- include: opts.include,
466
- exclude: opts.exclude,
467
- recentCommitWindow: opts.recentCommitWindow,
468
- });
469
-
470
- const tracked = listTrackedFiles(cwd);
471
- // Compile the include/exclude globs to RegExp exactly once per call
472
- // (memoized here, hoisted out of the per-file loop) so a repo with
473
- // thousands of tracked files doesn't trigger thousands of RegExp
474
- // constructions. See `compileGlobs`.
475
- const includeRes = compileGlobs(cfg.include);
476
- const excludeRes = compileGlobs(cfg.exclude);
477
- const filtered = tracked.filter(
478
- (f) => matchesAny(f, includeRes) && !matchesAny(f, excludeRes),
479
- );
480
- filtered.sort();
481
-
482
- const pkg = readPackageManifestSurface(cwd, logger);
483
- const recentlyTouched = listRecentlyTouchedDirs(cwd, cfg.recentCommitWindow);
484
- const testSurface = detectTestSurface(cwd, pkg);
485
-
486
- let displayFiles = filtered;
487
- let truncated = false;
488
- if (cfg.tier === 'skinny' && filtered.length > MAX_FILES_SKINNY) {
489
- // Proportional per-top-level-dir budgeting (not a flat lexicographic
490
- // slice) so consumer source survives the cap even when a dot-prefixed
491
- // tree like `.agents/scripts/**` sorts ahead of it. See `proportionalCap`.
492
- displayFiles = proportionalCap(filtered, MAX_FILES_SKINNY);
493
- truncated = true;
494
- }
495
-
496
- const snapshot = {
497
- tier: cfg.tier,
498
- generatedAt: new Date().toISOString(),
499
- pkg,
500
- files: displayFiles,
501
- fileCount: filtered.length,
502
- truncated,
503
- recentlyTouched,
504
- testSurface,
505
- signatures: null,
506
- };
507
-
508
- if (cfg.tier === 'medium') {
509
- snapshot.signatures = collectSignatures(cwd, filtered, logger);
510
- }
511
-
512
- return snapshot;
513
- }
@@ -1,147 +0,0 @@
1
- /**
2
- * planning/spec-authoring-grounding.js — F10 Spec code-grounding.
3
- *
4
- * Story #4139 (Epic #4131). `/plan` Phase 7 authors the Tech Spec
5
- * from the Epic body, the scraped project docs, and the
6
- * `codebaseSnapshot` structural view of the consumer repo. Two failure
7
- * modes made that grounding silently partial:
8
- *
9
- * 1. The skinny-tier snapshot caps its file list at `MAX_FILES_SKINNY`
10
- * and sets `truncated: true` — but the only operator-visible signal
11
- * was a stderr `Logger.warn` (Story #3959). The spec author consumes
12
- * the JSON envelope, not stderr, so it never learned the snapshot
13
- * was partial. A run that dropped "377 of 627 files" looked complete
14
- * to the author.
15
- *
16
- * 2. When the Epic body cites a code path that is **absent** from the
17
- * snapshot's file set, nothing surfaced the gap *during* authoring.
18
- * The post-author spec-freshness gate (Story #2635) catches stale
19
- * citations only after the Tech Spec is written — one phase too late
20
- * to ground the author's choices.
21
- *
22
- * `buildAuthoringGrounding` derives a small, bounded `grounding` block that
23
- * is attached to the `codebaseSnapshot` envelope so the author (and the
24
- * operator inspecting the run) see both signals before the spec is written:
25
- *
26
- * - `truncation` — non-null when the snapshot dropped files. Carries the
27
- * dropped count, the matched/shown totals, and the two remedies. This
28
- * is the structured, in-envelope form of the Story #3959 stderr warning.
29
- * - `citedButAbsent` — path-shaped references pulled from the authoring
30
- * prose (the Epic body) that are **not** present in the snapshot's file
31
- * set and are not phrased as net-new. Bounded to `MAX_CITED_ABSENT`
32
- * entries so a pathological Epic body cannot blow the envelope budget.
33
- *
34
- * The grounding is targeted (the prose the author is grounding *from* and
35
- * the files the snapshot already carries), not a whole-repo dump — the
36
- * snapshot file set is the only source consulted, so this adds no new
37
- * filesystem or git probes.
38
- */
39
-
40
- /**
41
- * Hard cap on the `citedButAbsent` list so an Epic body that mentions a
42
- * very large number of paths cannot inflate the authoring envelope. The
43
- * cap is generous relative to a realistic Epic citation count; when it is
44
- * hit, `citedButAbsentTruncated: true` flags the elision so the signal is
45
- * not silently dropped (the very failure mode this Story fixes).
46
- */
47
- export const MAX_CITED_ABSENT = 40;
48
-
49
- /**
50
- * Build the operator-visible truncation signal from a snapshot envelope.
51
- * Returns `null` when the snapshot is absent or was not truncated.
52
- *
53
- * @param {object|null} snapshot - The `codebaseSnapshot` envelope.
54
- * @returns {{ dropped: number, matched: number, shown: number, tier: string, remedies: string[] } | null}
55
- */
56
- export function buildTruncationSignal(snapshot) {
57
- if (!snapshot || snapshot.truncated !== true) return null;
58
- const matched = Number.isInteger(snapshot.fileCount) ? snapshot.fileCount : 0;
59
- const shown = Array.isArray(snapshot.files) ? snapshot.files.length : 0;
60
- const dropped = Math.max(0, matched - shown);
61
- return {
62
- dropped,
63
- matched,
64
- shown,
65
- tier: typeof snapshot.tier === 'string' ? snapshot.tier : 'skinny',
66
- remedies: [
67
- 'Set planning.codebaseSnapshot.tier: "medium" in .agentrc.json to restore full grounding.',
68
- 'Narrow planning.codebaseSnapshot.include in .agentrc.json so the cited surfaces survive the cap.',
69
- ],
70
- };
71
- }
72
-
73
- /**
74
- * Surface path-shaped references from the authoring prose that are absent
75
- * from the snapshot's file set. Reuses the spec-freshness path extractor so
76
- * the citation shapes recognised here match the post-author freshness gate
77
- * exactly — a path the author cites in prose is detected the same way before
78
- * and after authoring.
79
- *
80
- * A reference is reported only when it is **not** present in `snapshotFiles`
81
- * **and** the surrounding prose does not phrase it as net-new (the same
82
- * cue heuristic the freshness gate uses to demote intentional new-file
83
- * mentions). Results are deduped by path, sorted, and bounded to
84
- * `MAX_CITED_ABSENT`.
85
- *
86
- * @param {string} prose - The authoring prose (typically the Epic body).
87
- * @param {string[]} snapshotFiles - The snapshot's `files` array.
88
- * @param {object} deps
89
- * @param {Function} deps.collectReferences - (body) => Array<{ path, index, matchLength }>.
90
- * @param {Function} deps.hasNewFileCue - (body, index, matchLength) => boolean.
91
- * @returns {{ paths: string[], truncated: boolean }}
92
- */
93
- export function findCitedButAbsent(prose, snapshotFiles, deps) {
94
- const { collectReferences, hasNewFileCue } = deps;
95
- if (typeof prose !== 'string' || prose.length === 0) {
96
- return { paths: [], truncated: false };
97
- }
98
- const present = new Set(
99
- (Array.isArray(snapshotFiles) ? snapshotFiles : []).map((f) =>
100
- String(f).replace(/\\/g, '/'),
101
- ),
102
- );
103
- const absent = new Set();
104
- for (const { path, index, matchLength } of collectReferences(prose)) {
105
- const normalised = path.replace(/\\/g, '/');
106
- if (present.has(normalised)) continue;
107
- if (hasNewFileCue(prose, index, matchLength)) continue;
108
- absent.add(normalised);
109
- }
110
- const sorted = [...absent].sort();
111
- return {
112
- paths: sorted.slice(0, MAX_CITED_ABSENT),
113
- truncated: sorted.length > MAX_CITED_ABSENT,
114
- };
115
- }
116
-
117
- /**
118
- * Build the full `grounding` block attached to the `codebaseSnapshot`
119
- * envelope. Pure with respect to its inputs (no filesystem or git probes):
120
- * the snapshot file set is the sole grounding source, keeping the context
121
- * bounded for cost.
122
- *
123
- * @param {object} opts
124
- * @param {object|null} opts.snapshot - The `codebaseSnapshot` envelope.
125
- * @param {string} opts.prose - The authoring prose (Epic body) to scan.
126
- * @param {Function} opts.collectReferences - spec-freshness path extractor.
127
- * @param {Function} opts.hasNewFileCue - spec-freshness net-new cue check.
128
- * @returns {{ truncation: object|null, citedButAbsent: string[], citedButAbsentTruncated: boolean }}
129
- */
130
- export function buildAuthoringGrounding({
131
- snapshot,
132
- prose,
133
- collectReferences,
134
- hasNewFileCue,
135
- }) {
136
- const truncation = buildTruncationSignal(snapshot);
137
- const { paths, truncated } = findCitedButAbsent(
138
- prose,
139
- snapshot?.files ?? [],
140
- { collectReferences, hasNewFileCue },
141
- );
142
- return {
143
- truncation,
144
- citedButAbsent: paths,
145
- citedButAbsentTruncated: truncated,
146
- };
147
- }