peaks-loop 4.0.43 → 4.0.44

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 (31) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/codegraph-commands.js +191 -6
  5. package/dist/cli/commands/final-review-commands.d.ts +34 -10
  6. package/dist/cli/commands/final-review-commands.js +130 -34
  7. package/dist/cli/commands/share-commands.d.ts +49 -0
  8. package/dist/cli/commands/share-commands.js +114 -14
  9. package/dist/services/codegraph/codegraph-autorefresh.js +12 -0
  10. package/dist/services/codegraph/codegraph-exclude-integrity.d.ts +61 -0
  11. package/dist/services/codegraph/codegraph-exclude-integrity.js +98 -0
  12. package/dist/services/codegraph/codegraph-exclude-reconciler.d.ts +26 -0
  13. package/dist/services/codegraph/codegraph-exclude-reconciler.js +217 -0
  14. package/dist/services/codegraph/codegraph-exclude-repair.d.ts +102 -0
  15. package/dist/services/codegraph/codegraph-exclude-repair.js +266 -0
  16. package/dist/services/codegraph/codegraph-preflight-service.js +12 -0
  17. package/dist/services/codegraph/codegraph-service.d.ts +0 -1
  18. package/dist/services/codegraph/codegraph-service.js +5 -4
  19. package/dist/services/doctor/doctor-service/checks/codegraph-exclude-integrity.d.ts +29 -0
  20. package/dist/services/doctor/doctor-service/checks/codegraph-exclude-integrity.js +88 -0
  21. package/dist/services/doctor/doctor-service/plugin-registry.js +2 -0
  22. package/dist/services/doctor/doctor-service/types.d.ts +27 -0
  23. package/dist/services/final-review/final-review-service.d.ts +154 -0
  24. package/dist/services/final-review/final-review-service.js +621 -7
  25. package/dist/services/final-review/index.d.ts +1 -1
  26. package/dist/services/final-review/index.js +1 -1
  27. package/dist/services/prd/handoff-auto-regen.js +0 -1
  28. package/dist/services/prd/handoff-service.d.ts +9 -1
  29. package/dist/services/prd/handoff-service.js +48 -6
  30. package/package.json +7 -5
  31. package/skills/peaks-final-review/SKILL.md +43 -32
@@ -5,6 +5,36 @@ import { readSharedChannel, writeSharedEntry, SHARED_CHANNEL_SOFT_VALUE_WARN } f
5
5
  import { writeLogEntry } from '../../services/log/logger.js';
6
6
  import { getCurrentSessionId } from '../../services/skills/skill-presence-service.js';
7
7
  import { summarizeBatchResults } from './sub-agent-shared.js';
8
+ /** The one selection rule `--request-id` and `--batch` now share. */
9
+ export const FINALIZE_SELECTION_RULE = 'prefer status=queued; among those, newest by record createdAt (then filename); ' +
10
+ 'no queued match means nothing is finalized';
11
+ /**
12
+ * N3 — pick the record `finalize --request-id` should act on.
13
+ *
14
+ * The branch this replaces `break`ed on the FIRST file whose record carried
15
+ * the requestId and never looked at `status`. With a re-dispatched request
16
+ * (this session holds six records for `2026-09-12-defect-remediation`) it
17
+ * always resolved to the OLDEST one — the already-`done` RD record — so
18
+ * finalizing reported success while the newer `queued` QA record stayed
19
+ * queued forever. `--batch` never had that bug: it filters on `queued`.
20
+ *
21
+ * Both branches are now the same rule. Return `null` when nothing is queued:
22
+ * a record that already left `queued` is precisely the one that must NOT be
23
+ * re-finalized, so the caller reports the survivors instead of touching one.
24
+ */
25
+ export function selectFinalizeTarget(candidates) {
26
+ const queued = candidates.filter(candidate => candidate.status === 'queued');
27
+ if (queued.length === 0)
28
+ return null;
29
+ return [...queued].sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.recordPath.localeCompare(a.recordPath))[0];
30
+ }
31
+ /** Why a candidate was not the chosen one — reported, never guessed at. */
32
+ export function describeFinalizeRejection(candidate, chosen) {
33
+ if (candidate.status !== 'queued' || chosen === null) {
34
+ return 'status is ' + candidate.status;
35
+ }
36
+ return 'superseded by the newer queued record ' + chosen.recordPath;
37
+ }
8
38
  export function registerShareCommand(parent, io) {
9
39
  addJsonOption(parent
10
40
  .command('share')
@@ -329,6 +359,31 @@ export function registerFinalizeCommand(parent, io) {
329
359
  const finalized = [];
330
360
  const skipped = [];
331
361
  const errors = [];
362
+ let selection = null;
363
+ /**
364
+ * N2 — one unreadable record must not abort the sweep.
365
+ *
366
+ * Skipping `active-dispatches.json` and `batch-*.counter.json` by
367
+ * FILENAME removed the two non-record files that happened to be in the
368
+ * directory, but a single stale or foreign `dispatch-*.json` (an old
369
+ * `version`, hand-edited JSON, a truncated write) still made
370
+ * `readRecord` throw from OUTSIDE any try/catch — and the throw
371
+ * escaped to the action's outer handler, so `--request-id` AND
372
+ * `--batch` both died with `FINALIZE_ERROR` and exit 1 without
373
+ * touching a single healthy record.
374
+ *
375
+ * A record that cannot be read is now reported in `errors[]` and
376
+ * skipped; every other record is processed as before.
377
+ */
378
+ const tryReadRecord = (recordPath) => {
379
+ try {
380
+ return readRecord(recordPath);
381
+ }
382
+ catch (e) {
383
+ errors.push({ recordPath, error: getErrorMessage(e) });
384
+ return null;
385
+ }
386
+ };
332
387
  const applyOutcome = (recordPath, rid) => {
333
388
  markCompleted({ recordPath, now: () => new Date(), status: mapped.status, outcome: mapped.outcome, projectRoot });
334
389
  finalized.push({ recordPath, requestId: rid, status: mapped.status });
@@ -352,29 +407,59 @@ export function registerFinalizeCommand(parent, io) {
352
407
  const fs2 = await import('node:fs');
353
408
  const path2 = await import('node:path');
354
409
  const dir = path2.resolve(projectRoot, '.peaks', '_sub_agents', sessionId);
355
- let resolvedPath = null;
410
+ const candidates = [];
356
411
  if (fs2.existsSync(dir)) {
357
412
  for (const f of fs2.readdirSync(dir)) {
358
- if (!f.endsWith('.json'))
413
+ // Only dispatch records are readable records. The session
414
+ // directory also holds `active-dispatches.json` (an index)
415
+ // and `batch-<uuid>.counter.json` (batch counters); neither
416
+ // carries a `version` field, so `readRecord` on them throws
417
+ // `Dispatch record version mismatch ... got undefined`. The
418
+ // `--batch` branch below has always used this same filter.
419
+ if (!f.startsWith('dispatch-') || !f.endsWith('.json'))
359
420
  continue;
360
421
  const p = path2.join(dir, f);
361
- const r = readRecord(p);
362
- if (r.requestId === options.requestId) {
363
- resolvedPath = p;
364
- break;
365
- }
422
+ const r = tryReadRecord(p);
423
+ if (r === null || r.requestId !== options.requestId)
424
+ continue;
425
+ candidates.push({
426
+ recordPath: p,
427
+ requestId: r.requestId,
428
+ status: r.status,
429
+ createdAt: r.createdAt
430
+ });
366
431
  }
367
432
  }
368
- if (!resolvedPath) {
433
+ if (candidates.length === 0) {
369
434
  printResult(io, fail('sub-agent.finalize', 'RECORD_NOT_FOUND', 'No dispatch record for requestId=' + options.requestId, { ok: false }, ['Check --request-id matches the dispatch envelope.']), asJson);
370
435
  process.exitCode = 1;
371
436
  return;
372
437
  }
373
- try {
374
- applyOutcome(resolvedPath, options.requestId);
438
+ // N3: same rule as `--batch` — see `selectFinalizeTarget`.
439
+ const chosen = selectFinalizeTarget(candidates);
440
+ selection = {
441
+ requestId: options.requestId,
442
+ rule: FINALIZE_SELECTION_RULE,
443
+ matched: candidates.length,
444
+ chosen: chosen?.recordPath ?? null,
445
+ rejected: candidates
446
+ .filter(candidate => candidate !== chosen)
447
+ .map(candidate => ({
448
+ recordPath: candidate.recordPath,
449
+ status: candidate.status,
450
+ reason: describeFinalizeRejection(candidate, chosen)
451
+ }))
452
+ };
453
+ for (const rejected of selection.rejected) {
454
+ skipped.push({ recordPath: rejected.recordPath, reason: rejected.reason });
375
455
  }
376
- catch (e) {
377
- errors.push({ recordPath: resolvedPath, error: getErrorMessage(e) });
456
+ if (chosen !== null) {
457
+ try {
458
+ applyOutcome(chosen.recordPath, chosen.requestId);
459
+ }
460
+ catch (e) {
461
+ errors.push({ recordPath: chosen.recordPath, error: getErrorMessage(e) });
462
+ }
378
463
  }
379
464
  }
380
465
  else {
@@ -386,7 +471,9 @@ export function registerFinalizeCommand(parent, io) {
386
471
  if (!f.startsWith('dispatch-') || !f.endsWith('.json'))
387
472
  continue;
388
473
  const p = path2.join(dir, f);
389
- const r = readRecord(p);
474
+ const r = tryReadRecord(p);
475
+ if (r === null)
476
+ continue;
390
477
  if (r.batchId !== options.batch)
391
478
  continue;
392
479
  if (r.status !== 'queued') {
@@ -402,7 +489,20 @@ export function registerFinalizeCommand(parent, io) {
402
489
  }
403
490
  }
404
491
  }
405
- printResult(io, ok('sub-agent.finalize', { finalized, skipped, errors, sessionId, outcome }, errors.length > 0 ? [errors.length + ' failed'] : [], errors.length > 0 ? ['Re-run after fixing.'] : ['All targeted records transitioned out of queued.']), asJson);
492
+ const hints = [];
493
+ if (errors.length > 0) {
494
+ hints.push('Re-run after fixing; unreadable records are listed in errors[] and were skipped, not fatal.');
495
+ }
496
+ else if (finalized.length === 0 && skipped.length > 0) {
497
+ hints.push('Nothing was finalized: every matching record had already left `queued`. See skipped[] for each record\'s status.');
498
+ }
499
+ else {
500
+ hints.push('All targeted records transitioned out of queued.');
501
+ }
502
+ if (selection !== null) {
503
+ hints.push(`--request-id selection (${selection.rule}): chose ${selection.chosen ?? '(none)'} of ${selection.matched} matching record(s); ${selection.rejected.length} rejected.`);
504
+ }
505
+ printResult(io, ok('sub-agent.finalize', { finalized, skipped, errors, selection, sessionId, outcome }, errors.length > 0 ? [errors.length + ' failed'] : [], hints), asJson);
406
506
  if (errors.length > 0)
407
507
  process.exitCode = 1;
408
508
  }
@@ -38,6 +38,7 @@
38
38
  import { existsSync, statSync } from 'node:fs';
39
39
  import { join } from 'node:path';
40
40
  import { CODEGRAPH_DIR_NAME, CODEGRAPH_MARKER_NAME, createCodegraphInvocation, executeCodegraphInvocation, isCodegraphInitialized, } from './codegraph-service.js';
41
+ import { repairCodegraphExcludeFromProject } from './codegraph-exclude-repair.js';
41
42
  /**
42
43
  * True when `<projectRoot>/.codegraph/` exists and is a directory.
43
44
  * Pure fs probe; never throws.
@@ -107,6 +108,17 @@ export async function refreshCodegraphAfterSlice(projectRoot, runner) {
107
108
  note: `auto codegraph refresh self-heal init failed (exit ${String(initResult.exitCode)}): ${firstMeaningfulLine(initResult.stderr || initResult.stdout)}`,
108
109
  };
109
110
  }
111
+ // That init just wrote upstream's 99-rule default `exclude`
112
+ // template, some of which block tracked source files — the same
113
+ // self-heal the CLI's `peaks codegraph init` performs, via the
114
+ // same shared helper. Skipped, this path would stamp the
115
+ // peaks-loop marker over an incomplete index that no later
116
+ // `init` (it would no-op) could ever repair.
117
+ //
118
+ // Never throws (the helper catches everything), and
119
+ // `reindex: false` because the index call below covers the
120
+ // recovered files.
121
+ await repairCodegraphExcludeFromProject(projectRoot, runner, { reindex: false });
110
122
  }
111
123
  const invocation = createCodegraphInvocation({ subcommand: 'index', project: projectRoot, quiet: true });
112
124
  const result = await executeCodegraphInvocation(invocation, runner);
@@ -0,0 +1,61 @@
1
+ import { type CodegraphExcludeViolation } from './codegraph-exclude-reconciler.js';
2
+ /**
3
+ * Exit code `peaks codegraph status` uses when the index is
4
+ * demonstrably incomplete. Distinct from the upstream pass-through
5
+ * exit code and from `CODEGRAPH_INIT_CONFLICT_EXIT_CODE` (73) so a CI
6
+ * job can tell "codegraph said no" apart from "peaks-loop found a
7
+ * tracked source file the index silently dropped".
8
+ */
9
+ export declare const CODEGRAPH_INTEGRITY_EXIT_CODE = 74;
10
+ export type CodegraphExcludeRuleImpact = {
11
+ /** The `exclude` rule that must be dropped. */
12
+ readonly rule: string;
13
+ /** Distinct tracked source files this single rule blocks. */
14
+ readonly blockedCount: number;
15
+ };
16
+ export type CodegraphExcludeIntegrityReport = {
17
+ /** Absolute path of the reconciled `.codegraph/config.json`. */
18
+ readonly configPath: string;
19
+ /** True when at least one tracked source file is blocked. */
20
+ readonly gap: boolean;
21
+ /** Tracked files that pass the config's `include` filter at all. */
22
+ readonly trackedSourceCount: number;
23
+ /** Distinct tracked source files blocked by at least one rule. */
24
+ readonly excludedTrackedCount: number;
25
+ /** One entry per (file, rule) pair — see S1's reconciler. */
26
+ readonly violations: readonly CodegraphExcludeViolation[];
27
+ /** Rules that must be dropped; empty exactly when `gap` is false. */
28
+ readonly rulesToRemove: readonly string[];
29
+ /** Per-rule blocked-file counts, in `rulesToRemove` order. */
30
+ readonly ruleImpacts: readonly CodegraphExcludeRuleImpact[];
31
+ };
32
+ /**
33
+ * True when `<projectRoot>/.codegraph/config.json` exists, i.e. when an
34
+ * exclude list is actually in play here. Both consumers use this to
35
+ * stay SILENT on a project that never ran `peaks codegraph init`: there
36
+ * is no exclusion to report, and a missing config is not a finding.
37
+ *
38
+ * Note the difference from `isCodegraphInitialized` in
39
+ * `codegraph-service.ts`, which probes `codegraph.db` (upstream's own
40
+ * definition of "initialized"). The exclude list is written by upstream
41
+ * init, so its presence is the narrower question this module asks.
42
+ */
43
+ export declare function isCodegraphExcludeConfigPresent(projectRoot: string): boolean;
44
+ /**
45
+ * Read the project's git-tracked files + `.codegraph/config.json` and
46
+ * fold the reconciliation into a report.
47
+ *
48
+ * Throws (never silently degrades) when the project is not a git work
49
+ * tree, when the config is missing, or when it is malformed — callers
50
+ * that must stay alive (doctor, `status`) catch and surface the reason.
51
+ */
52
+ export declare function inspectCodegraphExcludeIntegrity(projectRoot: string): CodegraphExcludeIntegrityReport;
53
+ /**
54
+ * Human-readable detail lines for a gapped report: the headline count,
55
+ * then the offending rules, then a sample of the blocked files so an
56
+ * operator can point at a concrete path without re-running anything.
57
+ *
58
+ * Returns an empty array for a clean report — the caller decides
59
+ * whether "clean" is worth printing at all.
60
+ */
61
+ export declare function renderCodegraphExcludeIntegrityLines(report: CodegraphExcludeIntegrityReport): readonly string[];
@@ -0,0 +1,98 @@
1
+ // src/services/codegraph/codegraph-exclude-integrity.ts
2
+ //
3
+ // Slice S2 of `2026-09-12-codegraph-exclude-integrity` — the READ-ONLY
4
+ // integrity report shared by `peaks codegraph status` and the
5
+ // `capability:codegraph-exclude-integrity` doctor check.
6
+ //
7
+ // S1's reconciler computes the raw truth (`violations` /
8
+ // `rulesToRemove`). This module folds it into a verdict-shaped report
9
+ // that both consumers can render and gate on, so neither of them has
10
+ // to re-derive "is there a gap, how big, and which rules cause it".
11
+ //
12
+ // It NEVER writes `.codegraph/config.json`. The only write path is
13
+ // `codegraph-exclude-repair.ts`, reachable from `peaks codegraph init`
14
+ // (fresh-initialization self-heal) and the explicit
15
+ // `peaks codegraph repair-exclude` command. Read stays read.
16
+ import { existsSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { CODEGRAPH_CONFIG_FILENAME, reconcileCodegraphExcludeFromProject } from './codegraph-exclude-reconciler.js';
19
+ import { CODEGRAPH_DIR_NAME } from './codegraph-service.js';
20
+ /**
21
+ * Exit code `peaks codegraph status` uses when the index is
22
+ * demonstrably incomplete. Distinct from the upstream pass-through
23
+ * exit code and from `CODEGRAPH_INIT_CONFLICT_EXIT_CODE` (73) so a CI
24
+ * job can tell "codegraph said no" apart from "peaks-loop found a
25
+ * tracked source file the index silently dropped".
26
+ */
27
+ export const CODEGRAPH_INTEGRITY_EXIT_CODE = 74;
28
+ /** How many offending rules and blocked files we name on the human path. */
29
+ const MAX_REPORTED_RULES = 10;
30
+ const MAX_REPORTED_FILES = 10;
31
+ /**
32
+ * True when `<projectRoot>/.codegraph/config.json` exists, i.e. when an
33
+ * exclude list is actually in play here. Both consumers use this to
34
+ * stay SILENT on a project that never ran `peaks codegraph init`: there
35
+ * is no exclusion to report, and a missing config is not a finding.
36
+ *
37
+ * Note the difference from `isCodegraphInitialized` in
38
+ * `codegraph-service.ts`, which probes `codegraph.db` (upstream's own
39
+ * definition of "initialized"). The exclude list is written by upstream
40
+ * init, so its presence is the narrower question this module asks.
41
+ */
42
+ export function isCodegraphExcludeConfigPresent(projectRoot) {
43
+ return existsSync(join(projectRoot, CODEGRAPH_DIR_NAME, CODEGRAPH_CONFIG_FILENAME));
44
+ }
45
+ /**
46
+ * Read the project's git-tracked files + `.codegraph/config.json` and
47
+ * fold the reconciliation into a report.
48
+ *
49
+ * Throws (never silently degrades) when the project is not a git work
50
+ * tree, when the config is missing, or when it is malformed — callers
51
+ * that must stay alive (doctor, `status`) catch and surface the reason.
52
+ */
53
+ export function inspectCodegraphExcludeIntegrity(projectRoot) {
54
+ const result = reconcileCodegraphExcludeFromProject(projectRoot);
55
+ const blockedCounts = new Map();
56
+ for (const violation of result.violations) {
57
+ blockedCounts.set(violation.matchedRule, (blockedCounts.get(violation.matchedRule) ?? 0) + 1);
58
+ }
59
+ return {
60
+ configPath: join(projectRoot, CODEGRAPH_DIR_NAME, CODEGRAPH_CONFIG_FILENAME),
61
+ gap: result.excludedTrackedCount > 0,
62
+ trackedSourceCount: result.trackedSourceCount,
63
+ excludedTrackedCount: result.excludedTrackedCount,
64
+ violations: result.violations,
65
+ rulesToRemove: result.rulesToRemove,
66
+ ruleImpacts: result.rulesToRemove.map((rule) => ({ rule, blockedCount: blockedCounts.get(rule) ?? 0 }))
67
+ };
68
+ }
69
+ /**
70
+ * Human-readable detail lines for a gapped report: the headline count,
71
+ * then the offending rules, then a sample of the blocked files so an
72
+ * operator can point at a concrete path without re-running anything.
73
+ *
74
+ * Returns an empty array for a clean report — the caller decides
75
+ * whether "clean" is worth printing at all.
76
+ */
77
+ export function renderCodegraphExcludeIntegrityLines(report) {
78
+ if (!report.gap) {
79
+ return [];
80
+ }
81
+ const lines = [
82
+ `[FAIL] codegraph index is incomplete: ${report.excludedTrackedCount} of ${report.trackedSourceCount} tracked source files are excluded by ${report.rulesToRemove.length} rule(s).`
83
+ ];
84
+ for (const impact of report.ruleImpacts.slice(0, MAX_REPORTED_RULES)) {
85
+ lines.push(` rule ${impact.rule} blocks ${impact.blockedCount} tracked file(s)`);
86
+ }
87
+ if (report.ruleImpacts.length > MAX_REPORTED_RULES) {
88
+ lines.push(` … and ${report.ruleImpacts.length - MAX_REPORTED_RULES} more rule(s)`);
89
+ }
90
+ for (const violation of report.violations.slice(0, MAX_REPORTED_FILES)) {
91
+ lines.push(` excluded: ${violation.path} <- ${violation.matchedRule}`);
92
+ }
93
+ if (report.violations.length > MAX_REPORTED_FILES) {
94
+ lines.push(` … and ${report.violations.length - MAX_REPORTED_FILES} more file/rule pair(s)`);
95
+ }
96
+ lines.push('Run `peaks codegraph repair-exclude --project <root>` to drop these rules, back up the config, and rebuild the index.');
97
+ return lines;
98
+ }
@@ -0,0 +1,26 @@
1
+ export declare function matchesCodegraphGlob(filePath: string, pattern: string): boolean;
2
+ export type CodegraphExcludeReconcileInput = {
3
+ readonly trackedFiles: readonly string[];
4
+ readonly include: readonly string[];
5
+ readonly exclude: readonly string[];
6
+ };
7
+ export type CodegraphExcludeViolation = {
8
+ readonly path: string;
9
+ readonly matchedRule: string;
10
+ };
11
+ export type CodegraphExcludeReconcileResult = {
12
+ readonly violations: readonly CodegraphExcludeViolation[];
13
+ readonly rulesToRemove: readonly string[];
14
+ readonly trackedSourceCount: number;
15
+ readonly excludedTrackedCount: number;
16
+ };
17
+ export declare function reconcileCodegraphExclude(input: CodegraphExcludeReconcileInput): CodegraphExcludeReconcileResult;
18
+ export declare const CODEGRAPH_CONFIG_FILENAME = "config.json";
19
+ export type CodegraphExcludeConfig = {
20
+ readonly include: readonly string[];
21
+ readonly exclude: readonly string[];
22
+ };
23
+ export declare function readTrackedFiles(projectRoot: string): readonly string[];
24
+ export declare function assertStringArray(value: unknown, field: string, configPath: string): readonly string[];
25
+ export declare function readCodegraphExcludeConfig(projectRoot: string): CodegraphExcludeConfig;
26
+ export declare function reconcileCodegraphExcludeFromProject(projectRoot: string): CodegraphExcludeReconcileResult;
@@ -0,0 +1,217 @@
1
+ // src/services/codegraph/codegraph-exclude-reconciler.ts
2
+ //
3
+ // Slice S1 of `2026-09-12-codegraph-exclude-integrity` — the pure
4
+ // reconciliation core. It answers one question:
5
+ //
6
+ // "Which git-tracked source files does the codegraph `exclude` list
7
+ // silently block, and which rules must be removed to unblock them?"
8
+ //
9
+ // Why this exists (the real defect): upstream `@colbymchenry/codegraph`
10
+ // ships a 99-entry default `exclude` template matched by *directory
11
+ // name* via `picomatch.isMatch(path, pattern, { dot: true })`. Upstream
12
+ // `mergeConfig` has no separate override channel — `config.json`'s
13
+ // `exclude` array replaces the defaults wholesale — so any default rule
14
+ // whose directory name collides with real source silently drops tracked
15
+ // files from the index while `peaks codegraph status` still reports
16
+ // `[OK] Index is up to date`. This repo has five such rules:
17
+ // `**` + `/artifacts/**`, `**` + `/release/**`, `**` + `/vendor/**`,
18
+ // `**` + `/bin/**`, `**` + `/publish/**`.
19
+ //
20
+ // Scope of this module:
21
+ // - PURE: `reconcileCodegraphExclude` takes already-resolved data and
22
+ // computes violations + the rules to remove. No fs, no spawn.
23
+ // - ADAPTER: `readTrackedFiles` / `readCodegraphExcludeConfig` /
24
+ // `reconcileCodegraphExcludeFromProject` are the thin
25
+ // boundary-reading helpers. They READ ONLY — this module never
26
+ // writes `.codegraph/config.json` (that is S2's repair path).
27
+ //
28
+ // It is deliberately generic: no hardcoded rule list. Any rule that
29
+ // blocks a tracked source file is caught by the same logic, in this
30
+ // repo or any other.
31
+ //
32
+ // NOTE FOR FUTURE EDITORS: glob literals contain the two-character
33
+ // sequence that ends a block comment, so every comment in this file
34
+ // uses `//` lines. Do not convert them to `/* ... */`.
35
+ import { execFileSync } from 'node:child_process';
36
+ import { readFileSync } from 'node:fs';
37
+ import { join } from 'node:path';
38
+ import picomatch from 'picomatch';
39
+ import { normalizePath } from '../../shared/path-utils.js';
40
+ import { CODEGRAPH_DIR_NAME } from './codegraph-service.js';
41
+ // ─────────────────────────────────────────────────────────────────────
42
+ // Glob matching — delegated to `picomatch`, the same engine upstream uses
43
+ //
44
+ // Upstream `@colbymchenry/codegraph` matches its `exclude` / `include`
45
+ // globs with `picomatch.isMatch(path, pattern, { dot: true })`.
46
+ //
47
+ // This module used to re-implement that matcher by hand, covering only
48
+ // `**`, `*` and `?`. That was a silent-failure generator: the hand-rolled
49
+ // version returned `false` for every *extended* glob (`{a,b}`, `[abc]`,
50
+ // `!(...)`), so a config carrying `**/{release,artifacts}/**` was
51
+ // reconciled as clean while the gap was still there — `init` and
52
+ // `repair-exclude` reported success over an incomplete index, which is
53
+ // the exact failure this mechanism exists to prevent. The generated
54
+ // regexes were also superlinear in the number of `**/` segments.
55
+ //
56
+ // So the matcher IS picomatch now: promoted from a transitive dependency
57
+ // of `@colbymchenry/codegraph` to a direct one, pinned to the version
58
+ // already in the tree (4.0.4). No new install weight, no fork of the
59
+ // semantics, and no way to drift from upstream again.
60
+ //
61
+ // `dot: true` keeps upstream's semantics: `*` and `?` also match a
62
+ // leading `.`.
63
+ //
64
+ // NOTE: this delegation was differential-tested against the previous
65
+ // implementation over every git-tracked file in this repo x every rule
66
+ // of both the upstream 99-rule default template and this repo's config,
67
+ // with zero disagreements on the vocabulary the old matcher supported —
68
+ // the only differences being the extended globs it used to get wrong.
69
+ //
70
+ // One side effect of the delegation had to be repaired right after it
71
+ // shipped: `picomatch('')` throws, so a config carrying `"exclude": [""]`
72
+ // made the whole reconciliation throw, and `peaks codegraph status`
73
+ // turned that into `[WARN] … not evaluated` with exit 0 — a silent false
74
+ // pass over a real index gap, which is precisely what this module exists
75
+ // to prevent. Empty / whitespace-only rules are now skipped before
76
+ // compilation (`isUnmatchableRule`), so no single junk entry can mask the
77
+ // verdict of the other rules.
78
+ // ─────────────────────────────────────────────────────────────────────
79
+ const PICOMATCH_OPTIONS = { dot: true };
80
+ // `picomatch(glob, options)` parses the glob once and returns a reusable
81
+ // matcher. Compiling per rule (not per file x rule) is what keeps a
82
+ // reconciliation over N files x M rules linear in N.
83
+ function compileGlob(pattern) {
84
+ return { pattern, match: picomatch(pattern, PICOMATCH_OPTIONS) };
85
+ }
86
+ // A rule that carries no glob at all — empty or whitespace-only — cannot
87
+ // name a project-relative source path, so it is skipped instead of
88
+ // compiled.
89
+ //
90
+ // Why this guard exists (regression introduced by the very commit that
91
+ // delegated matching to picomatch): `picomatch('')` THROWS
92
+ // ("Expected pattern to be a non-empty string"). A config carrying
93
+ // `"exclude": [""]` therefore aborted the whole reconciliation, and the
94
+ // consumers degraded that throw into a silent false pass — `status`
95
+ // printed `[WARN] codegraph exclude integrity not evaluated` and left
96
+ // the exit code at 0 while tracked source files were still excluded.
97
+ // Skipping the rule is semantically exact (it matches nothing) and keeps
98
+ // every OTHER rule's verdict, so one junk entry can never mask a real
99
+ // gap. `picomatch(' ')` does not throw; it simply matches no real path,
100
+ // so dropping it is a no-op with the same outcome.
101
+ function isUnmatchableRule(pattern) {
102
+ return pattern.trim().length === 0;
103
+ }
104
+ // Compile a rule list, dropping the rules that cannot match anything.
105
+ function compileRules(patterns) {
106
+ return patterns.filter((pattern) => !isUnmatchableRule(pattern)).map(compileGlob);
107
+ }
108
+ // Does `filePath` match `pattern` under the same rules upstream uses?
109
+ // Exported so the test suite can pin the glob semantics directly,
110
+ // including the negative case: the artifacts rule must NOT match
111
+ // `src/artifactsman/foo.ts`.
112
+ //
113
+ // Total by construction: an unmatchable rule answers `false` rather than
114
+ // throwing, so no caller can be turned into a false pass by one junk
115
+ // entry in the config. For every non-empty pattern the answer is the
116
+ // picomatch answer, unchanged.
117
+ export function matchesCodegraphGlob(filePath, pattern) {
118
+ if (isUnmatchableRule(pattern)) {
119
+ return false;
120
+ }
121
+ return compileGlob(pattern).match(normalizePath(filePath));
122
+ }
123
+ // Reconcile the codegraph `exclude` list against the set of git-tracked
124
+ // source files. Pure: no fs, no spawn, no clock.
125
+ //
126
+ // The `include` filter runs first: a rule that only blocks files the
127
+ // index would not ingest anyway (e.g. a markdown-only rule) is not a
128
+ // violation and is not removed.
129
+ export function reconcileCodegraphExclude(input) {
130
+ // Unmatchable rules are dropped from BOTH lists before compiling — see
131
+ // `isUnmatchableRule`. An empty `include` entry admits nothing, which
132
+ // is the same verdict as an explicitly empty `include` list, so it
133
+ // needs no special case here.
134
+ const includeRules = compileRules(input.include);
135
+ const excludeRules = compileRules(input.exclude);
136
+ const trackedSourceFiles = [];
137
+ for (const candidate of input.trackedFiles) {
138
+ const normalizedPath = normalizePath(candidate);
139
+ if (includeRules.some((rule) => rule.match(normalizedPath))) {
140
+ trackedSourceFiles.push(normalizedPath);
141
+ }
142
+ }
143
+ const violations = [];
144
+ const offendingRules = new Set();
145
+ const blockedFiles = new Set();
146
+ for (const path of trackedSourceFiles) {
147
+ for (const rule of excludeRules) {
148
+ if (!rule.match(path)) {
149
+ continue;
150
+ }
151
+ violations.push({ path, matchedRule: rule.pattern });
152
+ offendingRules.add(rule.pattern);
153
+ blockedFiles.add(path);
154
+ }
155
+ }
156
+ return {
157
+ violations,
158
+ rulesToRemove: input.exclude.filter((pattern) => offendingRules.has(pattern)),
159
+ trackedSourceCount: trackedSourceFiles.length,
160
+ excludedTrackedCount: blockedFiles.size
161
+ };
162
+ }
163
+ // ─────────────────────────────────────────────────────────────────────
164
+ // Thin boundary adapters — READ ONLY
165
+ // ─────────────────────────────────────────────────────────────────────
166
+ // Upstream `CONFIG_FILENAME` inside `<projectRoot>/.codegraph/`.
167
+ export const CODEGRAPH_CONFIG_FILENAME = 'config.json';
168
+ // Project-relative paths of every git-tracked file, exactly as
169
+ // `git ls-files` reports them. Why git and not an fs walk: the index
170
+ // must cover what git tracks (see the anti-fake-green contract in
171
+ // `src/services/dispatch/dispatch-sub-agent.ts`), so git is the only
172
+ // admissible source of truth.
173
+ //
174
+ // Throws when `projectRoot` is not inside a git work tree — callers
175
+ // decide whether that is fatal; this function never swallows it.
176
+ export function readTrackedFiles(projectRoot) {
177
+ const stdout = execFileSync('git', ['-C', projectRoot, 'ls-files'], {
178
+ encoding: 'utf8',
179
+ maxBuffer: 64 * 1024 * 1024
180
+ });
181
+ return stdout
182
+ .split('\n')
183
+ .map((line) => line.trim())
184
+ .filter((line) => line.length > 0)
185
+ .map((line) => normalizePath(line));
186
+ }
187
+ // Exported for S2's repair writer, which re-validates `exclude` on the
188
+ // way out so the read and write paths agree on what a valid config is.
189
+ export function assertStringArray(value, field, configPath) {
190
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {
191
+ throw new Error(`codegraph config ${configPath}: "${field}" must be an array of strings`);
192
+ }
193
+ return value;
194
+ }
195
+ // Read `<projectRoot>/.codegraph/config.json` and return just the two
196
+ // glob lists the reconciler needs. Read-only: this module never writes
197
+ // that file.
198
+ export function readCodegraphExcludeConfig(projectRoot) {
199
+ const configPath = join(projectRoot, CODEGRAPH_DIR_NAME, CODEGRAPH_CONFIG_FILENAME);
200
+ const parsed = JSON.parse(readFileSync(configPath, 'utf8'));
201
+ if (typeof parsed !== 'object' || parsed === null) {
202
+ throw new Error(`codegraph config ${configPath}: expected a JSON object`);
203
+ }
204
+ const record = parsed;
205
+ return {
206
+ include: assertStringArray(record.include, 'include', configPath),
207
+ exclude: assertStringArray(record.exclude, 'exclude', configPath)
208
+ };
209
+ }
210
+ // Read-only entry point: resolve the project's tracked files + codegraph
211
+ // config from disk and reconcile them. S2's repair path consumes this
212
+ // result and writes the reduced `exclude` list back; S1 only computes.
213
+ export function reconcileCodegraphExcludeFromProject(projectRoot) {
214
+ const trackedFiles = readTrackedFiles(projectRoot);
215
+ const { include, exclude } = readCodegraphExcludeConfig(projectRoot);
216
+ return reconcileCodegraphExclude({ trackedFiles, include, exclude });
217
+ }