peaks-loop 4.0.28 → 4.0.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.0.29 — 2026-09-03 (codegraph 生命周期闭环 — pre-read + auto-refresh)
4
+
5
+ **3 atomic commits from session 2026-09-03-session-d49394** (user feedback #3):
6
+
7
+ - `7b9fe627` feat(codegraph): auto-refresh index after slice completion (CLI-internal trigger)
8
+ - `b6599b56` feat(codegraph): preflight project structure into RD dispatch prompts
9
+ - `dfbd229e` docs(memory): sediment codegraph lifecycle closure (pre-read + auto-refresh)
10
+
11
+ **Highlights**:
12
+
13
+ 1. **slice/request 完成后自动刷新 codegraph** — `job checkpoint --state done` / `request transition --state qa-handoff` 成功后自动 `peaks codegraph index`。CLI-internal 触发(vendor-neutral、不可绕过、恰在 slice 边界 fire 一次),best-effort fail-silent(codegraph 失败不阻塞 checkpoint)。16 个新 BDD 测试。
14
+
15
+ 2. **RD 规划前强制读 codegraph 结构** — peaks-code dispatch RD 前 preflight:确保 index(无则 init+index,fresh 则跳过,foreign-schema fail-soft)+ 读有界 `codegraph files --json` 结构(cap 40 dirs / 12 root files)渲染 `## Codegraph structure` 块注入 RD prompt。codegraph 不可用 → 优雅降级 note,dispatch 照常。13 个新 BDD 测试。
16
+
17
+ 3. 闭环成型:规划前 pre-read(Slice B)→ 实现 → 完成后 auto-refresh(Slice A)→ 下一 slice 读到新图。
18
+
3
19
  ## 4.0.28 — 2026-09-03 (codegraph 目录回根 + env-first 1M 窗口识别)
4
20
 
5
21
  **3 atomic commits from session 2026-09-03-session-d49394** (user feedback batch):
@@ -371,6 +371,26 @@ export function registerDispatchCommand(parent, io) {
371
371
  // pure-function builder.
372
372
  const preflightService = new MemoryPreflightService(projectRoot, projectPrefs);
373
373
  const memoryBlock = await preflightService.fetchBlock(role);
374
+ // Slice 2026-09-03-codegraph-preread (Option A): pre-dispatch
375
+ // codegraph preflight for RD planning. BEFORE the RD sub-agent's
376
+ // prompt is composed, ensure the codegraph index exists (init +
377
+ // index best-effort when `.codegraph/` is absent; skip when already
378
+ // fresh) and read a BOUNDED project-structure summary. The block is
379
+ // only requested for the `rd` role — other roles keep the legacy
380
+ // prompt byte-identical. Fail-soft: any codegraph failure degrades
381
+ // to a null block (builder renders the "codegraph unavailable" note)
382
+ // and the dispatch proceeds; we never hard-block RD dispatch here.
383
+ let codegraphBlock;
384
+ if (role === 'rd') {
385
+ try {
386
+ const { buildCodegraphPreflightBlock } = await import('../../services/codegraph/codegraph-preflight-service.js');
387
+ const preflight = await buildCodegraphPreflightBlock(projectRoot);
388
+ codegraphBlock = preflight.available ? preflight.block : null;
389
+ }
390
+ catch {
391
+ codegraphBlock = null; // fail-soft: never block RD dispatch
392
+ }
393
+ }
374
394
  // Slice 2026-07-29-context-evaluation-accuracy: capture the
375
395
  // authoritative context-fill probe before composing the
376
396
  // dispatch prompt. The probe is token-counted (IDE adapter's
@@ -400,7 +420,11 @@ export function registerDispatchCommand(parent, io) {
400
420
  taskTitle: role,
401
421
  taskBody: options.prompt,
402
422
  memoryBlock,
403
- contextProbe
423
+ contextProbe,
424
+ // exactOptionalPropertyTypes: only set codegraphBlock when the rd
425
+ // preflight actually produced a value (null = attempted-unavailable,
426
+ // undefined = not requested → legacy prompt unchanged).
427
+ ...(codegraphBlock !== undefined ? { codegraphBlock } : {})
404
428
  });
405
429
  // Part 2.C: when --isolation worktree, prepend an isolation envelope
406
430
  // block so the sub-agent sees the lease id + worktree path. The block
@@ -13,6 +13,7 @@ import { JobInitInputSchema, JobCheckpointInputSchema, JobBlockInputSchema, } fr
13
13
  import { getCurrentSessionId } from '../../services/skills/skill-presence-service.js';
14
14
  import { buildCostCheckEnvelope, runKarpathyCostCheck, } from '../../services/karpathy-cost/karpathy-cost-check-service.js';
15
15
  import { read24hState } from '../../services/24h-mode/store.js';
16
+ import { refreshCodegraphAfterSlice, } from '../../services/codegraph/codegraph-autorefresh.js';
16
17
  function projectRoot(opts) {
17
18
  // Reuse the workspace root resolver from peaks CLI; for now, CWD as a safe placeholder.
18
19
  return opts.project ?? process.cwd();
@@ -172,6 +173,10 @@ export function registerJobCommands(program, io = { stdout: (t) => process.stdou
172
173
  const jobRoot = resolveJobStateRoot(opts);
173
174
  const store = new JobStateStore(jobRoot.rootDir);
174
175
  const orch = new JobOrchestrator(store);
176
+ // 2026-09-03-codegraph-autorefresh: set on --state done so the ok
177
+ // envelope carries a non-blocking `codegraph` result; null for
178
+ // failed/skipped (no slice-complete boundary).
179
+ let codegraph = null;
175
180
  if (parsed.data.state === 'done') {
176
181
  await orch.checkpointDone({ jobId: parsed.data.jobId, sliceId: parsed.data.sliceId, ...(parsed.data.commitSha ? { commitSha: parsed.data.commitSha } : {}) });
177
182
  // v3.1.2: after each --state done, mirror slice progress to
@@ -188,6 +193,14 @@ export function registerJobCommands(program, io = { stdout: (t) => process.stdou
188
193
  lastCommitSha: parsed.data.commitSha ?? null,
189
194
  updatedAt: new Date().toISOString()
190
195
  });
196
+ // Auto codegraph refresh at the slice-complete boundary. Best-effort
197
+ // and fail-silent: a refresh failure must never fail the checkpoint.
198
+ try {
199
+ codegraph = await refreshCodegraphAfterSlice(project);
200
+ }
201
+ catch (e) {
202
+ codegraph = { refreshed: false, reason: 'unavailable', note: `auto codegraph refresh failed: ${e instanceof Error ? e.message : String(e)}` };
203
+ }
191
204
  }
192
205
  else if (parsed.data.state === 'skipped') {
193
206
  await orch.checkpointSkipped({ jobId: parsed.data.jobId, sliceId: parsed.data.sliceId, reason: parsed.data.reason });
@@ -195,7 +208,7 @@ export function registerJobCommands(program, io = { stdout: (t) => process.stdou
195
208
  else {
196
209
  await orch.checkpointFailed({ jobId: parsed.data.jobId, sliceId: parsed.data.sliceId, reason: parsed.data.reason });
197
210
  }
198
- printResult(io, ok('checkpoint', { sliceId: parsed.data.sliceId, status: parsed.data.state }), opts);
211
+ printResult(io, ok('checkpoint', { sliceId: parsed.data.sliceId, status: parsed.data.state, codegraph }), opts);
199
212
  });
200
213
  addJsonOption(job.commands.find(c => c.name() === 'checkpoint'));
201
214
  job
@@ -6,6 +6,7 @@ import { lintRequestArtifact } from '../../services/artifacts/artifact-lint-serv
6
6
  import { getRepairCycleStatus } from '../../services/artifacts/repair-cycle-service.js';
7
7
  import { fail, ok } from 'peaks-loop-shared/result';
8
8
  import { triggerBestPracticeScan } from '../../services/prd/best-practice-auto-trigger.js';
9
+ import { refreshCodegraphAfterSlice, } from '../../services/codegraph/codegraph-autorefresh.js';
9
10
  import { formatMdCompact } from '../../shared/format-md-compact.js';
10
11
  import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
11
12
  /**
@@ -353,6 +354,10 @@ export function registerRequestCommands(program, io) {
353
354
  // unchanged — the existing auto-compact orchestrator continues to
354
355
  // refuse dispatch at ratio ≥ 0.95.
355
356
  let preCompact = null;
357
+ // 2026-09-03-codegraph-autorefresh: auto codegraph re-index on the
358
+ // RD → QA slice-complete boundary. Set only for rd:qa-handoff; null
359
+ // otherwise. Best-effort and fail-silent — never blocks the transition.
360
+ let codegraphRefresh = null;
356
361
  if (role === 'rd' && newState === 'qa-handoff') {
357
362
  try {
358
363
  const { maybePreCompactCheckpoint } = await import('../../services/compact/request-transition-hook.js');
@@ -369,6 +374,13 @@ export function registerRequestCommands(program, io) {
369
374
  // The hook is best-effort; never block the transition.
370
375
  preCompact = null;
371
376
  }
377
+ try {
378
+ codegraphRefresh = await refreshCodegraphAfterSlice(options.project);
379
+ }
380
+ catch {
381
+ // The refresh is best-effort; never block the transition.
382
+ codegraphRefresh = { refreshed: false, reason: 'unavailable', note: 'auto codegraph refresh failed after transition' };
383
+ }
372
384
  }
373
385
  // v2.13.2 AC-4 — auto-regen prd/handoff.md on prd:handed-off success.
374
386
  // Only fires when the handoff is missing; existing handoffs are not overwritten.
@@ -410,7 +422,7 @@ export function registerRequestCommands(program, io) {
410
422
  // transition is not a slice boundary), preCompact is null and
411
423
  // we emit a `preCompactCheckpoint: null` field so LLM callers
412
424
  // can branch on it without re-deriving zone membership.
413
- printResult(io, ok('request.transition', { ...result, preCompactCheckpoint: preCompact?.data ?? null }, preCompact !== null
425
+ printResult(io, ok('request.transition', { ...result, preCompactCheckpoint: preCompact?.data ?? null, codegraphRefresh }, preCompact !== null
414
426
  ? [
415
427
  `Pre-compact checkpoint written at ratio=${typeof preCompact.data === 'object' && preCompact.data !== null && 'ratio' in preCompact.data
416
428
  ? String(preCompact.data.ratio)
@@ -0,0 +1,22 @@
1
+ import { type CodegraphProcessRunner } from './codegraph-service.js';
2
+ export type CodegraphAutorefreshResult = {
3
+ refreshed: true;
4
+ } | {
5
+ refreshed: false;
6
+ reason: 'no-codegraph-dir' | 'index-failed' | 'unavailable';
7
+ note: string;
8
+ };
9
+ /**
10
+ * True when `<projectRoot>/.codegraph/` exists and is a directory.
11
+ * Pure fs probe; never throws.
12
+ */
13
+ export declare function isCodegraphPresent(projectRoot: string): boolean;
14
+ /**
15
+ * Run a best-effort `codegraph index` refresh for `projectRoot` after a
16
+ * slice-complete boundary. NEVER throws — every failure path returns a
17
+ * non-refreshed result so the caller keeps its ok envelope.
18
+ *
19
+ * The optional `runner` is a test seam mirroring
20
+ * `CodegraphProcessRunner`; when omitted the real process runner is used.
21
+ */
22
+ export declare function refreshCodegraphAfterSlice(projectRoot: string, runner?: CodegraphProcessRunner): Promise<CodegraphAutorefreshResult>;
@@ -0,0 +1,91 @@
1
+ // src/services/codegraph/codegraph-autorefresh.ts
2
+ //
3
+ // Slice 2026-09-03-codegraph-autorefresh — Option 1: CLI-internal
4
+ // auto codegraph refresh at the slice-complete boundary.
5
+ //
6
+ // `peaks codegraph index` is incremental + idempotent, so re-running it
7
+ // after a slice that changed code is cheap and safe. Rather than rely on
8
+ // the orchestrator LLM to remember the prose rule in
9
+ // `skills/peaks-code/references/codegraph-orchestration.md` ("MUST
10
+ // proactively run `peaks codegraph index --project <path>` after each
11
+ // slice"), the checkpoint/transition command itself triggers the refresh
12
+ // right before it returns its ok envelope. This is the vendor-neutral
13
+ // CLI-internal form of "hook on slice-complete": it is un-bypassable
14
+ // (fires even when the LLM dispatches the command through any IDE / no
15
+ // hook install surface needed), fires exactly once at the true slice
16
+ // boundary, and needs no IDE hook plumbing.
17
+ //
18
+ // The refresh is best-effort and FAIL-SILENT — it never throws and never
19
+ // blocks the checkpoint/transition ok envelope:
20
+ // - No `<projectRoot>/.codegraph/` directory → skip (codegraph was
21
+ // never initialized for this project; `peaks codegraph init` is a
22
+ // one-time setup the orchestrator owns).
23
+ // - The upstream index exits non-zero → return `index-failed` with a
24
+ // human-readable note.
25
+ // - Any unexpected error → return `unavailable` with a note.
26
+ //
27
+ // We deliberately do NOT auto-init: `codegraph init` can prompt / take a
28
+ // long time on first run, which would make the background side-effect
29
+ // block the slice boundary. Projects that want auto-refresh first run
30
+ // `peaks codegraph init` once (per the orchestration doc).
31
+ import { statSync } from 'node:fs';
32
+ import { join } from 'node:path';
33
+ import { CODEGRAPH_DIR_NAME, createCodegraphInvocation, executeCodegraphInvocation, } from './codegraph-service.js';
34
+ /**
35
+ * True when `<projectRoot>/.codegraph/` exists and is a directory.
36
+ * Pure fs probe; never throws.
37
+ */
38
+ export function isCodegraphPresent(projectRoot) {
39
+ try {
40
+ return statSync(join(projectRoot, CODEGRAPH_DIR_NAME)).isDirectory();
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ function errorMessage(error) {
47
+ return error instanceof Error ? error.message : String(error);
48
+ }
49
+ function firstMeaningfulLine(text) {
50
+ const trimmed = text.trim();
51
+ if (trimmed.length === 0)
52
+ return 'no upstream output';
53
+ const first = trimmed.split(/\r?\n/)[0];
54
+ return first !== undefined ? first.slice(0, 200) : 'no upstream output';
55
+ }
56
+ /**
57
+ * Run a best-effort `codegraph index` refresh for `projectRoot` after a
58
+ * slice-complete boundary. NEVER throws — every failure path returns a
59
+ * non-refreshed result so the caller keeps its ok envelope.
60
+ *
61
+ * The optional `runner` is a test seam mirroring
62
+ * `CodegraphProcessRunner`; when omitted the real process runner is used.
63
+ */
64
+ export async function refreshCodegraphAfterSlice(projectRoot, runner) {
65
+ if (!isCodegraphPresent(projectRoot)) {
66
+ return {
67
+ refreshed: false,
68
+ reason: 'no-codegraph-dir',
69
+ note: `auto codegraph refresh skipped: no ${CODEGRAPH_DIR_NAME} directory at ${join(projectRoot, CODEGRAPH_DIR_NAME)}. Run \`peaks codegraph init\` once to enable post-slice auto-refresh.`,
70
+ };
71
+ }
72
+ try {
73
+ const invocation = createCodegraphInvocation({ subcommand: 'index', project: projectRoot, quiet: true });
74
+ const result = await executeCodegraphInvocation(invocation, runner);
75
+ if (result.exitCode !== 0) {
76
+ return {
77
+ refreshed: false,
78
+ reason: 'index-failed',
79
+ note: `auto codegraph refresh failed (exit ${String(result.exitCode)}): ${firstMeaningfulLine(result.stderr || result.stdout)}`,
80
+ };
81
+ }
82
+ return { refreshed: true };
83
+ }
84
+ catch (error) {
85
+ return {
86
+ refreshed: false,
87
+ reason: 'unavailable',
88
+ note: `auto codegraph refresh unavailable: ${errorMessage(error)}`,
89
+ };
90
+ }
91
+ }
@@ -0,0 +1,53 @@
1
+ import { type CodegraphProcessRunner } from './codegraph-service.js';
2
+ export type CodegraphPreflightResult = {
3
+ available: true;
4
+ block: string;
5
+ fileCount: number;
6
+ truncated: boolean;
7
+ } | {
8
+ available: false;
9
+ note: string;
10
+ };
11
+ /** Cap for the directory histogram in the rendered structure block. */
12
+ export declare const CODEGRAPH_STRUCTURE_MAX_DIRS = 40;
13
+ /** Cap for bare root files listed in the rendered structure block. */
14
+ export declare const CODEGRAPH_STRUCTURE_MAX_ROOT_FILES = 12;
15
+ export interface CodegraphStructureFileEntry {
16
+ readonly path: string;
17
+ }
18
+ export interface CodegraphStructureRenderOptions {
19
+ readonly maxDirs?: number;
20
+ readonly maxRootFiles?: number;
21
+ }
22
+ export interface CodegraphStructureSummary {
23
+ /** Full `## Codegraph structure` markdown block, ending on its own paragraph. */
24
+ block: string;
25
+ total: number;
26
+ truncated: boolean;
27
+ }
28
+ /**
29
+ * Pure renderer: turn the codegraph `files --json` payload into a bounded
30
+ * `## Codegraph structure` block. Files are aggregated into a directory
31
+ * histogram (immediate parent dir; bare root files bucket separately),
32
+ * sorted by file count descending then name ascending, and capped at
33
+ * `maxDirs` rows / `maxRootFiles` root entries. Exported separately so the
34
+ * bounded-output contract is unit-testable without a filesystem.
35
+ */
36
+ export declare function renderCodegraphStructureBlock(files: readonly CodegraphStructureFileEntry[], options?: CodegraphStructureRenderOptions): CodegraphStructureSummary;
37
+ /**
38
+ * Pre-dispatch codegraph preflight. Returns a `## Codegraph structure`
39
+ * block when the index is (or becomes) readable; otherwise an
40
+ * `{ available: false, note }` result. NEVER throws — the caller must be
41
+ * able to degrade gracefully on every failure path.
42
+ *
43
+ * Behavior matrix (acceptance criteria):
44
+ * - `.codegraph/` absent → init + index (best-effort), then read.
45
+ * - `.codegraph/` present with peaks-loop marker → skip init/index
46
+ * (fresh), read directly. No redundant re-index on every dispatch.
47
+ * - `.codegraph/` present WITHOUT marker (foreign schema) → fail-soft;
48
+ * never clobber a foreign store.
49
+ *
50
+ * The optional `runner` mirrors `CodegraphProcessRunner` and is the ONLY
51
+ * injected boundary (tests fake the upstream binary).
52
+ */
53
+ export declare function buildCodegraphPreflightBlock(projectRoot: string, runner?: CodegraphProcessRunner): Promise<CodegraphPreflightResult>;
@@ -0,0 +1,226 @@
1
+ // src/services/codegraph/codegraph-preflight-service.ts
2
+ //
3
+ // Slice 2026-09-03-codegraph-preread (Option A) — pre-dispatch codegraph
4
+ // preflight for RD planning. peaks-code's RD dispatch path calls
5
+ // `buildCodegraphPreflightBlock` BEFORE composing the RD sub-agent prompt
6
+ // (src/cli/commands/dispatch-commands.ts) so the RD plans against the real
7
+ // module/file topology in the codegraph index, not LLM memory.
8
+ //
9
+ // The service does three things, all best-effort and fail-soft (it NEVER
10
+ // throws — every failure returns `{ available: false, note }` so the caller
11
+ // degrades to a "codegraph unavailable — proceeding on project-scan only"
12
+ // note and dispatch proceeds):
13
+ //
14
+ // 1. Ensure the schema exists: when `<projectRoot>/.codegraph/` is
15
+ // absent, run `codegraph init` + `codegraph index` (best-effort).
16
+ // 2. Skip-when-fresh: when `.codegraph/` already carries the
17
+ // peaks-loop marker, do NOT re-init / re-index on every dispatch
18
+ // (index is incremental; the sibling rid-2026-09-03-codegraph-autorefresh
19
+ // owns post-slice refresh). A foreign-schema `.codegraph/` is never
20
+ // clobbered — we fail-soft instead.
21
+ // 3. Read a BOUNDED project-structure summary from the index
22
+ // (`codegraph files --json`) and render it as a `## Codegraph
23
+ // structure` markdown block.
24
+ //
25
+ // Bounded-output contract: the rendered block never exceeds a safe prompt
26
+ // budget. The pure renderer `renderCodegraphStructureBlock` caps the
27
+ // directory histogram at CODEGRAPH_STRUCTURE_MAX_DIRS rows and the root-file
28
+ // listing at CODEGRAPH_STRUCTURE_MAX_ROOT_FILES entries; anything beyond
29
+ // those caps is summarized with a "… and N more" line and a `truncated: true`
30
+ // flag. Documented caps:
31
+ // - CODEGRAPH_STRUCTURE_MAX_DIRS = 40 directory rows
32
+ // - CODEGRAPH_STRUCTURE_MAX_ROOT_FILES = 12 bare root files
33
+ import { mkdirSync } from 'node:fs';
34
+ import { CODEGRAPH_DIR_NAME, createCodegraphInvocation, defaultCodegraphInitGuard, executeCodegraphInvocation, writeCodegraphMarker, } from './codegraph-service.js';
35
+ import { defaultCodegraphProcessRunner } from './codegraph-process-runner.js';
36
+ /** Cap for the directory histogram in the rendered structure block. */
37
+ export const CODEGRAPH_STRUCTURE_MAX_DIRS = 40;
38
+ /** Cap for bare root files listed in the rendered structure block. */
39
+ export const CODEGRAPH_STRUCTURE_MAX_ROOT_FILES = 12;
40
+ /** Strip a leading `./` (upstream codegraph paths may carry it). */
41
+ function normalizeCodegraphPath(path) {
42
+ return path.startsWith('./') ? path.slice(2) : path;
43
+ }
44
+ /**
45
+ * Pure renderer: turn the codegraph `files --json` payload into a bounded
46
+ * `## Codegraph structure` block. Files are aggregated into a directory
47
+ * histogram (immediate parent dir; bare root files bucket separately),
48
+ * sorted by file count descending then name ascending, and capped at
49
+ * `maxDirs` rows / `maxRootFiles` root entries. Exported separately so the
50
+ * bounded-output contract is unit-testable without a filesystem.
51
+ */
52
+ export function renderCodegraphStructureBlock(files, options = {}) {
53
+ const maxDirs = options.maxDirs ?? CODEGRAPH_STRUCTURE_MAX_DIRS;
54
+ const maxRootFiles = options.maxRootFiles ?? CODEGRAPH_STRUCTURE_MAX_ROOT_FILES;
55
+ const dirCounts = new Map();
56
+ const rootFiles = [];
57
+ let total = 0;
58
+ for (const file of files) {
59
+ const raw = normalizeCodegraphPath(file.path).replace(/\\/g, '/');
60
+ if (raw.length === 0)
61
+ continue;
62
+ total += 1;
63
+ const slash = raw.lastIndexOf('/');
64
+ if (slash === -1) {
65
+ rootFiles.push(raw);
66
+ continue;
67
+ }
68
+ const dir = raw.slice(0, slash);
69
+ dirCounts.set(dir, (dirCounts.get(dir) ?? 0) + 1);
70
+ }
71
+ const sortedDirs = [...dirCounts.entries()]
72
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
73
+ const shownDirs = sortedDirs.slice(0, maxDirs);
74
+ const truncatedDirs = sortedDirs.length > maxDirs;
75
+ const sortedRoot = [...rootFiles].sort((a, b) => a.localeCompare(b));
76
+ const shownRoot = sortedRoot.slice(0, maxRootFiles);
77
+ const truncatedRoot = sortedRoot.length > maxRootFiles;
78
+ const lines = ['## Codegraph structure', ''];
79
+ lines.push(total === 0
80
+ ? 'No files are indexed yet. Run `peaks codegraph index` before dispatching planning work for symbol-accurate structure.'
81
+ : `${total} file${total === 1 ? '' : 's'} indexed in the codegraph index:`);
82
+ for (const [dir, count] of shownDirs) {
83
+ lines.push(`- \`${dir}/\` — ${count} file${count === 1 ? '' : 's'}`);
84
+ }
85
+ if (truncatedDirs) {
86
+ lines.push(`- … and ${sortedDirs.length - maxDirs} more director${sortedDirs.length - maxDirs === 1 ? 'y' : 'ies'}`);
87
+ }
88
+ if (sortedRoot.length > 0) {
89
+ lines.push(`- (root) — ${shownRoot.map((f) => `\`${f}\``).join(', ')}${truncatedRoot ? ' …' : ''}`);
90
+ }
91
+ const block = lines.join('\n').replace(/\s+$/, '') + '\n\n';
92
+ return { block, total, truncated: truncatedDirs || truncatedRoot };
93
+ }
94
+ function errorMessage(error) {
95
+ return error instanceof Error ? error.message : String(error);
96
+ }
97
+ function firstMeaningfulLine(text) {
98
+ const trimmed = text.trim();
99
+ if (trimmed.length === 0)
100
+ return 'no upstream output';
101
+ const first = trimmed.split(/\r?\n/)[0];
102
+ return first !== undefined ? first.slice(0, 200) : 'no upstream output';
103
+ }
104
+ function parseFilesPayload(stdout) {
105
+ try {
106
+ const parsed = JSON.parse(stdout);
107
+ if (Array.isArray(parsed)) {
108
+ const entries = parsed
109
+ .filter((entry) => typeof entry === 'object' && entry !== null && 'path' in entry)
110
+ .map((entry) => ({ path: typeof entry.path === 'string' ? entry.path : '' }))
111
+ .filter((entry) => entry.path.length > 0);
112
+ return { ok: true, entries };
113
+ }
114
+ // JSON but not an array — not the shape we expect.
115
+ return { ok: false, entries: [] };
116
+ }
117
+ catch {
118
+ // Not JSON at all — e.g. the upstream text path ("No files indexed…").
119
+ return { ok: false, entries: [] };
120
+ }
121
+ }
122
+ async function readStructure(projectRoot, runner) {
123
+ let result;
124
+ try {
125
+ const invocation = createCodegraphInvocation({ subcommand: 'files', project: projectRoot, json: true });
126
+ result = await executeCodegraphInvocation(invocation, runner);
127
+ }
128
+ catch (error) {
129
+ return { available: false, note: `codegraph files unavailable: ${errorMessage(error)}` };
130
+ }
131
+ if (result.exitCode !== 0) {
132
+ return {
133
+ available: false,
134
+ note: `codegraph files failed (exit ${String(result.exitCode)}): ${firstMeaningfulLine(result.stderr || result.stdout)}`,
135
+ };
136
+ }
137
+ const { ok, entries } = parseFilesPayload(result.stdout);
138
+ if (!ok) {
139
+ return {
140
+ available: false,
141
+ note: 'codegraph files returned no parseable structure — run `peaks codegraph index` before dispatch for symbol-accurate structure.',
142
+ };
143
+ }
144
+ if (entries.length === 0) {
145
+ return {
146
+ available: false,
147
+ note: 'codegraph index has no files — run `peaks codegraph index` before dispatch for symbol-accurate structure.',
148
+ };
149
+ }
150
+ const summary = renderCodegraphStructureBlock(entries);
151
+ return {
152
+ available: true,
153
+ block: summary.block,
154
+ fileCount: summary.total,
155
+ truncated: summary.truncated,
156
+ };
157
+ }
158
+ /**
159
+ * Pre-dispatch codegraph preflight. Returns a `## Codegraph structure`
160
+ * block when the index is (or becomes) readable; otherwise an
161
+ * `{ available: false, note }` result. NEVER throws — the caller must be
162
+ * able to degrade gracefully on every failure path.
163
+ *
164
+ * Behavior matrix (acceptance criteria):
165
+ * - `.codegraph/` absent → init + index (best-effort), then read.
166
+ * - `.codegraph/` present with peaks-loop marker → skip init/index
167
+ * (fresh), read directly. No redundant re-index on every dispatch.
168
+ * - `.codegraph/` present WITHOUT marker (foreign schema) → fail-soft;
169
+ * never clobber a foreign store.
170
+ *
171
+ * The optional `runner` mirrors `CodegraphProcessRunner` and is the ONLY
172
+ * injected boundary (tests fake the upstream binary).
173
+ */
174
+ export async function buildCodegraphPreflightBlock(projectRoot, runner) {
175
+ const processRunner = runner ?? defaultCodegraphProcessRunner;
176
+ const guard = defaultCodegraphInitGuard(projectRoot);
177
+ if (guard.status === 'conflict-foreign-schema') {
178
+ return {
179
+ available: false,
180
+ note: `codegraph unavailable: ${CODEGRAPH_DIR_NAME}/ exists with a non-peaks-loop schema and was not touched. Move or rename the foreign directory, then re-run \`peaks codegraph init\` to enable pre-dispatch structure reads.`,
181
+ };
182
+ }
183
+ if (guard.status === 'fresh') {
184
+ // 1. init (best-effort). Upstream creates the `.codegraph/` dir; we
185
+ // stamp the peaks-loop marker afterwards so the NEXT dispatch hits
186
+ // the noop (skip-when-fresh) branch.
187
+ try {
188
+ const initResult = await executeCodegraphInvocation(createCodegraphInvocation({ subcommand: 'init', project: projectRoot }), processRunner);
189
+ if (initResult.exitCode !== 0) {
190
+ return {
191
+ available: false,
192
+ note: `codegraph init failed (exit ${String(initResult.exitCode)}): ${firstMeaningfulLine(initResult.stderr || initResult.stdout)}`,
193
+ };
194
+ }
195
+ try {
196
+ // Upstream init creates `.codegraph/`; mkdir is a no-op in the real
197
+ // path and lets the marker write succeed even when the runner is
198
+ // faked (tests). Best-effort: failure must not undo the init.
199
+ mkdirSync(guard.codegraphDir, { recursive: true });
200
+ writeCodegraphMarker(guard.codegraphDir);
201
+ }
202
+ catch {
203
+ // Best-effort: a marker-write failure must not undo the init.
204
+ }
205
+ }
206
+ catch (error) {
207
+ return { available: false, note: `codegraph init unavailable: ${errorMessage(error)}` };
208
+ }
209
+ // 2. index (best-effort).
210
+ try {
211
+ const indexResult = await executeCodegraphInvocation(createCodegraphInvocation({ subcommand: 'index', project: projectRoot, quiet: true }), processRunner);
212
+ if (indexResult.exitCode !== 0) {
213
+ return {
214
+ available: false,
215
+ note: `codegraph index failed (exit ${String(indexResult.exitCode)}): ${firstMeaningfulLine(indexResult.stderr || indexResult.stdout)}`,
216
+ };
217
+ }
218
+ }
219
+ catch (error) {
220
+ return { available: false, note: `codegraph index unavailable: ${errorMessage(error)}` };
221
+ }
222
+ }
223
+ // guard.status === 'noop-already-peaks-loop' (or we just initialized):
224
+ // the schema exists — do NOT re-index, go straight to the bounded read.
225
+ return readStructure(projectRoot, processRunner);
226
+ }
@@ -28,6 +28,20 @@ export interface DispatchPromptInput {
28
28
  * a byte-counted estimate.
29
29
  */
30
30
  contextProbe?: ContextPercentProbe | null;
31
+ /**
32
+ * Slice 2026-09-03-codegraph-preread (Option A): pre-composed
33
+ * `## Codegraph structure` markdown block, read from the codegraph
34
+ * index BEFORE the RD sub-agent's task body is composed so the RD
35
+ * plans against real module/file topology, not LLM memory.
36
+ *
37
+ * - `undefined` → no codegraph block (legacy callers unchanged).
38
+ * - `null` → codegraph was attempted but is unavailable; the composer
39
+ * renders a fixed "codegraph unavailable — proceeding on project-scan
40
+ * only" note (fail-soft; never hard-blocks a dispatch).
41
+ * - `string` → the block, rendered verbatim between the context window
42
+ * and the memory/task content.
43
+ */
44
+ codegraphBlock?: string | null;
31
45
  }
32
46
  /**
33
47
  * Slice 2026-07-29-worktree-l1: Layer 1 of the 3-layer worktree governance
@@ -88,6 +102,10 @@ export declare const LIFECYCLE_RULES = "## Sub-agent lifecycle rules (locked 202
88
102
  * `${formatTestToolDetection()}\n\n${taskBody}`. Today's pre-change behavior
89
103
  * produced the same string from `src/cli/commands/dispatch-commands.ts:220`,
90
104
  * so the unavailable branch MUST return `taskBody` (NOT a `# title\n\n` wrap).
105
+ * The contract holds for callers that do not pass `codegraphBlock` (all
106
+ * non-RD roles). Slice 2026-09-03-codegraph-preread deliberately inserts a
107
+ * codegraph structure block (or its fail-soft unavailable note) for RD
108
+ * dispatches between the context window and the memory/task content.
91
109
  *
92
110
  * Available branch prepends the memory block before the `## Task` heading so
93
111
  * `## Project memory …` always sits above the task brief (never pushed below
@@ -99,3 +117,11 @@ export declare const LIFECYCLE_RULES = "## Sub-agent lifecycle rules (locked 202
99
117
  * refusal is in scope before any task-specific prose arrives.
100
118
  */
101
119
  export declare function buildDispatchSystemPrompt(input: DispatchPromptInput): string;
120
+ /**
121
+ * Slice 2026-09-03-codegraph-preread: fixed degradation string emitted
122
+ * when the RD dispatch preflight requested a codegraph structure read but
123
+ * the index could not be resolved (absent + init failure, foreign schema,
124
+ * unparseable output). Kept as a constant so the unavailable branch is
125
+ * byte-stable and trivially testable.
126
+ */
127
+ export declare const CODEGRAPH_UNAVAILABLE_BLOCK = "## Codegraph structure\n\ncodegraph unavailable \u2014 proceeding on project-scan only.\n";
@@ -83,6 +83,10 @@ export const LIFECYCLE_RULES = `## Sub-agent lifecycle rules (locked 2026-08-01)
83
83
  * `${formatTestToolDetection()}\n\n${taskBody}`. Today's pre-change behavior
84
84
  * produced the same string from `src/cli/commands/dispatch-commands.ts:220`,
85
85
  * so the unavailable branch MUST return `taskBody` (NOT a `# title\n\n` wrap).
86
+ * The contract holds for callers that do not pass `codegraphBlock` (all
87
+ * non-RD roles). Slice 2026-09-03-codegraph-preread deliberately inserts a
88
+ * codegraph structure block (or its fail-soft unavailable note) for RD
89
+ * dispatches between the context window and the memory/task content.
86
90
  *
87
91
  * Available branch prepends the memory block before the `## Task` heading so
88
92
  * `## Project memory …` always sits above the task brief (never pushed below
@@ -94,12 +98,40 @@ export const LIFECYCLE_RULES = `## Sub-agent lifecycle rules (locked 2026-08-01)
94
98
  * refusal is in scope before any task-specific prose arrives.
95
99
  */
96
100
  export function buildDispatchSystemPrompt(input) {
97
- const { taskBody, memoryBlock, contextProbe } = input;
101
+ const { taskBody, memoryBlock, contextProbe, codegraphBlock } = input;
98
102
  const contextBlock = renderContextBlock(contextProbe ?? null);
103
+ const codegraphText = renderCodegraphBlock(codegraphBlock);
99
104
  if (memoryBlock.available === true && typeof memoryBlock.block === 'string') {
100
- return `${L1_WORKTREE_GOVERNANCE_BLOCK}\n${LIFECYCLE_RULES}\n${contextBlock}${memoryBlock.block}\n## Task\n${taskBody}`;
105
+ return `${L1_WORKTREE_GOVERNANCE_BLOCK}\n${LIFECYCLE_RULES}\n${contextBlock}${codegraphText}${memoryBlock.block}\n## Task\n${taskBody}`;
101
106
  }
102
- return `${L1_WORKTREE_GOVERNANCE_BLOCK}\n${LIFECYCLE_RULES}\n${contextBlock}${taskBody}`;
107
+ return `${L1_WORKTREE_GOVERNANCE_BLOCK}\n${LIFECYCLE_RULES}\n${contextBlock}${codegraphText}${taskBody}`;
108
+ }
109
+ /**
110
+ * Slice 2026-09-03-codegraph-preread: fixed degradation string emitted
111
+ * when the RD dispatch preflight requested a codegraph structure read but
112
+ * the index could not be resolved (absent + init failure, foreign schema,
113
+ * unparseable output). Kept as a constant so the unavailable branch is
114
+ * byte-stable and trivially testable.
115
+ */
116
+ export const CODEGRAPH_UNAVAILABLE_BLOCK = '## Codegraph structure\n\ncodegraph unavailable — proceeding on project-scan only.\n';
117
+ /**
118
+ * Render the codegraph insertion for a dispatch prompt.
119
+ *
120
+ * - `undefined` → empty (the composer is byte-identical to the legacy
121
+ * shape for callers that did not opt into a codegraph pre-read).
122
+ * - `null` → the fixed CODEGRAPH_UNAVAILABLE_BLOCK note.
123
+ * - `string` → the pre-composed block from the codegraph preflight
124
+ * service, verbatim.
125
+ *
126
+ * Every non-empty variant is normalized to end on its own paragraph
127
+ * (`\n\n`) so the following block (project memory or task body) starts
128
+ * cleanly regardless of the caller's trailing newline habits.
129
+ */
130
+ function renderCodegraphBlock(codegraphBlock) {
131
+ if (codegraphBlock === undefined)
132
+ return '';
133
+ const text = codegraphBlock === null ? CODEGRAPH_UNAVAILABLE_BLOCK : codegraphBlock;
134
+ return `${text.replace(/\s+$/, '')}\n\n`;
103
135
  }
104
136
  /**
105
137
  * Slice 2026-07-29-context-evaluation-accuracy: emit a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-loop",
3
- "version": "4.0.28",
3
+ "version": "4.0.29",
4
4
  "description": "Loop Engineering CLI — workflow primitive / loop guards / evaluators / slice orchestration",
5
5
  "author": "SquabbyZ",
6
6
  "keywords": [
@@ -101,10 +101,10 @@
101
101
  "fzf": "^0.5.2",
102
102
  "yaml": "^2.9.0",
103
103
  "zod": "^4.4.3",
104
- "peaks-loop-internal-runtime": "0.0.13",
105
- "peaks-loop-mut": "0.1.26",
106
- "peaks-loop-shared": "0.0.62",
107
- "peaks-loop-shared-channel": "0.0.30"
104
+ "peaks-loop-internal-runtime": "0.0.14",
105
+ "peaks-loop-mut": "0.1.27",
106
+ "peaks-loop-shared-channel": "0.0.31",
107
+ "peaks-loop-shared": "0.0.63"
108
108
  },
109
109
  "devDependencies": {
110
110
  "@changesets/cli": "2.31.1",