peaks-loop 4.0.43 → 4.0.45

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 (35) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/codegraph-commands.d.ts +1 -0
  5. package/dist/cli/commands/codegraph-commands.js +239 -8
  6. package/dist/cli/commands/final-review-commands.d.ts +34 -10
  7. package/dist/cli/commands/final-review-commands.js +132 -34
  8. package/dist/cli/commands/share-commands.d.ts +49 -0
  9. package/dist/cli/commands/share-commands.js +114 -14
  10. package/dist/services/codegraph/codegraph-autorefresh.js +12 -0
  11. package/dist/services/codegraph/codegraph-exclude-integrity.d.ts +61 -0
  12. package/dist/services/codegraph/codegraph-exclude-integrity.js +98 -0
  13. package/dist/services/codegraph/codegraph-exclude-reconciler.d.ts +26 -0
  14. package/dist/services/codegraph/codegraph-exclude-reconciler.js +217 -0
  15. package/dist/services/codegraph/codegraph-exclude-repair.d.ts +102 -0
  16. package/dist/services/codegraph/codegraph-exclude-repair.js +266 -0
  17. package/dist/services/codegraph/codegraph-preflight-service.js +12 -0
  18. package/dist/services/codegraph/codegraph-service.d.ts +0 -1
  19. package/dist/services/codegraph/codegraph-service.js +5 -4
  20. package/dist/services/doctor/doctor-service/checks/codegraph-exclude-integrity.d.ts +29 -0
  21. package/dist/services/doctor/doctor-service/checks/codegraph-exclude-integrity.js +88 -0
  22. package/dist/services/doctor/doctor-service/plugin-registry.js +2 -0
  23. package/dist/services/doctor/doctor-service/types.d.ts +27 -0
  24. package/dist/services/final-review/final-review-service.d.ts +335 -1
  25. package/dist/services/final-review/final-review-service.js +1457 -6
  26. package/dist/services/final-review/index.d.ts +2 -1
  27. package/dist/services/final-review/index.js +2 -1
  28. package/dist/services/final-review/pre-post-diff.d.ts +137 -0
  29. package/dist/services/final-review/pre-post-diff.js +657 -0
  30. package/dist/services/prd/handoff-auto-regen.js +0 -1
  31. package/dist/services/prd/handoff-service.d.ts +9 -1
  32. package/dist/services/prd/handoff-service.js +48 -6
  33. package/package.json +7 -5
  34. package/skills/peaks-final-review/SKILL.md +79 -35
  35. package/skills/peaks-final-review/references/4-dimensions.md +42 -5
@@ -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
+ }
@@ -0,0 +1,102 @@
1
+ import { type CodegraphProcessRunner } from './codegraph-service.js';
2
+ /** Suffix of the byte-exact pre-repair copy kept next to the config. */
3
+ export declare const CODEGRAPH_CONFIG_BACKUP_SUFFIX = ".bak";
4
+ export type CodegraphExcludeRepairPlan = {
5
+ /** True when at least one rule would actually be dropped. */
6
+ readonly changed: boolean;
7
+ /** The `exclude` array after the removal. */
8
+ readonly exclude: readonly string[];
9
+ /** Rules actually present in `exclude` and dropped, in config order. */
10
+ readonly removedRules: readonly string[];
11
+ };
12
+ /**
13
+ * Pure: given the current `exclude` list and the rules to drop, return
14
+ * the new list. No fs, no clock, no serialization.
15
+ *
16
+ * A rule named in `rulesToRemove` but absent from `exclude` is NOT
17
+ * invented — the result is a subset of the input, so a caller can
18
+ * never add a rule by accident. Removing an already-absent rule is a
19
+ * no-op, which is what makes the whole repair idempotent: feeding the
20
+ * repaired list back in yields `changed: false`.
21
+ */
22
+ export declare function repairCodegraphExclude(input: {
23
+ readonly exclude: readonly string[];
24
+ readonly rulesToRemove: readonly string[];
25
+ }): CodegraphExcludeRepairPlan;
26
+ export type CodegraphExcludeRepairOutcome = {
27
+ readonly applied: false;
28
+ readonly reason: 'no-rules-to-remove';
29
+ readonly removedRules: readonly string[];
30
+ } | {
31
+ readonly applied: true;
32
+ readonly configPath: string;
33
+ readonly backupPath: string;
34
+ readonly removedRules: readonly string[];
35
+ readonly excludeCountBefore: number;
36
+ readonly excludeCountAfter: number;
37
+ };
38
+ /**
39
+ * Apply the repair to `<projectRoot>/.codegraph/config.json`.
40
+ *
41
+ * No-op (and no write, no mtime change, no backup) when
42
+ * `rulesToRemove` is empty. Otherwise: back up the original bytes to
43
+ * `config.json.bak`, then rewrite the file with `exclude` reduced by
44
+ * exactly the rules that were both requested and present.
45
+ *
46
+ * Throws only on real fs/parse failures — the caller decides whether
47
+ * that is fatal (`repair-exclude` → non-zero exit) or a surfaced
48
+ * warning (`init` → keep going, the init itself already succeeded).
49
+ */
50
+ export declare function applyCodegraphExcludeRepair(projectRoot: string, rulesToRemove: readonly string[]): CodegraphExcludeRepairOutcome;
51
+ export type CodegraphExcludeRepairReport = {
52
+ /** True when the config was actually rewritten. */
53
+ readonly applied: boolean;
54
+ /** Rules dropped from `exclude`. */
55
+ readonly rulesRemoved: readonly string[];
56
+ /** Distinct tracked source files the dropped rules had been hiding. */
57
+ readonly filesRecovered: number;
58
+ /** Tracked files the config's `include` filter admits at all. */
59
+ readonly trackedSourceCount: number;
60
+ /** Absolute path of the rewritten config (empty when nothing was written). */
61
+ readonly configPath: string;
62
+ /** Byte-exact rollback copy (null when nothing was written). */
63
+ readonly backupPath: string | null;
64
+ /** True when the post-repair `codegraph index` finished successfully. */
65
+ readonly reindexed: boolean;
66
+ /**
67
+ * Non-null when the step could not run to completion (no git repo,
68
+ * missing config, malformed config, upstream index failure, …).
69
+ * Always reported, never swallowed — but never thrown either, so a
70
+ * successful `peaks codegraph init` is never undone by it.
71
+ */
72
+ readonly warning: string | null;
73
+ };
74
+ export type CodegraphExcludeRepairOptions = {
75
+ /**
76
+ * When `false`, skip the follow-up `codegraph index` rebuild and
77
+ * return `reindexed: false` (with no warning — nothing went wrong).
78
+ *
79
+ * Set by callers that run their OWN index immediately after this
80
+ * step: the pre-dispatch preflight and the post-slice autorefresh
81
+ * both call `init` → `index`, and an index is expensive enough
82
+ * (5-30 s on this repo) that a fresh init must not pay for it twice.
83
+ */
84
+ readonly reindex?: boolean;
85
+ };
86
+ /**
87
+ * Reconcile `projectRoot`'s exclude list, drop the rules that block
88
+ * tracked source files, and rebuild the index so the recovered files
89
+ * actually land in it.
90
+ *
91
+ * This is the ONE shared "after upstream init" self-heal: the CLI's
92
+ * fresh `peaks codegraph init`, the pre-dispatch preflight and the
93
+ * post-slice autorefresh all call it, so a fresh clone cannot end up
94
+ * with a peaks-loop marker stamped over an incomplete index that no
95
+ * later `init` would ever repair.
96
+ *
97
+ * Never throws. Every failure becomes a populated `warning` field, so
98
+ * the caller can surface it without the step being able to break the
99
+ * command it is attached to — including the fail-soft preflight and
100
+ * the never-throwing `refreshCodegraphAfterSlice`.
101
+ */
102
+ export declare function repairCodegraphExcludeFromProject(projectRoot: string, runner?: CodegraphProcessRunner, options?: CodegraphExcludeRepairOptions): Promise<CodegraphExcludeRepairReport>;
@@ -0,0 +1,266 @@
1
+ // src/services/codegraph/codegraph-exclude-repair.ts
2
+ //
3
+ // Slice S2 of `2026-09-12-codegraph-exclude-integrity` — the WRITE path.
4
+ //
5
+ // S1 computes `rulesToRemove`; this module applies it. Two callers are
6
+ // allowed to reach it and nothing else is:
7
+ //
8
+ // 1. `peaks codegraph init` — after a fresh upstream init (which
9
+ // always writes upstream's 99-rule default template), so a brand
10
+ // new clone / new machine ends up with a complete index instead
11
+ // of silently dropping tracked source files.
12
+ // 2. `peaks codegraph repair-exclude` — the explicit repair for a
13
+ // workspace that was initialized before this slice shipped, or
14
+ // whose config drifted.
15
+ //
16
+ // `status` and the doctor check deliberately do NOT live here: they
17
+ // read `codegraph-exclude-integrity.ts` and never write.
18
+ //
19
+ // Safety posture (the file being edited belongs to a THIRD-PARTY tool):
20
+ // - `rulesToRemove` comes from S1, which only ever lists a rule that
21
+ // actually blocks at least one git-tracked source file. A rule that
22
+ // matches nothing tracked is never dropped.
23
+ // - Nothing is written when `rulesToRemove` is empty — the config
24
+ // bytes, and its mtime, are untouched.
25
+ // - The original bytes are copied to `config.json.bak` before the
26
+ // rewrite, so a rollback is byte-exact.
27
+ // - The rewrite itself goes through a same-directory temp file plus
28
+ // `renameSync`, so the config is never observed half-written.
29
+ // - Every other key of the config survives verbatim, in its original
30
+ // position; only `exclude` changes.
31
+ import { randomBytes } from 'node:crypto';
32
+ import { readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
33
+ import { join } from 'node:path';
34
+ import { assertStringArray, CODEGRAPH_CONFIG_FILENAME } from './codegraph-exclude-reconciler.js';
35
+ import { CODEGRAPH_DIR_NAME, createCodegraphInvocation, executeCodegraphInvocation } from './codegraph-service.js';
36
+ import { inspectCodegraphExcludeIntegrity } from './codegraph-exclude-integrity.js';
37
+ /** Suffix of the byte-exact pre-repair copy kept next to the config. */
38
+ export const CODEGRAPH_CONFIG_BACKUP_SUFFIX = '.bak';
39
+ /**
40
+ * Pure: given the current `exclude` list and the rules to drop, return
41
+ * the new list. No fs, no clock, no serialization.
42
+ *
43
+ * A rule named in `rulesToRemove` but absent from `exclude` is NOT
44
+ * invented — the result is a subset of the input, so a caller can
45
+ * never add a rule by accident. Removing an already-absent rule is a
46
+ * no-op, which is what makes the whole repair idempotent: feeding the
47
+ * repaired list back in yields `changed: false`.
48
+ */
49
+ export function repairCodegraphExclude(input) {
50
+ const removable = new Set(input.rulesToRemove);
51
+ // De-duplicated, order-preserving. A config that lists the same rule
52
+ // twice would otherwise be counted twice here, and this array is what
53
+ // the caller reports to the user as "rules removed".
54
+ const removedRules = [...new Set(input.exclude.filter((rule) => removable.has(rule)))];
55
+ if (removedRules.length === 0) {
56
+ return { changed: false, exclude: input.exclude, removedRules: [] };
57
+ }
58
+ return {
59
+ changed: true,
60
+ exclude: input.exclude.filter((rule) => !removable.has(rule)),
61
+ removedRules
62
+ };
63
+ }
64
+ /**
65
+ * Detect the indentation the config already uses so the rewrite keeps
66
+ * the file's shape instead of reformatting a third-party tool's file.
67
+ *
68
+ * A single-line (minified) config has no indentation to copy: `0` tells
69
+ * `JSON.stringify` to emit compact JSON, so the file comes back as the
70
+ * one-liner it went in as. `0` is NOT the same as "not set" here — an
71
+ * omitted indent would also be compact, but returning a number keeps the
72
+ * intent explicit at the call site.
73
+ *
74
+ * Falls back to two spaces when the file spans lines but has no indented
75
+ * member.
76
+ */
77
+ function detectIndent(text) {
78
+ if (!text.trimEnd().includes('\n')) {
79
+ return 0;
80
+ }
81
+ const match = /\n([ \t]+)"/.exec(text);
82
+ return match?.[1] ?? 2;
83
+ }
84
+ function serializeConfig(config, originalText) {
85
+ const body = JSON.stringify(config, null, detectIndent(originalText));
86
+ return originalText.endsWith('\n') ? `${body}\n` : body;
87
+ }
88
+ /**
89
+ * Write `content` to `filePath` atomically: same-directory temp file,
90
+ * then `renameSync` over the target.
91
+ *
92
+ * The file being written belongs to a THIRD-PARTY tool, so a crash or a
93
+ * full disk mid-`writeFileSync` must never leave a half-written config
94
+ * behind. `rename` within one directory is atomic, so a reader sees
95
+ * either the old bytes or the new ones, never a prefix of the new ones.
96
+ * The temp file lives next to the target (same directory ⇒ same
97
+ * filesystem ⇒ the rename cannot degrade to a cross-device copy) and is
98
+ * removed if the write or the rename fails.
99
+ *
100
+ * N5: the temp name carries the pid plus 6 random bytes. A FIXED
101
+ * `${filePath}.tmp` is single-writer only, and this writer has three
102
+ * reachable concurrent callers — a fresh `peaks codegraph init` (which
103
+ * repairs then indexes), the pre-dispatch preflight, and the post-slice
104
+ * autorefresh. Two overlapping writers sharing one temp name let one
105
+ * `renameSync` publish a file the other was still writing, which is
106
+ * exactly the half-written config the temp file exists to prevent. The
107
+ * suffix keeps the temp in the SAME directory, so the rename stays
108
+ * within one filesystem and therefore stays atomic.
109
+ */
110
+ function writeConfigAtomic(filePath, content) {
111
+ const tempPath = `${filePath}.${String(process.pid)}.${randomBytes(6).toString('hex')}.tmp`;
112
+ try {
113
+ writeFileSync(tempPath, content, 'utf8');
114
+ renameSync(tempPath, filePath);
115
+ }
116
+ catch (error) {
117
+ rmSync(tempPath, { force: true });
118
+ throw error;
119
+ }
120
+ }
121
+ /**
122
+ * Apply the repair to `<projectRoot>/.codegraph/config.json`.
123
+ *
124
+ * No-op (and no write, no mtime change, no backup) when
125
+ * `rulesToRemove` is empty. Otherwise: back up the original bytes to
126
+ * `config.json.bak`, then rewrite the file with `exclude` reduced by
127
+ * exactly the rules that were both requested and present.
128
+ *
129
+ * Throws only on real fs/parse failures — the caller decides whether
130
+ * that is fatal (`repair-exclude` → non-zero exit) or a surfaced
131
+ * warning (`init` → keep going, the init itself already succeeded).
132
+ */
133
+ export function applyCodegraphExcludeRepair(projectRoot, rulesToRemove) {
134
+ if (rulesToRemove.length === 0) {
135
+ return { applied: false, reason: 'no-rules-to-remove', removedRules: [] };
136
+ }
137
+ const configPath = join(projectRoot, CODEGRAPH_DIR_NAME, CODEGRAPH_CONFIG_FILENAME);
138
+ const originalText = readFileSync(configPath, 'utf8');
139
+ const parsed = JSON.parse(originalText);
140
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
141
+ throw new Error(`codegraph config ${configPath}: expected a JSON object`);
142
+ }
143
+ const record = parsed;
144
+ const exclude = assertStringArray(record.exclude, 'exclude', configPath);
145
+ const plan = repairCodegraphExclude({ exclude, rulesToRemove });
146
+ if (!plan.changed) {
147
+ return { applied: false, reason: 'no-rules-to-remove', removedRules: [] };
148
+ }
149
+ const backupPath = `${configPath}${CODEGRAPH_CONFIG_BACKUP_SUFFIX}`;
150
+ writeFileSync(backupPath, originalText, 'utf8');
151
+ // Spread first, then replace `exclude` — every other key keeps its
152
+ // original value AND its original position in the serialized object.
153
+ writeConfigAtomic(configPath, serializeConfig({ ...record, exclude: plan.exclude }, originalText));
154
+ return {
155
+ applied: true,
156
+ configPath,
157
+ backupPath,
158
+ removedRules: plan.removedRules,
159
+ excludeCountBefore: exclude.length,
160
+ excludeCountAfter: plan.exclude.length
161
+ };
162
+ }
163
+ function errorMessage(error) {
164
+ return error instanceof Error ? error.message : String(error);
165
+ }
166
+ function emptyRepairReport(warning) {
167
+ return {
168
+ applied: false,
169
+ rulesRemoved: [],
170
+ filesRecovered: 0,
171
+ trackedSourceCount: 0,
172
+ configPath: '',
173
+ backupPath: null,
174
+ reindexed: false,
175
+ warning
176
+ };
177
+ }
178
+ /**
179
+ * Reconcile `projectRoot`'s exclude list, drop the rules that block
180
+ * tracked source files, and rebuild the index so the recovered files
181
+ * actually land in it.
182
+ *
183
+ * This is the ONE shared "after upstream init" self-heal: the CLI's
184
+ * fresh `peaks codegraph init`, the pre-dispatch preflight and the
185
+ * post-slice autorefresh all call it, so a fresh clone cannot end up
186
+ * with a peaks-loop marker stamped over an incomplete index that no
187
+ * later `init` would ever repair.
188
+ *
189
+ * Never throws. Every failure becomes a populated `warning` field, so
190
+ * the caller can surface it without the step being able to break the
191
+ * command it is attached to — including the fail-soft preflight and
192
+ * the never-throwing `refreshCodegraphAfterSlice`.
193
+ */
194
+ export async function repairCodegraphExcludeFromProject(projectRoot, runner, options = {}) {
195
+ let integrity;
196
+ try {
197
+ integrity = inspectCodegraphExcludeIntegrity(projectRoot);
198
+ }
199
+ catch (error) {
200
+ return emptyRepairReport(`codegraph exclude reconcile skipped: ${errorMessage(error)}`);
201
+ }
202
+ if (integrity.rulesToRemove.length === 0) {
203
+ return {
204
+ applied: false,
205
+ rulesRemoved: [],
206
+ filesRecovered: 0,
207
+ trackedSourceCount: integrity.trackedSourceCount,
208
+ configPath: integrity.configPath,
209
+ backupPath: null,
210
+ reindexed: false,
211
+ warning: null
212
+ };
213
+ }
214
+ let outcome;
215
+ try {
216
+ outcome = applyCodegraphExcludeRepair(projectRoot, integrity.rulesToRemove);
217
+ }
218
+ catch (error) {
219
+ return emptyRepairReport(`codegraph exclude repair failed for ${integrity.configPath}: ${errorMessage(error)}`);
220
+ }
221
+ if (!outcome.applied) {
222
+ return {
223
+ applied: false,
224
+ rulesRemoved: [],
225
+ filesRecovered: 0,
226
+ trackedSourceCount: integrity.trackedSourceCount,
227
+ configPath: integrity.configPath,
228
+ backupPath: null,
229
+ reindexed: false,
230
+ warning: null
231
+ };
232
+ }
233
+ const base = {
234
+ applied: true,
235
+ rulesRemoved: outcome.removedRules,
236
+ filesRecovered: integrity.excludedTrackedCount,
237
+ trackedSourceCount: integrity.trackedSourceCount,
238
+ configPath: outcome.configPath,
239
+ backupPath: outcome.backupPath
240
+ };
241
+ if (options.reindex === false) {
242
+ // The caller indexes immediately after this returns, so doing it
243
+ // here too would index the same tree twice for no extra coverage.
244
+ return { ...base, reindexed: false, warning: null };
245
+ }
246
+ // The recovered files are on disk but not in the index yet. Rebuild
247
+ // it now — that is the whole point of repairing at init time.
248
+ try {
249
+ const result = await executeCodegraphInvocation(createCodegraphInvocation({ subcommand: 'index', project: projectRoot, quiet: true }), runner);
250
+ if (result.exitCode !== 0) {
251
+ return {
252
+ ...base,
253
+ reindexed: false,
254
+ warning: `codegraph exclude repaired (${outcome.removedRules.length} rule(s) removed) but the follow-up index failed (exit ${String(result.exitCode)}); run \`peaks codegraph index --project <root>\`.`
255
+ };
256
+ }
257
+ return { ...base, reindexed: true, warning: null };
258
+ }
259
+ catch (error) {
260
+ return {
261
+ ...base,
262
+ reindexed: false,
263
+ warning: `codegraph exclude repaired (${outcome.removedRules.length} rule(s) removed) but the follow-up index could not run: ${errorMessage(error)}`
264
+ };
265
+ }
266
+ }
@@ -33,6 +33,7 @@
33
33
  import { mkdirSync } from 'node:fs';
34
34
  import { CODEGRAPH_DIR_NAME, createCodegraphInvocation, defaultCodegraphInitGuard, executeCodegraphInvocation, isCodegraphInitialized, writeCodegraphMarker, } from './codegraph-service.js';
35
35
  import { defaultCodegraphProcessRunner } from './codegraph-process-runner.js';
36
+ import { repairCodegraphExcludeFromProject } from './codegraph-exclude-repair.js';
36
37
  /** Cap for the directory histogram in the rendered structure block. */
37
38
  export const CODEGRAPH_STRUCTURE_MAX_DIRS = 40;
38
39
  /** Cap for bare root files listed in the rendered structure block. */
@@ -208,6 +209,17 @@ export async function buildCodegraphPreflightBlock(projectRoot, runner) {
208
209
  catch {
209
210
  // Best-effort: a marker-write failure must not undo the init.
210
211
  }
212
+ // Upstream init just wrote its 99-rule default `exclude` template,
213
+ // some of which block tracked source files. Without this, a fresh
214
+ // clone is initialized HERE, by the first dispatch's preflight —
215
+ // marker stamped over an incomplete index — and every later
216
+ // `peaks codegraph init` hits `noop-already-peaks-loop`, so the
217
+ // CLI's own self-heal becomes unreachable. Same shared helper, so
218
+ // all three init landing sites behave identically.
219
+ //
220
+ // Never throws, and `reindex: false` because the index call below
221
+ // covers the recovered files anyway.
222
+ await repairCodegraphExcludeFromProject(projectRoot, processRunner, { reindex: false });
211
223
  }
212
224
  catch (error) {
213
225
  return { available: false, note: `codegraph init unavailable: ${errorMessage(error)}` };
@@ -10,7 +10,6 @@ type BaseCodegraphInvocationOptions = {
10
10
  files?: string[];
11
11
  json?: boolean;
12
12
  quiet?: boolean;
13
- yes?: boolean;
14
13
  force?: boolean;
15
14
  limit?: number;
16
15
  maxDepth?: number;
@@ -13,7 +13,11 @@ const NUMERIC_FLAG_NAMES = ['limit', 'maxDepth'];
13
13
  const COMMON_OPTION_KEYS = ['subcommand', 'project'];
14
14
  const ALLOWED_OPTIONS_BY_SUBCOMMAND = {
15
15
  status: [],
16
- init: ['yes'],
16
+ // Upstream `init` takes NO flags. The `--yes` we used to whitelist
17
+ // here was passed straight through to `@colbymchenry/codegraph`, which
18
+ // rejects it with CODEGRAPH_COMMAND_FAILED — and upstream init never
19
+ // prompts, so there was nothing for it to answer.
20
+ init: [],
17
21
  index: ['force', 'quiet'],
18
22
  query: ['search', 'json', 'limit'],
19
23
  files: ['json', 'maxDepth'],
@@ -120,9 +124,6 @@ function buildCommandArgs(options, projectRoot) {
120
124
  if (options.subcommand === 'affected') {
121
125
  args.push(...buildAffectedFileArgs(projectRoot, options.files));
122
126
  }
123
- if (options.yes === true) {
124
- args.push('--yes');
125
- }
126
127
  if (options.force === true) {
127
128
  args.push('--force');
128
129
  }