peaks-loop 4.0.20 → 4.0.23

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.
@@ -48,6 +48,45 @@ export type DispatchOptions = {
48
48
  graphNode?: string;
49
49
  workflowId?: string;
50
50
  graphRef?: string;
51
+ /**
52
+ * rid-001 detached sub-agent dispatch (slice 2026-08-11): dispatch
53
+ * execution mode. `in-process` (default) keeps the existing warm-path
54
+ * CLI dispatch; `detached` shells out to
55
+ * `peaks-loop-internal-runtime/dispatch.dispatchDetached` for vendor
56
+ * CLI execution. Default `in-process` preserves backward compat with
57
+ * the 106+ existing dispatch call sites.
58
+ */
59
+ mode?: 'in-process' | 'detached';
60
+ /**
61
+ * rid-001 detached sub-agent dispatch: target vendor CLI when
62
+ * --mode detached is selected. Only consulted in the detached path;
63
+ * ignored in the default in-process path. Accepts `claude | codex
64
+ * | copilot` (matches VendorAdapterRegistry).
65
+ */
66
+ vendor?: 'claude' | 'codex' | 'copilot';
67
+ /**
68
+ * rid-001 Task 11.5 budget ceiling: user-overrides ResourceBudgetGuard
69
+ * when active concurrent fan-out would otherwise throttle detached
70
+ * dispatch. The user accepts the risk; surfaces as `warnings[]` only.
71
+ */
72
+ noThrottle?: boolean;
73
+ /**
74
+ * rid-001 Task 11.5: override the per-tenant max-concurrent budget
75
+ * (default 8). Effective in both detached (ResourceBudgetGuard) and
76
+ * in-process (batch-counter) paths.
77
+ */
78
+ maxConcurrent?: string;
79
+ /**
80
+ * F5 follow-up (sediment 2026-08-11-rid-001-redo-fake-green-recovery-closure
81
+ * §Lesson 1): frontmatter `--must-ls-files <glob>` flag. When set, the
82
+ * dispatch CLI runs `git ls-files <glob>` upfront, surfaces
83
+ * `mustLsFilesVerification: { path, exists, files }` in the envelope,
84
+ * and prepends a `## must_ls_files enforcement` block to the sub-agent
85
+ * prompt so the LLM's first action MUST verify the file exists before
86
+ * claiming "PASS". Absent → unchanged behavior (backward compat with
87
+ * the 106+ existing dispatch call sites).
88
+ */
89
+ mustLsFiles?: string;
51
90
  json?: boolean;
52
91
  };
53
92
  export type HeartbeatOptions = {
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Phase B Task 20: peaks vendor-detect CLI.
3
+ * Reports which vendor CLIs are installed on PATH + recommends default.
4
+ * Spec: docs/superpowers/specs/2026-08-10-peaks-detached-sub-agent-design.md §3.3
5
+ */
6
+ import type { Command } from 'commander';
7
+ import { type ProgramIO } from '../cli-helpers.js';
1
8
  export declare function vendorDetect(opts: {
2
9
  json: boolean;
3
10
  }): Promise<{
@@ -8,3 +15,11 @@ export declare function vendorDetect(opts: {
8
15
  recommended: string | null;
9
16
  };
10
17
  }>;
18
+ /**
19
+ * `peaks vendor-detect` CLI registration (rid-001 redo).
20
+ * Slice 2026-08-11 detached-sub-agent-design §3.3 + §5.3 — register the
21
+ * previously-dead-coded `vendorDetect()` handler at the top-level program
22
+ * surface so `peaks vendor-detect --json` actually reaches it (was
23
+ * dead-coded at the CLI seam even though the handler existed).
24
+ */
25
+ export declare function registerVendorDetectCommand(program: Command, io: ProgramIO): void;
@@ -1,8 +1,5 @@
1
- /**
2
- * Phase B Task 20: peaks vendor-detect CLI.
3
- * Reports which vendor CLIs are installed on PATH + recommends default.
4
- * Spec: docs/superpowers/specs/2026-08-10-peaks-detached-sub-agent-design.md §3.3
5
- */
1
+ import { addJsonOption, printResult } from '../cli-helpers.js';
2
+ import { ok } from 'peaks-loop-shared/result';
6
3
  import { defaultRegistry } from 'peaks-loop-internal-runtime';
7
4
  export async function vendorDetect(opts) {
8
5
  const reg = defaultRegistry();
@@ -14,3 +11,23 @@ export async function vendorDetect(opts) {
14
11
  const recommended = installed[0] ?? null;
15
12
  return { ok: true, command: 'vendor-detect', data: { installed, recommended } };
16
13
  }
14
+ /**
15
+ * `peaks vendor-detect` CLI registration (rid-001 redo).
16
+ * Slice 2026-08-11 detached-sub-agent-design §3.3 + §5.3 — register the
17
+ * previously-dead-coded `vendorDetect()` handler at the top-level program
18
+ * surface so `peaks vendor-detect --json` actually reaches it (was
19
+ * dead-coded at the CLI seam even though the handler existed).
20
+ */
21
+ export function registerVendorDetectCommand(program, io) {
22
+ addJsonOption(program
23
+ .command('vendor-detect')
24
+ .description('Detect which vendor CLIs (claude / codex / copilot) are installed ' +
25
+ 'on PATH and recommend a default for --mode detached dispatch. ' +
26
+ 'Returns `{ installed: string[], recommended: <id|null> }`.')).action(async (options) => {
27
+ const result = await vendorDetect({ json: options.json === true });
28
+ printResult(io, ok('vendor-detect', result.data, [], [
29
+ 'Pass the recommended vendor id to `peaks sub-agent dispatch --mode detached --vendor <id>`.',
30
+ 'Re-run with --json to machine-parse.'
31
+ ]), options.json === true);
32
+ });
33
+ }
@@ -26,6 +26,8 @@ import { resolveCanonicalProjectRoot } from '../../../services/config/config-ser
26
26
  import { applyHookInstall, readHookStatus } from '../../../services/skills/hooks-settings-service.js';
27
27
  import { clearStalePresenceOnRotation } from '../../../services/skills/skill-presence-service.js';
28
28
  import { gcStalePresenceLeases } from '../../../services/skills/presence-lease-service.js';
29
+ import { defaultCodegraphInitGuard, writeCodegraphMarker } from '../../../services/codegraph/codegraph-service.js';
30
+ import { mkdirSync } from 'node:fs';
29
31
  import { fail, ok } from 'peaks-loop-shared/result';
30
32
  import { addJsonOption, getErrorMessage, printResult } from '../../cli-helpers.js';
31
33
  import { hasStandardsCheckedMarker, markStandardsChecked } from '../../../services/standards/missing-standards-detector.js';
@@ -337,6 +339,36 @@ export function registerWorkspaceInitCommand(workspace, io) {
337
339
  if (report.standardsMissing.missing) {
338
340
  markStandardsChecked(projectRoot, sessionId);
339
341
  }
342
+ // rid-CG-001: auto-stake a peaks-loop-managed `.codegraph/`
343
+ // directory after a successful workspace init. Calls the
344
+ // conflict guard (rid-CG-006) BEFORE writing so a foreign
345
+ // schema is never silently overwritten. We deliberately do
346
+ // NOT spawn the upstream codegraph binary here — `peaks
347
+ // workspace init` must stay fast and offline-safe; the user
348
+ // runs `peaks codegraph index` next if they want the actual
349
+ // SQLite-backed index.
350
+ let codegraphAutoOutcome = null;
351
+ try {
352
+ const guard = defaultCodegraphInitGuard(projectRoot);
353
+ if (guard.status === 'fresh') {
354
+ mkdirSync(guard.codegraphDir, { recursive: true });
355
+ writeCodegraphMarker(guard.codegraphDir);
356
+ codegraphAutoOutcome = { status: 'fresh' };
357
+ nextActions.push(`Auto-created empty ${guard.codegraphDir}/ and stamped the peaks-loop marker. ` +
358
+ 'Run `peaks codegraph index --project <path>` next to build the SQLite-backed index.');
359
+ }
360
+ else if (guard.status === 'noop-already-peaks-loop') {
361
+ codegraphAutoOutcome = { status: 'noop' };
362
+ }
363
+ else {
364
+ codegraphAutoOutcome = { status: 'conflict' };
365
+ warningsForEnvelope.push(`${guard.codegraphDir} already exists with a non-peaks-loop schema; ` +
366
+ 'refusing to auto-init codegraph. Run `peaks codegraph init --project <path>` after moving the foreign directory.');
367
+ }
368
+ }
369
+ catch (err) {
370
+ warningsForEnvelope.push(`codegraph auto-stake failed: ${getErrorMessage(err)}`);
371
+ }
340
372
  printResult(io, ok('workspace.init', {
341
373
  ...report,
342
374
  // Slice 018: surface outer-session-mismatch rotation in the
@@ -351,6 +383,8 @@ export function registerWorkspaceInitCommand(workspace, io) {
351
383
  scope: hooksOutcome.scope,
352
384
  ...(hooksOutcome.reason !== undefined ? { reason: hooksOutcome.reason } : {})
353
385
  },
386
+ // rid-CG-001: surface codegraph auto-stake outcome.
387
+ ...(codegraphAutoOutcome !== null ? { codegraphAutoStake: codegraphAutoOutcome } : {}),
354
388
  // Slice 2026-07-15-project-scan-bootstrap (G2): envelope
355
389
  // for the .peaks/project-scan/ bootstrap. Always present
356
390
  // (even when skipped / errored) so downstream readers can
@@ -40,4 +40,163 @@ export type CodegraphExecutionResult = {
40
40
  export type CodegraphProcessRunner = (invocation: CodegraphInvocation) => Promise<CodegraphExecutionResult>;
41
41
  export declare function createCodegraphInvocation(options: CodegraphInvocationOptions): CodegraphInvocation;
42
42
  export declare function executeCodegraphInvocation(invocation: CodegraphInvocation, runner?: CodegraphProcessRunner): Promise<CodegraphExecutionResult>;
43
+ /**
44
+ * Slice rid-CG-006 — root `.codegraph/` (legacy). peaks-loop still
45
+ * reads from this location for back-compat with downstream consumers
46
+ * whose projects pre-date the slice rid-CG-003 preferred-path move.
47
+ */
48
+ export declare const CODEGRAPH_DIR_NAME = ".codegraph";
49
+ /**
50
+ * Slice rid-CG-003 — preferred managed location. Going forward
51
+ * peaks-loop writes here; legacy root `.codegraph/` is only used
52
+ * when it pre-exists AND `.peaks/.codegraph/` does not.
53
+ */
54
+ export declare const PREFERRED_CODEGRAPH_DIR = ".peaks/.codegraph";
55
+ /**
56
+ * Marker file peaks-loop writes inside the resolved codegraph dir
57
+ * after a successful upstream init. Its presence distinguishes
58
+ * peaks-loop-managed schemas from foreign ones (aider / cody /
59
+ * etc. all happily use the same directory name).
60
+ */
61
+ export declare const CODEGRAPH_MARKER_NAME = ".peaks-loop-marker";
62
+ export declare const CODEGRAPH_INIT_CONFLICT_EXIT_CODE = 73;
63
+ export type ResolvedCodegraphLocation = {
64
+ readonly source: 'preferred';
65
+ readonly cwd: string;
66
+ readonly codegraphDir: string;
67
+ } | {
68
+ readonly source: 'legacy';
69
+ readonly cwd: string;
70
+ readonly codegraphDir: string;
71
+ } | {
72
+ readonly source: 'fresh-preferred';
73
+ readonly cwd: string;
74
+ readonly codegraphDir: string;
75
+ };
76
+ /**
77
+ * Slice rid-CG-003 — pure resolver that returns the cwd the
78
+ * upstream codegraph binary should be spawned with and the
79
+ * absolute path to the data directory it should read/write.
80
+ *
81
+ * Precedence:
82
+ * 1. `<projectRoot>/.peaks/.codegraph/` exists → use it
83
+ * (cwd = `<projectRoot>/.peaks`, codegraphDir = preferred path)
84
+ * 2. `<projectRoot>/.codegraph/` exists → fall back to legacy
85
+ * (cwd = `<projectRoot>`, codegraphDir = legacy path)
86
+ * 3. neither exists → default to the preferred path so the next
87
+ * `peaks codegraph init` lands in `.peaks/.codegraph/`
88
+ * (cwd = `<projectRoot>/.peaks`, codegraphDir = preferred path,
89
+ * `source: 'fresh-preferred'`)
90
+ *
91
+ * The cwd is the directory the upstream binary treats as "project
92
+ * root" — the binary's default codegraph discovery reads
93
+ * `<cwd>/.codegraph/`, so we always set cwd to the PARENT of the
94
+ * resolved codegraph dir. Pure fs check; no IO beyond `existsSync`.
95
+ */
96
+ export declare function resolveCodegraphProjectRoot(projectRoot: string): ResolvedCodegraphLocation;
97
+ export type CodegraphInitGuardResult = {
98
+ status: 'fresh';
99
+ codegraphDir: string;
100
+ } | {
101
+ status: 'noop-already-peaks-loop';
102
+ codegraphDir: string;
103
+ } | {
104
+ status: 'conflict-foreign-schema';
105
+ codegraphDir: string;
106
+ };
107
+ export declare class CodegraphInitConflictError extends Error {
108
+ readonly codegraphDir: string;
109
+ readonly code = "CODEGRAPH_INIT_CONFLICT";
110
+ readonly exitCode = 73;
111
+ constructor(message: string, codegraphDir: string);
112
+ }
113
+ export type CodegraphInitGuard = (projectRoot: string) => CodegraphInitGuardResult;
114
+ /**
115
+ * Slice rid-CG-003 — preferred-path-first init guard.
116
+ *
117
+ * Looks at `.peaks/.codegraph/` first; falls back to `.codegraph/`
118
+ * when the preferred path is absent. The 'fresh' case returns the
119
+ * preferred path so the next `peaks codegraph init` lands inside
120
+ * `.peaks/` instead of polluting the project root.
121
+ */
122
+ export declare function defaultCodegraphInitGuard(projectRoot: string): CodegraphInitGuardResult;
123
+ /**
124
+ * Pure-fs helper that stamps the peaks-loop marker AFTER a
125
+ * successful upstream init. The CLI action must call this after
126
+ * `executeCodegraphInvocation` returns exit 0.
127
+ */
128
+ export declare function writeCodegraphMarker(codegraphDir: string): void;
129
+ /**
130
+ * Test seam — pure stub of the default guard. Lets callers inject a
131
+ * pre-computed outcome without touching the real filesystem.
132
+ */
133
+ export declare function constantCodegraphInitGuard(outcome: CodegraphInitGuardResult): CodegraphInitGuard;
134
+ /**
135
+ * Per-file row rendered into the codegraph-context envelope's
136
+ * Markdown table. Symbol-count + cross-ref edges are intentionally
137
+ * optional: when the upstream `affected` JSON does not carry them
138
+ * (older codegraph versions, or a non-JSON stdout) we leave the
139
+ * cells blank instead of inventing numbers.
140
+ */
141
+ export type CodegraphAffectedRow = {
142
+ readonly file: string;
143
+ readonly symbolCount?: number;
144
+ readonly crossRefEdges?: number;
145
+ };
146
+ export type WriteCodegraphAffectedContextInput = {
147
+ /** The peaks-loop project root (where `.peaks/_runtime/` lives). */
148
+ readonly projectRoot: string;
149
+ /** Request id, e.g. `rid-CG-002`. Used as the envelope's anchor. */
150
+ readonly rid: string;
151
+ /** Project-relative file paths passed to `peaks codegraph affected`. */
152
+ readonly files: readonly string[];
153
+ /** Raw upstream payload (string or already-parsed JSON). */
154
+ readonly affectedPayload: unknown;
155
+ /**
156
+ * Optional override for the active session id. When omitted,
157
+ * the writer calls `getSessionId(projectRoot)` and falls
158
+ * back to a graceful skip-with-warning when no binding is
159
+ * present (e.g. the consumer ran `peaks codegraph affected`
160
+ * outside of a peaks session).
161
+ */
162
+ readonly sessionId?: string | null;
163
+ /**
164
+ * Optional clock seam for tests; defaults to `new Date()`.
165
+ */
166
+ readonly now?: () => Date;
167
+ };
168
+ export type WriteCodegraphAffectedContextResult = {
169
+ readonly written: true;
170
+ readonly path: string;
171
+ readonly sessionId: string;
172
+ } | {
173
+ readonly written: false;
174
+ readonly path: '';
175
+ readonly warning: string;
176
+ };
177
+ /**
178
+ * Pure renderer — turns the affected payload + rid + file list
179
+ * into a Markdown body with a leading human-readable table and
180
+ * a trailing JSON fence for machine consumers.
181
+ */
182
+ export declare function renderCodegraphAffectedContext(rid: string, files: readonly string[], affectedPayload: unknown, sessionId: string, now?: () => Date): string;
183
+ /**
184
+ * Write the `codegraph-context.md` envelope into the canonical
185
+ * session directory (`.peaks/_runtime/<sessionId>/rd/`). The
186
+ * envelope is what peaks-code's RD dispatch hook reads back to
187
+ * seed the QA / TXT handoff (see `peaks-code/SKILL.md`
188
+ * §"Codegraph orchestration context").
189
+ *
190
+ * The function is intentionally side-effect-safe:
191
+ * - No session binding → returns `{ written: false, warning }`,
192
+ * does NOT throw. Callers can either surface the warning or
193
+ * treat it as a no-op.
194
+ * - Directory creation is `mkdirSync({ recursive: true })`, so
195
+ * the first run in a fresh session creates the `rd/` subdir
196
+ * on demand.
197
+ * - The path is canonicalized through `getSessionDir` so
198
+ * downstream readers (QA / TXT) get a stable path even when
199
+ * the caller passed a relative `projectRoot`.
200
+ */
201
+ export declare function writeCodegraphAffectedContext(input: WriteCodegraphAffectedContextInput): WriteCodegraphAffectedContextResult;
43
202
  export {};
@@ -1,7 +1,8 @@
1
- import { existsSync, realpathSync, statSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { createRequire } from 'node:module';
3
- import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
3
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
4
  import { defaultCodegraphProcessRunner } from './codegraph-process-runner.js';
5
+ import { getSessionId, getSessionDir } from '../session/index.js';
5
6
  const CODEGRAPH_PACKAGE_NAME = '@colbymchenry/codegraph';
6
7
  const CODEGRAPH_PACKAGE_VERSION = '0.7.10';
7
8
  const CODEGRAPH_EXECUTABLE = process.execPath;
@@ -142,6 +143,11 @@ function buildCommandArgs(options, projectRoot) {
142
143
  export function createCodegraphInvocation(options) {
143
144
  assertSupportedSubcommand(options.subcommand);
144
145
  const projectRoot = resolveProjectRoot(options.project);
146
+ // Slice rid-CG-003 — spawn the upstream binary inside the
147
+ // resolved codegraph dir's PARENT so its default `.codegraph/`
148
+ // discovery lands on `.peaks/.codegraph/` (preferred) or
149
+ // `.codegraph/` (legacy fallback).
150
+ const location = resolveCodegraphProjectRoot(projectRoot);
145
151
  assertSupportedOptions(options);
146
152
  assertRequiredOptions(options);
147
153
  assertPositiveInteger(options.limit, 'limit');
@@ -149,7 +155,7 @@ export function createCodegraphInvocation(options) {
149
155
  return {
150
156
  executable: CODEGRAPH_EXECUTABLE,
151
157
  args: buildCommandArgs(options, projectRoot),
152
- cwd: projectRoot,
158
+ cwd: location.cwd,
153
159
  packageName: CODEGRAPH_PACKAGE_NAME,
154
160
  packageVersion: CODEGRAPH_PACKAGE_VERSION,
155
161
  subcommand: options.subcommand
@@ -158,3 +164,211 @@ export function createCodegraphInvocation(options) {
158
164
  export async function executeCodegraphInvocation(invocation, runner = defaultCodegraphProcessRunner) {
159
165
  return runner(invocation);
160
166
  }
167
+ /* ──────────────────────────────────────────────────────────────────────
168
+ * Slice rid-CG-003 — preferred `.peaks/.codegraph/` lookup + legacy
169
+ * root `.codegraph/` fallback (spike follow-up #1).
170
+ * ────────────────────────────────────────────────────────────────────── */
171
+ /**
172
+ * Slice rid-CG-006 — root `.codegraph/` (legacy). peaks-loop still
173
+ * reads from this location for back-compat with downstream consumers
174
+ * whose projects pre-date the slice rid-CG-003 preferred-path move.
175
+ */
176
+ export const CODEGRAPH_DIR_NAME = '.codegraph';
177
+ /**
178
+ * Slice rid-CG-003 — preferred managed location. Going forward
179
+ * peaks-loop writes here; legacy root `.codegraph/` is only used
180
+ * when it pre-exists AND `.peaks/.codegraph/` does not.
181
+ */
182
+ export const PREFERRED_CODEGRAPH_DIR = '.peaks/.codegraph';
183
+ /**
184
+ * Marker file peaks-loop writes inside the resolved codegraph dir
185
+ * after a successful upstream init. Its presence distinguishes
186
+ * peaks-loop-managed schemas from foreign ones (aider / cody /
187
+ * etc. all happily use the same directory name).
188
+ */
189
+ export const CODEGRAPH_MARKER_NAME = '.peaks-loop-marker';
190
+ export const CODEGRAPH_INIT_CONFLICT_EXIT_CODE = 73;
191
+ /**
192
+ * Slice rid-CG-003 — pure resolver that returns the cwd the
193
+ * upstream codegraph binary should be spawned with and the
194
+ * absolute path to the data directory it should read/write.
195
+ *
196
+ * Precedence:
197
+ * 1. `<projectRoot>/.peaks/.codegraph/` exists → use it
198
+ * (cwd = `<projectRoot>/.peaks`, codegraphDir = preferred path)
199
+ * 2. `<projectRoot>/.codegraph/` exists → fall back to legacy
200
+ * (cwd = `<projectRoot>`, codegraphDir = legacy path)
201
+ * 3. neither exists → default to the preferred path so the next
202
+ * `peaks codegraph init` lands in `.peaks/.codegraph/`
203
+ * (cwd = `<projectRoot>/.peaks`, codegraphDir = preferred path,
204
+ * `source: 'fresh-preferred'`)
205
+ *
206
+ * The cwd is the directory the upstream binary treats as "project
207
+ * root" — the binary's default codegraph discovery reads
208
+ * `<cwd>/.codegraph/`, so we always set cwd to the PARENT of the
209
+ * resolved codegraph dir. Pure fs check; no IO beyond `existsSync`.
210
+ */
211
+ export function resolveCodegraphProjectRoot(projectRoot) {
212
+ const preferredDir = join(projectRoot, PREFERRED_CODEGRAPH_DIR);
213
+ const legacyDir = join(projectRoot, CODEGRAPH_DIR_NAME);
214
+ if (existsSync(preferredDir)) {
215
+ return {
216
+ source: 'preferred',
217
+ cwd: join(projectRoot, '.peaks'),
218
+ codegraphDir: preferredDir
219
+ };
220
+ }
221
+ if (existsSync(legacyDir)) {
222
+ return {
223
+ source: 'legacy',
224
+ cwd: projectRoot,
225
+ codegraphDir: legacyDir
226
+ };
227
+ }
228
+ return {
229
+ source: 'fresh-preferred',
230
+ cwd: join(projectRoot, '.peaks'),
231
+ codegraphDir: preferredDir
232
+ };
233
+ }
234
+ export class CodegraphInitConflictError extends Error {
235
+ codegraphDir;
236
+ code = 'CODEGRAPH_INIT_CONFLICT';
237
+ exitCode = CODEGRAPH_INIT_CONFLICT_EXIT_CODE;
238
+ constructor(message, codegraphDir) {
239
+ super(message);
240
+ this.codegraphDir = codegraphDir;
241
+ this.name = 'CodegraphInitConflictError';
242
+ }
243
+ }
244
+ /**
245
+ * Inspect a candidate codegraph directory and return its guard
246
+ * status. A file (or symlink-to-file) at the path counts as a
247
+ * foreign-schema conflict because it blocks directory creation.
248
+ */
249
+ function inspectCandidateCodegraphDir(codegraphDir) {
250
+ let isDir = false;
251
+ try {
252
+ isDir = statSync(codegraphDir).isDirectory();
253
+ }
254
+ catch {
255
+ isDir = false;
256
+ }
257
+ if (!isDir) {
258
+ return { status: 'conflict-foreign-schema', codegraphDir };
259
+ }
260
+ const markerPath = join(codegraphDir, CODEGRAPH_MARKER_NAME);
261
+ if (existsSync(markerPath)) {
262
+ return { status: 'noop-already-peaks-loop', codegraphDir };
263
+ }
264
+ return { status: 'conflict-foreign-schema', codegraphDir };
265
+ }
266
+ /**
267
+ * Slice rid-CG-003 — preferred-path-first init guard.
268
+ *
269
+ * Looks at `.peaks/.codegraph/` first; falls back to `.codegraph/`
270
+ * when the preferred path is absent. The 'fresh' case returns the
271
+ * preferred path so the next `peaks codegraph init` lands inside
272
+ * `.peaks/` instead of polluting the project root.
273
+ */
274
+ export function defaultCodegraphInitGuard(projectRoot) {
275
+ const preferredDir = join(projectRoot, PREFERRED_CODEGRAPH_DIR);
276
+ const legacyDir = join(projectRoot, CODEGRAPH_DIR_NAME);
277
+ if (existsSync(preferredDir)) {
278
+ return inspectCandidateCodegraphDir(preferredDir);
279
+ }
280
+ if (existsSync(legacyDir)) {
281
+ return inspectCandidateCodegraphDir(legacyDir);
282
+ }
283
+ return { status: 'fresh', codegraphDir: preferredDir };
284
+ }
285
+ /**
286
+ * Pure-fs helper that stamps the peaks-loop marker AFTER a
287
+ * successful upstream init. The CLI action must call this after
288
+ * `executeCodegraphInvocation` returns exit 0.
289
+ */
290
+ export function writeCodegraphMarker(codegraphDir) {
291
+ writeFileSync(join(codegraphDir, CODEGRAPH_MARKER_NAME), 'peaks-loop-managed\n', 'utf8');
292
+ }
293
+ /**
294
+ * Test seam — pure stub of the default guard. Lets callers inject a
295
+ * pre-computed outcome without touching the real filesystem.
296
+ */
297
+ export function constantCodegraphInitGuard(outcome) {
298
+ return () => outcome;
299
+ }
300
+ /**
301
+ * Pure renderer — turns the affected payload + rid + file list
302
+ * into a Markdown body with a leading human-readable table and
303
+ * a trailing JSON fence for machine consumers.
304
+ */
305
+ export function renderCodegraphAffectedContext(rid, files, affectedPayload, sessionId, now = () => new Date()) {
306
+ const rows = files.map((file) => ({ file }));
307
+ const generatedAt = now().toISOString();
308
+ const lines = [
309
+ '# Codegraph orchestration context',
310
+ '',
311
+ `- sessionId: \`${sessionId}\``,
312
+ `- rid: \`${rid}\``,
313
+ `- generatedAt: \`${generatedAt}\``,
314
+ '',
315
+ '## Affected files',
316
+ '',
317
+ '| file | symbolCount | crossRefEdges |',
318
+ '| --- | --- | --- |',
319
+ ...rows.map((row) => {
320
+ const sym = row.symbolCount === undefined ? '' : String(row.symbolCount);
321
+ const edges = row.crossRefEdges === undefined ? '' : String(row.crossRefEdges);
322
+ return `| \`${row.file}\` | ${sym} | ${edges} |`;
323
+ }),
324
+ '',
325
+ '## Raw upstream output',
326
+ '',
327
+ '```json',
328
+ typeof affectedPayload === 'string'
329
+ ? affectedPayload
330
+ : JSON.stringify(affectedPayload, null, 2),
331
+ '```',
332
+ ''
333
+ ];
334
+ return lines.join('\n');
335
+ }
336
+ /**
337
+ * Write the `codegraph-context.md` envelope into the canonical
338
+ * session directory (`.peaks/_runtime/<sessionId>/rd/`). The
339
+ * envelope is what peaks-code's RD dispatch hook reads back to
340
+ * seed the QA / TXT handoff (see `peaks-code/SKILL.md`
341
+ * §"Codegraph orchestration context").
342
+ *
343
+ * The function is intentionally side-effect-safe:
344
+ * - No session binding → returns `{ written: false, warning }`,
345
+ * does NOT throw. Callers can either surface the warning or
346
+ * treat it as a no-op.
347
+ * - Directory creation is `mkdirSync({ recursive: true })`, so
348
+ * the first run in a fresh session creates the `rd/` subdir
349
+ * on demand.
350
+ * - The path is canonicalized through `getSessionDir` so
351
+ * downstream readers (QA / TXT) get a stable path even when
352
+ * the caller passed a relative `projectRoot`.
353
+ */
354
+ export function writeCodegraphAffectedContext(input) {
355
+ const sessionId = input.sessionId ?? getSessionId(input.projectRoot);
356
+ if (!sessionId || sessionId.length === 0) {
357
+ return {
358
+ written: false,
359
+ path: '',
360
+ warning: 'No active peaks-loop session binding; skipping codegraph-context envelope. ' +
361
+ 'Run `peaks workspace init` (or `peaks session set <id>`) and re-invoke ' +
362
+ '`peaks codegraph affected`.'
363
+ };
364
+ }
365
+ const sessionDir = getSessionDir(input.projectRoot, sessionId);
366
+ const rdDir = join(sessionDir, 'rd');
367
+ if (!existsSync(rdDir)) {
368
+ mkdirSync(rdDir, { recursive: true });
369
+ }
370
+ const contextPath = join(rdDir, 'codegraph-context.md');
371
+ const body = renderCodegraphAffectedContext(input.rid, input.files, input.affectedPayload, sessionId, input.now);
372
+ writeFileSync(contextPath, body, 'utf8');
373
+ return { written: true, path: contextPath, sessionId };
374
+ }
@@ -6,6 +6,13 @@
6
6
  * Fails when the version drifts, when the binary is missing, or
7
7
  * when the package is not resolvable at all.
8
8
  *
9
+ * Slice rid-CG-003 (spike follow-up #3): also detects which managed
10
+ * codegraph directory is in use — `.peaks/.codegraph/` (preferred)
11
+ * or root `.codegraph/` (legacy) — via the injected `managedPathProbe`.
12
+ * The CG-007 yarn-pnp fallback is preserved as the package-resolution
13
+ * default; the managed-path probe defaults to the same fs-walk-style
14
+ * helper and is independently injectable for tests.
15
+ *
9
16
  * The probe is injected so tests do not depend on the real
10
17
  * `node_modules` resolution; the default probe uses
11
18
  * `createRequire(import.meta.url)` to find the package.