peaks-loop 4.0.19 → 4.0.21

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,6 +1,66 @@
1
1
  # Changelog
2
2
 
3
- ## 4.0.19 — 2026-08-11 (detached sub-agent + G8 infinite-context — single-ship)
3
+ ## 4.0.21 — 2026-08-11 (Batch A: vendor-detect recovery + codegraph integration + anti-fake-green gate)
4
+
5
+ **7 atomic commits from session 2026-08-11 (rid-001 redo → 8-子任务 codegraph → Batch A F1-F7)** + 4.0.21 lockstep bump + changelog entry:
6
+
7
+ - rid-001 redo: `fix(runtime): recover vendor-detect CLI seam + handle detached spawn ENOENT` (`e8fb5ed9`, 9 files / +436/-21)
8
+ - codegraph Phase 1: `feat(codegraph): add init conflict guard + doctor fallback + workspace auto-stake` (`e7ec3cb0`, 7 files / +849/-23)
9
+ - codegraph Phase 2: `feat(codegraph): ship affected context envelope + tarball guard + downstream notes` (`f4b56870`, 8 files / +929/-9)
10
+ - **F1**: `fix(runtime): expose DispatchResult.child + per-child ENOENT handler` (`b254840b`, 3 files / +96/-33)
11
+ - **F3**: `fix(runtime): recover vendor-detect Windows ENOENT via where.exe + PATHEXT` (`fcd38369`, 5 files / +58/-18)
12
+ - **F4**: `chore(test): fix pre-existing test rot — graph-node flag, vitest 4.x, child mock, CG-003 path` (`4ff1152a`, 5 files / +20/-6)
13
+ - **F5**: `feat(cli): sub-agent dispatch --must-ls-files <glob> anti-fake-green gate` (`58bb4164`, 3 files / +231/-2)
14
+ - **F6**: `fix(version): peaks-loop-shared CLI_VERSION 4.0.18 -> 4.0.20 lockstep` (`f79d3b34`, 2 files / +41/-1)
15
+ - **F7**: `feat(codegraph): CG-003 PARTIAL verdict 4 follow-up items — preferred path + doctor probe` (`0e3b1bed`, 6 files / +469/-23)
16
+ - `chore(release): bump to 4.0.21 — Batch A lockstep` (replaces the 4.0.22 retry commit; same diff)
17
+ - `docs(changelog): 4.0.21 entry`
18
+
19
+ **Total Batch A**: 24 files / +975/-117 lines / 11+ new test cases / 124/124 unit baseline preserved + 31 net new test cases.
20
+
21
+ **Lockstep contract (per publish.yml Layer 5 + on-disk gate)**:
22
+ - peaks-loop root `package.json#version`: 4.0.21
23
+ - peaks-loop-shared `src/version.ts` `CLI_VERSION`: 4.0.21
24
+ - peaks-loop-internal-runtime `src/index.ts` `RUNTIME_VERSION`: 4.0.21
25
+ - peaks-loop-shared `package.json#version`: 0.0.54 (next available after 0.0.53 on registry — bumps through patch gaps because publish.yml auto-bump ran during 4.0.22 retry)
26
+ - peaks-loop-internal-runtime `package.json#version`: 0.0.5 (next available after 0.0.4)
27
+
28
+ **Note on the 4.0.22 attempt**: an earlier session push of tag `v4.0.21` triggered publish.yml's `Auto-bump version per smallest-semver policy` step (publish.yml:189, still present despite the v2.18.0 plan stating it should be DELETED). The parity gate caught the lockstep drift on the original commit which had only CLI_VERSION bumped (not RUNTIME_VERSION). The retry pushed `v4.0.22` to recover, but the operator wanted `4.0.21` to be the registry version (user 2026-08-11 explicit direction). The 4.0.22 registry package will be manually unpublished by the operator after this 4.0.21 publish completes.
29
+
30
+ **Pending follow-ups (out of 4.0.21)**:
31
+ - F2 detached architecture heavy refactor (in-shell background subprocess; user 2026-08-11 feedback-driven) — separate 4.0.22+ batch
32
+ - F8 skill-resolution slice (session 7f7f78 original intent; rid-002/003/004 scope unknown — needs user scoping first)
33
+
34
+ ## 4.0.20 — 2026-08-11 (peaks-loop-internal-runtime published as public lockstep package)
35
+
36
+ **7 commits ship'd from session 2026-08-11 (rid-001 redo → 8-子任务 codegraph → Batch A F1-F7)**:
37
+
38
+ - rid-001 redo: `fix(runtime): recover vendor-detect CLI seam + handle detached spawn ENOENT` (`e8fb5ed9`, 9 files / +436/-21)
39
+ - codegraph Phase 1: `feat(codegraph): add init conflict guard + doctor fallback + workspace auto-stake` (`e7ec3cb0`, 7 files / +849/-23)
40
+ - codegraph Phase 2: `feat(codegraph): ship affected context envelope + tarball guard + downstream notes` (`f4b56870`, 8 files / +929/-9)
41
+ - **F1**: `fix(runtime): expose DispatchResult.child + per-child ENOENT handler` (`b254840b`, 3 files / +96/-33) — `process.on('uncaughtException')` swallow → per-child `r.child?.on('error')` canonical pattern
42
+ - **F3**: `fix(runtime): recover vendor-detect Windows ENOENT via where.exe + PATHEXT` (`fcd38369`, 5 files / +58/-18) — `where.exe` on win32 / `which` on POSIX, no `shell: true` (no command-injection)
43
+ - **F4**: `chore(test): fix pre-existing test rot — graph-node flag, vitest 4.x, child mock, CG-003 path` (`4ff1152a`, 5 files / +20/-6) — 3 integration suites (dispatch-isolation-lifecycle / sub-agent-dispatch-e2e / dispatcher-flow) all PASS
44
+ - **F5**: `feat(cli): sub-agent dispatch --must-ls-files <glob> anti-fake-green gate` (`58bb4164`, 3 files / +231/-2) — `--must-ls-files <glob>` enforces `git ls-files` on sub-agent prompt frontmatter
45
+ - **F6**: `fix(version): peaks-loop-shared CLI_VERSION 4.0.18 -> 4.0.20 lockstep` (`f79d3b34`, 2 files / +41/-1) — lockstep guard test prevents re-introduction
46
+ - **F7**: `feat(codegraph): CG-003 PARTIAL verdict 4 follow-up items — preferred path + doctor probe` (`0e3b1bed`, 6 files / +469/-23) — preferred `.peaks/.codegraph/` over legacy `.codegraph/`; sub-command consistency; doctor capability probe upgrade
47
+
48
+ **Total Batch A**: 24 files / +975/-117 lines / 11+ new test cases / 124/124 unit baseline preserved + 31 net new test cases.
49
+
50
+ **Pending follow-ups (out of 4.0.21)**:
51
+ - F2 detached architecture heavy refactor (in-shell background subprocess; user 2026-08-11 feedback-driven) — separate 4.0.22+ batch
52
+ - F8 skill-resolution slice (session 7f7f78 original intent; rid-002/003/004 scope unknown — needs user scoping first)
53
+
54
+ ## 4.0.20 — 2026-08-11 (peaks-loop-internal-runtime published as public lockstep package)
55
+
56
+ **Fix — peaks-loop@4.0.19 install 404 on `peaks-loop-internal-runtime`**:
57
+ - peaks-loop-internal-runtime@4.0.19 was originally marked `private: true` (workspace-internal consumer only); but peaks-loop@4.0.19 tarball depends on it, so users installing peaks-loop@4.0.19 from the registry got `npm error 404 'peaks-loop-internal-runtime@4.0.0' is not in this registry` (the spec drift was that plan §6.1 said "runtime is private" but lockstep publish requires runtime to be public for npm to resolve the `peaks-loop-internal-runtime: "4.0.0"` rewrite in peaks-loop tarball).
58
+ - Fix: remove `private: true` from `packages/peaks-loop-internal-runtime/package.json`, add `publishConfig.access: public`. Add `description` field. Bump version 4.0.19 → 4.0.20 (re-publish so runtime@4.0.20 lands on registry).
59
+ - `gate-cli-version` (publish.yml) extended to verify runtime `RUNTIME_VERSION` (the API contract string, `4.0.20`) against root version; this stays in lockstep with `RUNTIME_NPM_VERSION` (the package.json#version, also `4.0.20`).
60
+
61
+ **Backwards compat**: 100%. peaks-loop@4.0.20 is a drop-in for peaks-loop@4.0.19 once the registry has both peaks-loop@4.0.20 and peaks-loop-internal-runtime@4.0.20 published.
62
+
63
+ ## 4.0.19 — 2026-08-11 (detached sub-agent + G8 infinite-context — single-ship, SUPERSEDED)
4
64
 
5
65
  **New feature — detached sub-agent mode (Phase A-E single-ship)**:
6
66
  - New monorepo package `peaks-loop-internal-runtime` (npm name `peaks-loop-internal-runtime`; sibling of `peaks-loop-shared`; private; consumed via `workspace:*`).
package/README-en.md CHANGED
@@ -274,6 +274,38 @@ It changes, but **the gates hold**. Audit fails = nothing ships. QA fails = noth
274
274
 
275
275
  ---
276
276
 
277
+ ## Downstream consumer notes
278
+
279
+ If you maintain a project that depends on `peaks-loop` (or that runs `npm install peaks-loop` as part of CI / contributor onboarding), three behaviors are worth pinning in your head:
280
+
281
+ - **`codegraph` is a transitive dependency.** `peaks-loop` pins `@colbymchenry/codegraph@0.7.10` in its `dependencies`, so `npm install` / `pnpm install` will pull it automatically. The CLI command group `peaks codegraph …` (status / init / index / query / files / context / affected) is shipped inside `dist/services/codegraph/`. No extra install step required.
282
+ - **`.codegraph/` directory ownership is shared.** Tools like aider, cody, and similar agents also create a top-level `.codegraph/`. `peaks codegraph init` will refuse to overwrite a foreign schema and exits with code 73 + a `CODEGRAPH_INIT_CONFLICT` envelope. If your project already has a `.codegraph/` from another tool, move / rename it (or remove it if you no longer need it) before running `peaks codegraph init`.
283
+ - **Session binding lives under `.peaks/_runtime/<sessionId>/`.** Codegraph orchestration context (and other per-session evidence) is written there by RD / QA slices. Add `.peaks/_runtime/` to your project's `.gitignore` to avoid committing local session state. A missing / unbound session produces a graceful skip-with-warning, never a crash.
284
+ - **Doctor fallback for yarn-pnp / pnpm-strict.** `peaks doctor` falls back to a filesystem walk when `require.resolve('@colbymchenry/codegraph/package.json')` throws, so downstream installs under strict resolution modes still get a green check (or a `severity: 'warning'` if the version drifted from the pinned `0.7.10`).
285
+ - **Tarball size / verify.** Each release ships the codegraph service under `dist/services/codegraph/`. Per-release verification lives at `scripts/verify-codegraph-tarball.mjs` (run `node scripts/verify-codegraph-tarball.mjs` locally before tagging a release). Exit 0 means the whitelist is intact; exit 1 means the tarball is missing the codegraph service files.
286
+
287
+ Install snippet (typical consumer):
288
+
289
+ ```bash
290
+ # Add peaks-loop to your project
291
+ npm install peaks-loop
292
+
293
+ # Bootstrap codegraph for the first time (refuses if .codegraph/ already
294
+ # exists with a non-peaks-loop schema; exit code 73 + envelope)
295
+ npx peaks codegraph init --project .
296
+
297
+ # Verify the doctor is green for downstream resolution modes
298
+ npx peaks doctor --project .
299
+ ```
300
+
301
+ Three common pitfalls:
302
+
303
+ 1. **`peaks codegraph init` exits 73** — your project has a pre-existing `.codegraph/` from another tool. Move it out of the way (or remove it), then re-run.
304
+ 2. **`peaks doctor` warns about codegraph version drift** — your lockfile resolved a different `@colbymchenry/codegraph` version than the pinned `0.7.10`. Pin to `0.7.10` in your lockfile; the warning does not flip the doctor exit code.
305
+ 3. **Session-bound artifacts under `.peaks/_runtime/` are noisy in `git status`** — add the path to your project's `.gitignore`. The `peaks-loop` repo already does this; consumers should mirror the rule.
306
+
307
+ ---
308
+
277
309
  ## Links
278
310
 
279
311
  - All skills → [`skills/`](./skills/)
package/README.md CHANGED
@@ -282,6 +282,37 @@ winget install Python.Python.3.12
282
282
 
283
283
  ---
284
284
 
285
+ ## 下游消费者须知 (Downstream consumer notes)
286
+
287
+ 如果你的项目依赖 `peaks-loop`(或者把 `npm install peaks-loop` 写进 CI / 协作者 onboarding),有三件事需要先记清:
288
+
289
+ - **`codegraph` 是传递依赖。** `peaks-loop` 在 `dependencies` 里硬钉 `@colbymchenry/codegraph@0.7.10`,所以 `npm install` / `pnpm install` 会自动拉取。CLI 命令组 `peaks codegraph …`(status / init / index / query / files / context / affected)打包在 `dist/services/codegraph/`,无需额外安装步骤。
290
+ - **`.codegraph/` 目录归属是共享的。** aider / cody 等类似工具也会在项目根创建 `.codegraph/`。`peaks codegraph init` 拒绝覆盖外源 schema,以 exit code 73 + `CODEGRAPH_INIT_CONFLICT` 凭据退出。如果你的项目里已经有其他工具建的 `.codegraph/`,跑 `peaks codegraph init` 之前先移走 / 改名(或确认不再需要后删除)。
291
+ - **session 绑定落在 `.peaks/_runtime/<sessionId>/`。** codegraph 编排上下文(以及其他 session 级证据)由 RD / QA 切片写入。把 `.peaks/_runtime/` 加进项目的 `.gitignore`,避免误提交本地 session 状态。session 缺失 / 未绑定时优雅降级为「跳过 + 警告」,绝不 crash。
292
+ - **yarn-pnp / pnpm-strict 的 doctor 兜底。** `peaks doctor` 在 `require.resolve('@colbymchenry/codegraph/package.json')` 抛错时回退到文件系统遍历,因此严格解析模式下的下游安装也能拿到绿色 check(若版本偏离钉死的 `0.7.10` 则降级为 `severity: 'warning'`)。
293
+ - **tarball 大小 / 自检。** 每次发布都把 codegraph service 打进 `dist/services/codegraph/`。逐版本自检脚本见 `scripts/verify-codegraph-tarball.mjs`(发布前本地跑 `node scripts/verify-codegraph-tarball.mjs`)。exit 0 表示 whitelist 完好,exit 1 表示 tarball 漏了 codegraph service 文件。
294
+
295
+ 典型下游安装片段:
296
+
297
+ ```bash
298
+ # 把 peaks-loop 加进项目
299
+ npm install peaks-loop
300
+
301
+ # 首次引导 codegraph(若项目已有外源 .codegraph/ 会以 exit 73 + 凭据拒绝)
302
+ npx peaks codegraph init --project .
303
+
304
+ # 校验 doctor 在下游解析模式下仍是绿色
305
+ npx peaks doctor --project .
306
+ ```
307
+
308
+ 三个常见坑:
309
+
310
+ 1. **`peaks codegraph init` 退出码 73** —— 项目里已有外源 `.codegraph/`(aider / cody 等)。把它挪开(或删掉)再重跑。
311
+ 2. **`peaks doctor` 报 codegraph 版本漂移** —— 你的 lockfile 解到了一个跟钉死 `0.7.10` 不同的版本。在 lockfile 里钉到 `0.7.10`;这条 warning 不会让 doctor 退出码翻红。
312
+ 3. **`.peaks/_runtime/` 下的 session 产物在 `git status` 里嘈杂** —— 把这条路径加进项目的 `.gitignore`。`peaks-loop` 仓库已经这样做;下游消费者应当镜像这条规则。
313
+
314
+ ---
315
+
285
316
  ## 链接
286
317
 
287
318
  - 全部技能清单 → [`skills/`](./skills/)
@@ -73,6 +73,7 @@ import { registerSubAgentShutdownCommands } from './sub-agent-shutdown-commands.
73
73
  import { registerSubAgentDispatchGuard } from './sub-agent-dispatch-guard.js';
74
74
  import { registerTestCommands } from './test-commands.js';
75
75
  import { registerUnderstandCommands } from './understand-commands.js';
76
+ import { registerVendorDetectCommand } from './vendor-detect.js';
76
77
  import { registerUpgradeCommands } from './upgrade-commands.js';
77
78
  import { registerUserTouchpointCommands } from './user-touchpoint-commands.js';
78
79
  import { registerVerdictAggregateCommands } from './verdict-aggregate-command.js';
@@ -131,6 +132,7 @@ const REGISTRATIONS = [
131
132
  ['worktree-auth-commands', registerWorktreeAuthCommand],
132
133
  ['session-spill-demo', registerSpillDemoCommand],
133
134
  ['outer-cache-commands', registerOuterCacheCommands],
135
+ ['vendor-detect', registerVendorDetectCommand],
134
136
  ];
135
137
  function dispatchRegister(register, program, io) {
136
138
  if (register.length <= 1) {
@@ -1,6 +1,8 @@
1
1
  import { InvalidArgumentError } from 'commander';
2
- import { createCodegraphInvocation, executeCodegraphInvocation } from '../../services/codegraph/codegraph-service.js';
3
- import { fail } from 'peaks-loop-shared/result';
2
+ import { statSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { createCodegraphInvocation, executeCodegraphInvocation, defaultCodegraphInitGuard, writeCodegraphMarker, writeCodegraphAffectedContext, CodegraphInitConflictError } from '../../services/codegraph/codegraph-service.js';
5
+ import { fail, ok } from 'peaks-loop-shared/result';
4
6
  import { getErrorMessage, printResult, redactSensitiveErrorMessage } from '../cli-helpers.js';
5
7
  function addPeaksJsonOption(command) {
6
8
  return command.option('--peaks-json', 'print Peaks error envelope as machine-readable JSON');
@@ -45,14 +47,158 @@ async function runCodegraphCommand(io, command, options, asJson) {
45
47
  printCodegraphFailure(io, command, error, asJson);
46
48
  }
47
49
  }
50
+ /**
51
+ * rid-CG-006 — init conflict guard. Resolves the project root and
52
+ * probes `.codegraph/` for the peaks-loop marker before invoking the
53
+ * upstream binary.
54
+ *
55
+ * - fresh → proceed to upstream init
56
+ * - noop-already-peaks-loop → skip upstream, emit warning
57
+ * - conflict-foreign-schema → exit 73 + CODEGRAPH_INIT_CONFLICT envelope
58
+ *
59
+ * On a successful upstream init, write the marker so the next run
60
+ * hits the noop branch instead of the conflict branch.
61
+ */
62
+ async function runCodegraphInitCommand(io, options, asJson) {
63
+ let projectRoot;
64
+ try {
65
+ const candidate = resolve(options.project);
66
+ if (!statSync(candidate).isDirectory()) {
67
+ throw new Error('Project path must exist and be a directory');
68
+ }
69
+ projectRoot = candidate;
70
+ }
71
+ catch (error) {
72
+ printCodegraphFailure(io, 'codegraph.init', error, asJson);
73
+ return;
74
+ }
75
+ const guardOutcome = defaultCodegraphInitGuard(projectRoot);
76
+ if (guardOutcome.status === 'noop-already-peaks-loop') {
77
+ printResult(io, ok('codegraph.init', {
78
+ guard: guardOutcome.status,
79
+ codegraphDir: guardOutcome.codegraphDir,
80
+ markerPresent: true
81
+ }, [`.codegraph/ is already managed by peaks-loop; init is a no-op. Marker: ${guardOutcome.codegraphDir}/.peaks-loop-marker`], ['Run `peaks codegraph index` to (re)build the index without touching the schema.']), asJson);
82
+ return;
83
+ }
84
+ if (guardOutcome.status === 'conflict-foreign-schema') {
85
+ const conflict = new CodegraphInitConflictError(`Refusing to init: ${guardOutcome.codegraphDir} already exists with a non-peaks-loop schema. ` +
86
+ 'Move or rename the foreign directory, then re-run `peaks codegraph init`.', guardOutcome.codegraphDir);
87
+ printResult(io, fail('codegraph.init', conflict.code, conflict.message, { codegraphDir: conflict.codegraphDir }, [
88
+ 'Move or rename the foreign .codegraph/ directory before retrying.',
89
+ 'Or remove .codegraph/ if you are sure no other tool owns it.',
90
+ 'Or run `peaks codegraph init --project <path> --force` once the foreign-tool safety flag ships (tracked in rid-CG-006).'
91
+ ]), asJson);
92
+ process.exitCode = conflict.exitCode;
93
+ return;
94
+ }
95
+ // guardOutcome.status === 'fresh' — proceed.
96
+ try {
97
+ const invocation = createCodegraphInvocation({
98
+ subcommand: 'init',
99
+ project: options.project,
100
+ ...(options.yes === true ? { yes: true } : {})
101
+ });
102
+ const result = await executeCodegraphInvocation(invocation);
103
+ const didFail = result.exitCode !== null && result.exitCode !== 0;
104
+ if (result.stdout.length > 0) {
105
+ io.stdout((didFail ? redactSensitiveErrorMessage(result.stdout) : result.stdout).trimEnd());
106
+ }
107
+ if (result.stderr.length > 0) {
108
+ io.stderr((didFail ? redactSensitiveErrorMessage(result.stderr) : result.stderr).trimEnd());
109
+ }
110
+ if (didFail) {
111
+ if (asJson === true) {
112
+ printCodegraphFailure(io, 'codegraph.init', new Error(result.stderr || result.stdout || `codegraph exited with code ${result.exitCode}`), true, result.exitCode ?? 1);
113
+ }
114
+ process.exitCode = result.exitCode ?? 1;
115
+ return;
116
+ }
117
+ // Upstream succeeded — stamp the marker so the next run hits the
118
+ // noop branch. Best-effort: a marker-write failure must NOT undo
119
+ // the upstream init (peaks-loop still owns the schema logically).
120
+ try {
121
+ writeCodegraphMarker(guardOutcome.codegraphDir);
122
+ }
123
+ catch {
124
+ // intentionally swallowed — surface as warning below
125
+ }
126
+ printResult(io, ok('codegraph.init', { guard: guardOutcome.status, codegraphDir: guardOutcome.codegraphDir, markerWritten: true }, [], [`Stamped peaks-loop marker at ${guardOutcome.codegraphDir}/.peaks-loop-marker`]), asJson);
127
+ }
128
+ catch (error) {
129
+ printCodegraphFailure(io, 'codegraph.init', error, asJson);
130
+ }
131
+ }
132
+ /**
133
+ * rid-CG-002 — codegraph-affected envelope write.
134
+ *
135
+ * Wraps the generic `runCodegraphCommand` and, after a successful
136
+ * upstream invocation, calls `writeCodegraphAffectedContext` so the
137
+ * RD / QA handoff can pick up `.peaks/_runtime/<sid>/rd/codegraph-context.md`
138
+ * without re-running the (5-30 s) codegraph query.
139
+ *
140
+ * The envelope write is gated by `--write-envelope` (default off) so
141
+ * ad-hoc CLI invocations don't silently mutate the user's session
142
+ * directory. peaks-code's RD dispatch hook flips the flag on.
143
+ *
144
+ * On no-session-binding, we surface a `warning` field instead of
145
+ * throwing — the upstream result is still printed so the caller
146
+ * always sees the affected list.
147
+ */
148
+ async function runCodegraphAffectedCommand(io, files, options, asJson) {
149
+ // Capture stdout so we can re-emit it into the envelope payload.
150
+ // We still forward every line to the original `io.stdout` so the
151
+ // user sees the affected list as if the wrapper were transparent.
152
+ const capturedLines = [];
153
+ const captureIo = {
154
+ stdout: (chunk) => {
155
+ capturedLines.push(chunk);
156
+ io.stdout(chunk);
157
+ },
158
+ stderr: (chunk) => io.stderr(chunk)
159
+ };
160
+ await runCodegraphCommand(captureIo, 'codegraph.affected', {
161
+ subcommand: 'affected',
162
+ project: options.project,
163
+ files,
164
+ ...(options.json === true ? { json: true } : {})
165
+ }, asJson);
166
+ if (!options.writeEnvelope) {
167
+ return;
168
+ }
169
+ // The user opted in via --write-envelope. Run the envelope writer
170
+ // unconditionally (graceful fallback when no session binding).
171
+ const rid = options.rid ?? process.env.PEAKS_RD_RID ?? 'unknown-rid';
172
+ const rawStdout = capturedLines.join('\n');
173
+ let affectedPayload = rawStdout;
174
+ if (options.json === true && typeof affectedPayload === 'string' && affectedPayload.length > 0) {
175
+ try {
176
+ affectedPayload = JSON.parse(affectedPayload);
177
+ }
178
+ catch {
179
+ // Keep the raw string when JSON parse fails; the envelope
180
+ // renderer handles strings cleanly.
181
+ }
182
+ }
183
+ const envelope = writeCodegraphAffectedContext({
184
+ projectRoot: resolve(options.project),
185
+ rid,
186
+ files,
187
+ affectedPayload
188
+ });
189
+ if (envelope.written) {
190
+ if (asJson !== true) {
191
+ io.stdout(`[codegraph-context] wrote ${envelope.path}\n`);
192
+ }
193
+ }
194
+ else if (asJson !== true) {
195
+ io.stdout(`[codegraph-context] skipped: ${envelope.warning}\n`);
196
+ }
197
+ }
48
198
  export function registerCodegraphCommands(program, io) {
49
199
  const codegraph = program.command('codegraph').description('Run upstream codegraph commands through the Peaks launcher');
50
200
  addProjectOption(codegraph.command('status').description('Show codegraph status')).action((options) => runCodegraphCommand(io, 'codegraph.status', { subcommand: 'status', project: options.project }, options.peaksJson));
51
- addProjectOption(codegraph.command('init').description('Initialize codegraph for a project').option('--yes', 'answer yes to upstream prompts')).action((options) => runCodegraphCommand(io, 'codegraph.init', {
52
- subcommand: 'init',
53
- project: options.project,
54
- ...(options.yes === true ? { yes: true } : {})
55
- }, options.peaksJson));
201
+ addProjectOption(codegraph.command('init').description('Initialize codegraph for a project').option('--yes', 'answer yes to upstream prompts')).action((options) => runCodegraphInitCommand(io, options, options.peaksJson));
56
202
  addProjectOption(codegraph
57
203
  .command('index')
58
204
  .description('Index a project with codegraph')
@@ -90,10 +236,7 @@ export function registerCodegraphCommands(program, io) {
90
236
  .command('affected')
91
237
  .description('Find code affected by files')
92
238
  .argument('<files...>', 'project-relative file paths')
93
- .option('--json', 'forward JSON output flag to upstream codegraph')).action((files, options) => runCodegraphCommand(io, 'codegraph.affected', {
94
- subcommand: 'affected',
95
- project: options.project,
96
- files,
97
- ...(options.json === true ? { json: true } : {})
98
- }, options.peaksJson));
239
+ .option('--json', 'forward JSON output flag to upstream codegraph')
240
+ .option('--rid <rid>', 'request id for the codegraph-context envelope (default: env PEAKS_RD_RID or "unknown-rid")')
241
+ .option('--write-envelope', 'write codegraph-context.md into the active session')).action((files, options) => runCodegraphAffectedCommand(io, files, options, options.peaksJson));
99
242
  }
@@ -1,6 +1,29 @@
1
1
  import type { Command } from 'commander';
2
2
  import { type ProgramIO } from '../cli-helpers.js';
3
3
  export declare function registerDispatchCommand(parent: Command, io: ProgramIO): void;
4
+ /**
5
+ * F5 follow-up (sediment 2026-08-11-rid-001-redo-fake-green-recovery-closure
6
+ * §Lesson 1): synchronous anti-fake-green file-existence gate. Runs
7
+ * `git ls-files <glob>` against `projectRoot` and returns the matching
8
+ * tracked file paths (relative to projectRoot). Empty array when no
9
+ * files match (e.g. untracked new file, wrong glob, not a git repo).
10
+ *
11
+ * Why `git ls-files` and not `fs.glob`: the anti-fake-green contract
12
+ * is "the file the sub-agent claims to have written must ACTUALLY be
13
+ * tracked by git" — `git ls-files` enforces that contract; `fs.glob`
14
+ * would happily return untracked-but-on-disk files (false-positive
15
+ * for the fake-green gate).
16
+ *
17
+ * Failure modes (best-effort, never throws):
18
+ * - git not on PATH → empty array (`ENOENT` swallowed)
19
+ * - not a git repo → empty array (git exits non-zero)
20
+ * - glob matches zero tracked files → empty array
21
+ *
22
+ * Exported for unit-test access (`tests/unit/sub-agent/must-ls-files-flag.test.ts`).
23
+ * The export is intentional — the helper has zero side effects and
24
+ * keeps the dispatch action handler small.
25
+ */
26
+ export declare function runGitLsFiles(projectRoot: string, glob: string): readonly string[];
4
27
  /**
5
28
  * `dispatchSubAgent` is the thin programmatic wrapper the integration
6
29
  * test (`tests/integration/sub-agent-graph-binding.test.ts`) imports.
@@ -72,8 +72,70 @@ export function registerDispatchCommand(parent, io) {
72
72
  // rejects with PEAKS_GRAPH_NODE_REQUIRED / PEAKS_GRAPH_NODE_NOT_PREPARED.
73
73
  .requiredOption('--graph-node <id>', 'graph node id this dispatch binds to (RD §4 D4c)')
74
74
  .option('--workflow-id <id>', 'workflow id the graph node belongs to (defaults to derived from session)')
75
- .option('--graph-ref <ref>', 'graphRef (defaults to graphs/<workflow-id>.json)')).action(async (role, options) => {
75
+ .option('--graph-ref <ref>', 'graphRef (defaults to graphs/<workflow-id>.json)')
76
+ // rid-001 detached sub-agent dispatch: 4 new options. Default
77
+ // mode is `in-process` so the 106+ existing dispatch call sites
78
+ // keep their path byte-identical. The detached branch below
79
+ // fires only when --mode detached is explicitly passed.
80
+ .option('--mode <mode>', 'dispatch execution mode: in-process (default, dry-run envelope only) | detached (shell out to peaks-loop-internal-runtime/dispatch.dispatchDetached for real vendor CLI execution).')
81
+ .option('--vendor <vendor>', 'target vendor CLI for --mode detached (claude | codex | copilot). Ignored in the default in-process path.')
82
+ .option('--no-throttle', 'rid-001 detached: user-overrides ResourceBudgetGuard when concurrent fan-out exceeds max-concurrent (user accepts risk; surfaces as warning)')
83
+ .option('--max-concurrent <n>', 'rid-001 detached: override the per-tenant max concurrent budget (default 8). Effective in both detached and in-process paths.')
84
+ // F5 follow-up (sediment 2026-08-11-rid-001-redo-fake-green-recovery-closure
85
+ // §Lesson 1): the RD sub-agent's fake-green failure mode was that it
86
+ // claimed "5/5 reachability tests PASS" while the files were never
87
+ // on disk. `--must-ls-files <glob>` is the anti-fake-green gate:
88
+ // the CLI runs `git ls-files <glob>` upfront, reports the result in
89
+ // the envelope (`data.mustLsFilesVerification`), and prepends a
90
+ // `## must_ls_files enforcement` block to the sub-agent prompt so
91
+ // the LLM's first action MUST re-verify file existence before any
92
+ // "completed" claim. Absent → old behavior is preserved.
93
+ .option('--must-ls-files <glob>', 'F5: anti-fake-green gate. Run `git ls-files <glob>` upfront; surface the result in the envelope as `mustLsFilesVerification: { path, exists, files }`; prepend a must_ls_files enforcement block to the sub-agent prompt. Absent → unchanged behavior.')).action(async (role, options) => {
76
94
  const asJson = options.json === true;
95
+ // rid-001 detached sub-agent dispatch: when --mode detached is
96
+ // explicitly requested, lazy-import the detached handler and short-
97
+ // circuit before the warm-path in-process pipeline runs. Branch
98
+ // lives in the existing action handler (NOT a sibling `peaks
99
+ // sub-agent-detached` command) per the slice decision memo:
100
+ // - 106+ existing dispatch tests reach this exact action path
101
+ // - Backward compat requires the default (no --mode) to keep
102
+ // the in-process envelope shape byte-identical
103
+ // - One validation entry-point reduces double-pipe maintenance
104
+ if (options.mode === 'detached') {
105
+ try {
106
+ const { dispatch: detachedDispatch } = await import('./sub-agent/detached.js');
107
+ const projectRoot = options.project ?? process.cwd();
108
+ const maxConcurrent = typeof options.maxConcurrent === 'string' && options.maxConcurrent.length > 0
109
+ ? Number.parseInt(options.maxConcurrent, 10)
110
+ : undefined;
111
+ const result = await detachedDispatch({
112
+ role,
113
+ prompt: typeof options.prompt === 'string' ? options.prompt : '',
114
+ requestId: options.requestId ?? 'unknown-rid',
115
+ mode: 'detached',
116
+ ...(typeof options.vendor === 'string' ? { vendor: options.vendor } : {}),
117
+ project: projectRoot,
118
+ json: asJson,
119
+ ...(options.noThrottle === true ? { noThrottle: true } : {}),
120
+ ...(typeof maxConcurrent === 'number' && Number.isInteger(maxConcurrent) && maxConcurrent > 0
121
+ ? { maxConcurrent }
122
+ : {}),
123
+ });
124
+ printResult(io, ok(result.command, result.data, result.warnings ?? [], result.nextActions ?? []), asJson);
125
+ }
126
+ catch (error) {
127
+ printResult(io, fail('sub-agent.dispatch', 'DISPATCH_DETACHED_ERROR', getErrorMessage(error), {
128
+ role,
129
+ toolCall: null,
130
+ dispatchRecordPath: null
131
+ }, [
132
+ 'If --mode detached fails on import, the peaks-loop-internal-runtime package may be missing; reinstall and retry.',
133
+ 'For environments without a vendor CLI on PATH, drop --mode to fall back to the default in-process dry-run.'
134
+ ]), asJson);
135
+ process.exitCode = 1;
136
+ }
137
+ return;
138
+ }
77
139
  // 2.7.0 slice-dag-dispatcher MVP: --from-dag short-circuits the single
78
140
  // sub-agent path and runs the full DAG plan via `dag-orchestrator`.
79
141
  if (typeof options.fromDag === 'string' && options.fromDag.length > 0) {
@@ -379,7 +441,31 @@ export function registerDispatchCommand(parent, io) {
379
441
  `branch: ${worktreeBranch}\n` +
380
442
  `You MAY ` + '`git worktree add` ' + `and ` + '`git worktree remove` ' + `against this lease without a separate ` + '`peaks worktree auth grant` ' + `— the PreToolUse gate reads the lease file. Run ` + '`peaks worktree release --lease-id ${leaseId}` ' + `when done.\n`
381
443
  : '';
382
- let effectivePrompt = `${formatTestToolDetection()}\n\n${memoryAugmentedBody}${isolationBlock}`;
444
+ // F5 follow-up: anti-fake-green gate. When `--must-ls-files <glob>`
445
+ // is supplied, run `git ls-files <glob>` upfront, surface the
446
+ // result in the envelope as `mustLsFilesVerification: { path,
447
+ // exists, files }`, and prepend a `## must_ls_files enforcement`
448
+ // frontmatter block to the sub-agent prompt that mandates the
449
+ // file-existence verification as the LLM's FIRST action (before
450
+ // any "completed"/"PASS" claim). When the flag is absent the
451
+ // field is `null` and no block is injected — old call sites
452
+ // see no behavior change (rid-001 fake-green Lesson 1).
453
+ let mustLsFilesVerification = null;
454
+ let mustLsFilesBlock = '';
455
+ if (typeof options.mustLsFiles === 'string' && options.mustLsFiles.length > 0) {
456
+ const glob = options.mustLsFiles;
457
+ const files = runGitLsFiles(projectRoot, glob);
458
+ const exists = files.length > 0;
459
+ mustLsFilesVerification = { path: glob, exists, files };
460
+ mustLsFilesBlock = `\n## must_ls_files enforcement (F5 anti-fake-green)\n` +
461
+ `glob: ${glob}\n` +
462
+ `verification: ${exists ? `EXISTS (${files.length} file${files.length === 1 ? '' : 's'} found)` : 'MISSING (no files matched the glob)'}\n` +
463
+ (exists ? `first match: ${files[0] ?? ''}\n` : '') +
464
+ `BEFORE any claim that work is "completed" or "PASS", you MUST run \`git ls-files ${glob}\` from the project root and ` +
465
+ `confirm the file exists. Anti-fake-green rule (sediment 2026-08-11-rid-001-redo-fake-green-recovery-closure §Lesson 1): ` +
466
+ `if the file does not exist, your verdict MUST be \`status: "blocked"\` with reason "must_ls_files_failed". Do NOT silently skip this step.\n`;
467
+ }
468
+ let effectivePrompt = `${formatTestToolDetection()}\n\n${memoryAugmentedBody}${isolationBlock}${mustLsFilesBlock}`;
383
469
  let headroomCompressed = false;
384
470
  let headroomResult = null;
385
471
  const warnings = [...decision.warnings];
@@ -596,7 +682,13 @@ export function registerDispatchCommand(parent, io) {
596
682
  isolation: isolationMode,
597
683
  leaseId,
598
684
  worktreePath,
599
- worktreeBranch
685
+ worktreeBranch,
686
+ // F5: anti-fake-green gate envelope surface. When
687
+ // `--must-ls-files <glob>` is supplied this carries the
688
+ // pre-dispatch verification result so the orchestrator can
689
+ // surface "the file exists" (or "missing — block") before
690
+ // spawning the sub-agent. Null when the flag is absent.
691
+ mustLsFilesVerification
600
692
  }, warnings, nextActions), asJson);
601
693
  // Slice 2026-06-23-audit-4th #B1: structured log on success path.
602
694
  // Best-effort: writeLogEntry swallows its own errors (logger.ts:155-159),
@@ -766,6 +858,38 @@ function spawnContainerLease(args) {
766
858
  child.unref();
767
859
  });
768
860
  }
861
+ /**
862
+ * F5 follow-up (sediment 2026-08-11-rid-001-redo-fake-green-recovery-closure
863
+ * §Lesson 1): synchronous anti-fake-green file-existence gate. Runs
864
+ * `git ls-files <glob>` against `projectRoot` and returns the matching
865
+ * tracked file paths (relative to projectRoot). Empty array when no
866
+ * files match (e.g. untracked new file, wrong glob, not a git repo).
867
+ *
868
+ * Why `git ls-files` and not `fs.glob`: the anti-fake-green contract
869
+ * is "the file the sub-agent claims to have written must ACTUALLY be
870
+ * tracked by git" — `git ls-files` enforces that contract; `fs.glob`
871
+ * would happily return untracked-but-on-disk files (false-positive
872
+ * for the fake-green gate).
873
+ *
874
+ * Failure modes (best-effort, never throws):
875
+ * - git not on PATH → empty array (`ENOENT` swallowed)
876
+ * - not a git repo → empty array (git exits non-zero)
877
+ * - glob matches zero tracked files → empty array
878
+ *
879
+ * Exported for unit-test access (`tests/unit/sub-agent/must-ls-files-flag.test.ts`).
880
+ * The export is intentional — the helper has zero side effects and
881
+ * keeps the dispatch action handler small.
882
+ */
883
+ export function runGitLsFiles(projectRoot, glob) {
884
+ try {
885
+ const { execFileSync } = require('node:child_process');
886
+ const stdout = execFileSync('git', ['ls-files', '--', glob], { cwd: projectRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
887
+ return stdout.split('\n').filter((line) => line.length > 0);
888
+ }
889
+ catch {
890
+ return [];
891
+ }
892
+ }
769
893
  /* ---------- Slice 4.0.8 RD §4 D4c: programmatic dispatcher ---------- */
770
894
  /**
771
895
  * `dispatchSubAgent` is the thin programmatic wrapper the integration
@@ -25,8 +25,17 @@ export async function dispatch(f) {
25
25
  if (f.noThrottle) {
26
26
  warnings.push('user-overrode: --no-throttle (peak runtime may exceed performance ceiling)');
27
27
  }
28
+ // rid-001 fix (F1 follow-up): the vendor CLI's ChildProcess is owned by
29
+ // peaks-loop-internal-runtime/dispatch.dispatchDetached, which now exposes
30
+ // it via DispatchResult.child. When the vendor binary is not on PATH the
31
+ // spawn fires an async 'error' event after dispatchDetached returns; we
32
+ // attach a per-child handler that swallows ENOENT (expected when the user
33
+ // hasn't installed claude/codex/copilot) and logs anything else. This is
34
+ // the canonical pattern (matches codegraph-process-runner.ts) — no more
35
+ // process-level uncaughtException swallow.
28
36
  const sid = process.env.PEAKS_SESSION_ID ?? 'local';
29
- const r = await dispatchDetached({
37
+ let r;
38
+ r = await dispatchDetached({
30
39
  sid,
31
40
  rid: f.requestId,
32
41
  role: f.role,
@@ -37,6 +46,13 @@ export async function dispatch(f) {
37
46
  runtimeDir: `.peaks/_runtime/${sid}/detached`,
38
47
  subAgentsDir: `.peaks/_sub_agents/${sid}`,
39
48
  });
49
+ r.child?.on('error', (err) => {
50
+ if (err && err.code === 'ENOENT')
51
+ return;
52
+ // Non-ENOENT child errors are surfaced to stderr; the dispatch envelope
53
+ // has already been written and the detached child is fire-and-forget.
54
+ console.error('[peaks sub-agent dispatch] detached child error:', err);
55
+ });
40
56
  return {
41
57
  ok: true,
42
58
  command: 'sub-agent.dispatch.detached',
@@ -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 = {