peaks-loop 4.0.12 → 4.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/cli/commands/_register.js +2 -0
  3. package/dist/cli/commands/core/doctor-command.js +26 -3
  4. package/dist/cli/commands/outer-cache-commands.d.ts +11 -0
  5. package/dist/cli/commands/outer-cache-commands.js +120 -0
  6. package/dist/services/doctor/doctor-service/checks/multi-binary-drift.d.ts +61 -0
  7. package/dist/services/doctor/doctor-service/checks/multi-binary-drift.js +268 -0
  8. package/dist/services/doctor/doctor-service/plugin-registry.js +2 -0
  9. package/dist/services/doctor/doctor-service/report/final-summary.d.ts +13 -5
  10. package/dist/services/doctor/doctor-service/report/final-summary.js +19 -9
  11. package/dist/services/doctor/doctor-service/types.d.ts +55 -0
  12. package/dist/services/doctor/doctor-service.d.ts +2 -1
  13. package/dist/services/doctor/doctor-service.js +1 -0
  14. package/dist/services/doctor/index.d.ts +1 -1
  15. package/dist/services/doctor/index.js +1 -1
  16. package/dist/services/session/session-binding-bridge.js +58 -5
  17. package/dist/services/skills/hooks-settings-service.js +22 -2
  18. package/dist/services/skills/outer-cache-hook-constants.d.ts +24 -0
  19. package/dist/services/skills/outer-cache-hook-constants.js +24 -0
  20. package/dist/services/skills/skill-statusline-renderer.d.ts +3 -1
  21. package/dist/services/skills/skill-statusline-renderer.js +37 -1
  22. package/dist/services/skills/skill-statusline-service.d.ts +14 -0
  23. package/dist/services/skills/skill-statusline-service.js +40 -16
  24. package/dist/services/skills/skill-statusline-sid-suffix.d.ts +55 -0
  25. package/dist/services/skills/skill-statusline-sid-suffix.js +65 -0
  26. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.0.14 — 2026-08-06 (outer-session cache + ensureSession meta over-coverage)
4
+
5
+ **Fixes the "5 terminals / 5 sessions stuck on 3fe1be" bug** (slice `2026-08-06-session-outer-cache-and-meta-coverage`, commit `f02a9b45`):
6
+ - `src/services/session/session-binding-bridge.ts` `getCurrentOuterSessionId(projectRoot)` now resolves `PEAKS_OUTER_SESSION_ID` → `CLAUDE_CODE_SESSION_ID` → file-cache at `.peaks/_runtime/.outer-session-cache.json` → `undefined`. Any IO error or non-string `outerSessionId` is a cache-miss (no throw). The cache file lives under the existing `.peaks/_runtime/` gitignore parent rule — no `.gitignore` modification.
7
+ - Same file: `ensureSession` early-return path now calls `setSessionMeta(projectRoot, existing.sessionId, { outerSessionId })` BEFORE returning. The on-disk `.peaks/_runtime/<sid>/session.json` always reflects the latest outerSessionId (not a stale value captured at first-bind time). All other meta fields (title / skill / mode / gate / createdAt) preserved via read-modify-write; `lastActivity` bumped to `now`.
8
+ - `src/services/skills/hooks-settings-service.ts` `resolveHookEntries('claude-code')` now appends a SessionStart entry that runs `peaks outer-cache write --project ...` to keep the cache in sync with the active Claude Code session. Uninstall strips the entry alongside the gate-enforce entry. Multi-binary drift guard (slice 2026-08-05) still fires correctly because the SessionStart entry uses the same `peaks` binary.
9
+ - New CLI: `peaks outer-cache write` (no flags) + `peaks outer-cache read` (returns `{ missing: true }` envelope on absent / malformed / IO error). Write exits 1 with `OUTER_CACHE_NO_ENV` when neither `PEAKS_OUTER_SESSION_ID` nor `CLAUDE_CODE_SESSION_ID` is set.
10
+ - Tests: `tests/unit/session/get-current-outer-session-id.test.ts` (10 cases pinning env > cache > undefined ordering, cache-miss / malformed JSON / IO error tolerance) + `tests/unit/session/ensure-session-meta-coverage.test.ts` (7 cases pinning AC8-AC11 over-coverage). Regression sweep: 179/179 PASS / 1 SKIP (pre-existing Win-only conditional).
11
+ - A.3 / A.4 / A.5 (workflowId by callerId + `session.json` migration + cross-outer rotation cases) explicitly reserved for a future session per PRD NG1-NG4.
12
+
13
+ **Lockstep bump.** peaks-loop-shared `0.0.43 → 0.0.44` (CLI_VERSION re-stamped to 4.0.14); peaks-loop-mut `0.1.16 → 0.1.17`; peaks-loop-shared-channel `0.0.20 → 0.0.21`.
14
+
15
+ ## 4.0.13 — 2026-08-06 (statusline empty-render fix + sid-only marker + multi-binary drift guard)
16
+
17
+ **Statusline callerId fallback + active `[short-sid]` suffix** (slice `2026-08-05-statusline-empty-render-and-short-sid-suffix`, commit `4be37d08`):
18
+ - `src/services/skills/skill-statusline-service.ts` `readPresenceReadOnly` now retries with `callerId: null` when callerId-filtered resolution returns `source: 'none'`. Multi-tenant isolation invariant preserved (slice 4-B Case A regression test stays green).
19
+ - `src/services/skills/skill-statusline-renderer.ts` introduces `formatShortSid(sessionId) = sessionId.split('-').pop()` helper. Active state now renders `Peaks ● peaks-code → peaks-loop [3fe1be]`.
20
+ - New tests: `tests/unit/skills/skill-statusline-empty-render-and-short-sid-suffix.test.ts` (8 cases). Existing dual-skill test updated to accept the `[aaaa]` suffix.
21
+ - Fixes the "new session statusline empty despite active peaks-code lease" bug (root cause: outerSessionId drift between caller shell and lease's callerId).
22
+
23
+ **Statusline sid-only marker (idle/stale) + multi-binary drift guard** (slice `2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard`, commits `95654d48` + `34de6c22` repair):
24
+ - `skill-statusline-service.ts` adds `sessionId: string | null` to `StatusLineModel`. `skill-statusline-sid-suffix.ts` (new, 78 LOC) holds `computeRootSuffix` + `formatShortSid` (extracted from renderer for LOC cap; renderer 806 → 776).
25
+ - Idle and stale states now append ` [3fe1be]` after `peaks-loop` when session.json is bound. Invalid-presence stays unchanged (G2 — read-error signal must remain loud).
26
+ - New `peaks doctor check` plugin `build:multi-binary-drift` scans `process.env.PATH` for `peaks` binaries (cross-platform: `peaks` / `peaks.cmd` / `peaks.ps1`), resolves each to its `node_modules/peaks-loop/package.json`, dedupes by realpath, and emits `PEAKS_MULTI_BINARY_DRIFT` warning when ≥ 2 distinct versions coexist. Caught the `/c/nvm4w/nodejs 4.0.12 + /c/Users/smallMark/AppData/Roaming/npm 3.1.2` case that produced "Hook JSON output validation failed" on fresh IDE sessions.
27
+ - **Severity-aware `buildReport`** (the canonical reference for warn-only doctor checks): `DoctorCheck` now carries `severity: 'error' | 'warning'`. `buildReport` separates errors from warnings; `summary.ok = errors === 0`. Multi-binary drift emits `severity: 'warning'`. CLI exit code only flips on errors.
28
+ - Tests: `tests/unit/skills/skill-statusline-sid-only-marker.test.ts` (8 cases) + `tests/unit/doctor/multi-binary-drift-check.test.ts` (13 cases) + `tests/unit/doctor/final-summary-severity.test.ts` (7 cases) + `tests/unit/doctor/doctor-exit-code-warn-only.test.ts` (5 cases). 170/170 green on slice surface.
29
+
30
+ **Lockstep bump.** peaks-loop-shared `0.0.42 → 0.0.43` (CLI_VERSION re-stamped to 4.0.13); peaks-loop-mut `0.1.15 → 0.1.16`; peaks-loop-shared-channel `0.0.19 → 0.0.20`.
31
+
3
32
  ## 4.0.12 — 2026-08-05 (5-slice optimization bundle)
4
33
 
5
34
  **publish.yml strict tag gate** (slice 1, commit `f60f7597`):
@@ -36,6 +36,7 @@ import { registerWorkflowEvalCommands } from './loop-eval-commands.js';
36
36
  import { registerMutCommands } from './mut-commands.js';
37
37
  import { registerObservabilityCommands } from './observability-commands.js';
38
38
  import { registerOpenSpecCommands } from './openspec-commands.js';
39
+ import { registerOuterCacheCommands } from './outer-cache-commands.js';
39
40
  import { registerPerfAuditCommands } from './perf-audit-commands.js';
40
41
  import { registerPerfCommands } from './perf-commands.js';
41
42
  import { registerPlaywrightCommands } from './playwright-commands.js';
@@ -127,6 +128,7 @@ const REGISTRATIONS = [
127
128
  ['adapter-commands-s2a', registerAdapterS2ACommands], ['polyrepo-commands', registerPolyrepoCommands],
128
129
  ['worktree-auth-commands', registerWorktreeAuthCommand],
129
130
  ['session-spill-demo', registerSpillDemoCommand],
131
+ ['outer-cache-commands', registerOuterCacheCommands],
130
132
  ];
131
133
  function dispatchRegister(register, program, io) {
132
134
  if (register.length <= 1) {
@@ -212,6 +212,10 @@ export function registerDoctorCommand(program, io) {
212
212
  const data = logsSection === null
213
213
  ? { ...report, staleBinding: staleBindingSection }
214
214
  : { ...report, logs: logsSection, staleBinding: staleBindingSection };
215
+ // Slice 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
216
+ // repair cycle: `report.summary.ok` already factors in the
217
+ // severity-aware aggregation in `buildReport` (warnings do NOT
218
+ // flip `ok`). Stale-binding is independent and still escalates.
215
219
  const result = report.summary.ok && staleInstances.length === 0
216
220
  ? ok('doctor', data)
217
221
  : fail('doctor', 'DOCTOR_FAILED', staleInstances.length > 0
@@ -224,9 +228,20 @@ export function registerDoctorCommand(program, io) {
224
228
  }
225
229
  else {
226
230
  // Human-readable: one line per check, green/red indicators, no JSON.
231
+ // Slice 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
232
+ // repair cycle: warn-only findings (severity: 'warning') surface
233
+ // as `! check.ok` with the `! ` (warning) glyph so the operator
234
+ // can tell them apart from real errors without reading JSON.
227
235
  for (const check of report.checks) {
228
- const icon = check.ok ? '+' : '×';
229
- io.stdout(` ${icon} ${check.message}`);
236
+ if (check.ok) {
237
+ io.stdout(` + ${check.message}`);
238
+ }
239
+ else if (check.severity === 'warning') {
240
+ io.stdout(` ! ${check.message}`);
241
+ }
242
+ else {
243
+ io.stdout(` × ${check.message}`);
244
+ }
230
245
  }
231
246
  if (logsSection !== null) {
232
247
  io.stdout('\n logs:');
@@ -252,11 +267,19 @@ export function registerDoctorCommand(program, io) {
252
267
  io.stdout(' rerun with --cleanup-stale to drop them');
253
268
  }
254
269
  }
255
- io.stdout(`\n ${report.summary.passed} passed, ${report.summary.failed} failed`);
270
+ io.stdout(`\n ${report.summary.passed} passed, ${report.summary.failed} failed${report.summary.warnings > 0 ? `, ${report.summary.warnings} warning(s)` : ''}`);
256
271
  if (!report.summary.ok || staleInstances.length > 0) {
257
272
  io.stderr(`\nDOCTOR_FAILED: ${staleInstances.length > 0 ? 'stale binding present' : `${report.summary.failed} check(s) failed`}.`);
258
273
  }
274
+ else if (report.summary.warnings > 0) {
275
+ io.stdout(`\n ${report.summary.warnings} warning(s) present — exit code 0 (warn-only).`);
276
+ }
259
277
  }
278
+ // Slice 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
279
+ // repair cycle: `report.summary.ok` already factors in the
280
+ // severity-aware aggregation in `buildReport` (warnings do NOT
281
+ // flip `ok`). The exit-code gate remains `summary.ok &&
282
+ // !staleInstances` — no separate warning special-case needed here.
260
283
  if (!report.summary.ok || staleInstances.length > 0) {
261
284
  process.exitCode = 1;
262
285
  }
@@ -0,0 +1,11 @@
1
+ import type { Command } from 'commander';
2
+ import { type ProgramIO } from '../cli-helpers.js';
3
+ export type OuterCacheWriteOptions = {
4
+ readonly project?: string;
5
+ readonly json?: boolean;
6
+ };
7
+ export type OuterCacheReadOptions = {
8
+ readonly project?: string;
9
+ readonly json?: boolean;
10
+ };
11
+ export declare function registerOuterCacheCommands(program: Command, io: ProgramIO): void;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Slice 2026-08-06-session-outer-cache (G1 / G2) — per-project outer-session
3
+ * cache CLI surface.
4
+ *
5
+ * Writes and reads `.peaks/_runtime/.outer-session-cache.json` so that
6
+ * peaks CLI sub-processes (which typically do NOT inherit
7
+ * `CLAUDE_CODE_SESSION_ID` from the parent shell) can resolve the
8
+ * current outer session id via `getCurrentOuterSessionId(projectRoot)`
9
+ * in `src/services/session/session-binding-bridge.ts`.
10
+ *
11
+ * The SessionStart hook wired by `peaks hooks install` invokes
12
+ * `peaks outer-cache write` to keep this file in sync with the active
13
+ * Claude Code / Trae / IDE session. The file is under `.peaks/_runtime/`
14
+ * (gitignored), so no `.gitignore` change is required.
15
+ *
16
+ * Subcommand surface:
17
+ * - `peaks outer-cache write` (no flags) — read PEAKS_OUTER_SESSION_ID
18
+ * ?? CLAUDE_CODE_SESSION_ID, write the cache file, return JSON
19
+ * envelope with `{ outerSessionId, capturedAt, cachePath, written }`.
20
+ * - `peaks outer-cache read` — return
21
+ * `{ outerSessionId, capturedAt, cachePath }` when present or
22
+ * `{ missing: true, cachePath }` when not. Never throws on
23
+ * malformed JSON / IO error.
24
+ *
25
+ * Both subcommands accept `--project <path>` (defaults to cwd / git
26
+ * root) and `--json` (default in TTY-less invocations).
27
+ */
28
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
29
+ import { dirname, join } from 'node:path';
30
+ import { fail, ok } from 'peaks-loop-shared/result';
31
+ import { addJsonOption, printResult } from '../cli-helpers.js';
32
+ import { findProjectRoot } from '../../services/config/config-safety.js';
33
+ const OUTER_SESSION_CACHE_REL = join('.peaks', '_runtime', '.outer-session-cache.json');
34
+ function resolveCachePath(projectRoot) {
35
+ return join(projectRoot, OUTER_SESSION_CACHE_REL);
36
+ }
37
+ function readEnvOuter() {
38
+ const peaks = process.env.PEAKS_OUTER_SESSION_ID;
39
+ if (typeof peaks === 'string' && peaks.length > 0)
40
+ return peaks;
41
+ const claude = process.env.CLAUDE_CODE_SESSION_ID;
42
+ if (typeof claude === 'string' && claude.length > 0)
43
+ return claude;
44
+ return undefined;
45
+ }
46
+ function readCacheFile(cachePath) {
47
+ if (!existsSync(cachePath))
48
+ return { ok: false, missing: true };
49
+ try {
50
+ const raw = readFileSync(cachePath, 'utf8');
51
+ const parsed = JSON.parse(raw);
52
+ if (parsed !== null &&
53
+ typeof parsed === 'object' &&
54
+ typeof parsed.outerSessionId === 'string' &&
55
+ (parsed.outerSessionId).length > 0 &&
56
+ typeof parsed.capturedAt === 'string') {
57
+ return {
58
+ ok: true,
59
+ outerSessionId: parsed.outerSessionId,
60
+ capturedAt: parsed.capturedAt
61
+ };
62
+ }
63
+ return { ok: false, missing: true };
64
+ }
65
+ catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
66
+ return { ok: false, missing: true };
67
+ }
68
+ }
69
+ export function registerOuterCacheCommands(program, io) {
70
+ const outerCache = program
71
+ .command('outer-cache')
72
+ .description('Read / write the per-project outer-session cache file (.peaks/_runtime/.outer-session-cache.json). The SessionStart hook installed by `peaks hooks install` keeps this file in sync with the live Claude Code / Trae / IDE session so peaks CLI sub-processes (which typically do NOT inherit CLAUDE_CODE_SESSION_ID) can resolve the current outer session via getCurrentOuterSessionId(projectRoot).');
73
+ addJsonOption(outerCache
74
+ .command('write')
75
+ .description('Write the current outer-session-id (PEAKS_OUTER_SESSION_ID ?? CLAUDE_CODE_SESSION_ID) into the per-project cache file. Idempotent: re-runs overwrite. Exits 1 with OUTER_CACHE_NO_ENV when neither env var is set.')
76
+ .option('--project <path>', 'target project root (defaults to git root or cwd)')).action((options) => {
77
+ const projectRoot = options.project ?? (findProjectRoot(process.cwd()) ?? process.cwd());
78
+ const cachePath = resolveCachePath(projectRoot);
79
+ const outerSessionId = readEnvOuter();
80
+ if (outerSessionId === undefined) {
81
+ printResult(io, fail('outer-cache.write', 'OUTER_CACHE_NO_ENV', 'Neither PEAKS_OUTER_SESSION_ID nor CLAUDE_CODE_SESSION_ID is set; nothing to write', { cachePath, written: false, projectRoot }, [
82
+ 'Set PEAKS_OUTER_SESSION_ID=<id> or run from inside Claude Code / Trae / IDE so CLAUDE_CODE_SESSION_ID is exported',
83
+ 'Re-run `peaks hooks install` to wire the SessionStart hook that writes this cache automatically'
84
+ ]), options.json === true);
85
+ process.exitCode = 1;
86
+ return;
87
+ }
88
+ const capturedAt = new Date().toISOString();
89
+ const payload = { outerSessionId, capturedAt };
90
+ try {
91
+ const dir = dirname(cachePath);
92
+ if (!existsSync(dir))
93
+ mkdirSync(dir, { recursive: true });
94
+ writeFileSync(cachePath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
95
+ }
96
+ catch (error) {
97
+ const message = error instanceof Error ? error.message : String(error);
98
+ printResult(io, fail('outer-cache.write', 'OUTER_CACHE_WRITE_FAILED', `Failed to write outer-session cache: ${message}`, { cachePath, written: false, projectRoot, outerSessionId, capturedAt }, [message]), options.json === true);
99
+ process.exitCode = 1;
100
+ return;
101
+ }
102
+ printResult(io, ok('outer-cache.write', { cachePath, written: true, projectRoot, outerSessionId, capturedAt }, [], [`Wrote ${cachePath}; getCurrentOuterSessionId() will resolve to "${outerSessionId}" until the next SessionStart fires.`]), options.json === true);
103
+ });
104
+ addJsonOption(outerCache
105
+ .command('read')
106
+ .description('Read the current value of the per-project outer-session cache. Returns { missing: true, cachePath } when the file is absent / malformed / empty; never throws on IO error.')
107
+ .option('--project <path>', 'target project root (defaults to git root or cwd)')).action((options) => {
108
+ const projectRoot = options.project ?? (findProjectRoot(process.cwd()) ?? process.cwd());
109
+ const cachePath = resolveCachePath(projectRoot);
110
+ const result = readCacheFile(cachePath);
111
+ if (result.ok) {
112
+ printResult(io, ok('outer-cache.read', { cachePath, missing: false, projectRoot, outerSessionId: result.outerSessionId, capturedAt: result.capturedAt }, [], [`outer-session-id resolved: ${result.outerSessionId} (captured ${result.capturedAt})`]), options.json === true);
113
+ return;
114
+ }
115
+ printResult(io, ok('outer-cache.read', { cachePath, missing: true, projectRoot }, ['No outer-session cache present — getCurrentOuterSessionId() will return undefined for this project.'], [
116
+ 'Re-run `peaks hooks install` to wire the SessionStart hook that writes this cache automatically',
117
+ 'Or set PEAKS_OUTER_SESSION_ID=<id> so env-first resolution wins without touching the cache'
118
+ ]), options.json === true);
119
+ });
120
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Check: PATH-scoped `peaks-loop` binary drift
3
+ * (`build:multi-binary-drift`).
4
+ *
5
+ * Slice 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
6
+ * (G3/G4). When more than one `peaks-loop` binary is discoverable on
7
+ * `process.env.PATH` AND the discovered versions disagree, the doctor
8
+ * emits a `PEAKS_MULTI_BINARY_DRIFT` warning. The user-reported
9
+ * production symptom — `peaks` resolving to an old version (e.g.
10
+ * 3.1.2 on `%AppData%\Roaming\npm`) while the freshly installed
11
+ * binary on another PATH entry (e.g. `C:\nvm4w\nodejs\peaks` 4.0.12)
12
+ * is what the user just bumped — produces "Hook JSON output
13
+ * validation failed" when the IDE statusline hook inherits the older
14
+ * binary via PATH ordering.
15
+ *
16
+ * Behavior contract (PRD AC6 / AC7 / AC8 / AC9):
17
+ * - AC6: ≥ 2 peaks-loop binaries with different versions → warning
18
+ * - AC7: warning severity only, doctor exit 0 (no other check
19
+ * flipped to error)
20
+ * - AC8: drift is scoped to peaks-loop (`package.json#name ===
21
+ * 'peaks-loop'`); sibling npm tools are NOT flagged
22
+ * - AC9: cross-platform — uses `process.env.PATH` + `path.delimiter`
23
+ * + binary naming `peaks` / `peaks.cmd` / `peaks.ps1`
24
+ *
25
+ * Pure `inspectMultiBinaryDrift` helper is exported so tests can drive
26
+ * the filesystem walk without monkey-patching `process.env.PATH` or
27
+ * `realpathSync`. Every probe call is wrapped in try/catch (read-only,
28
+ * must not throw across the doctor boundary).
29
+ */
30
+ import type { DoctorCheckPlugin, MultiBinaryDriftInspection } from '../types.js';
31
+ /**
32
+ * Local record shape — same as the canonical
33
+ * `MultiBinaryDriftInspection.binaries[number]`. Re-declared so the
34
+ * helper signature carries the concrete shape (the canonical
35
+ * `MultiBinaryDriftInspection` widens `version` + `installDate` to
36
+ * `string | null` so external consumers do not depend on the
37
+ * field being nullable).
38
+ */
39
+ export type PeaksBinaryRecord = {
40
+ readonly path: string;
41
+ readonly version: string | null;
42
+ readonly installDate: string | null;
43
+ readonly realpath: string;
44
+ };
45
+ /**
46
+ * Pure helper. Inspects `process.env.PATH` (or the injected
47
+ * `pathEnv`) for `peaks-loop` binaries. Each found binary is walked
48
+ * to its package.json via `realpathSync` so symlinks / npm shims /
49
+ * `peaks.cmd` / `peaks.ps1` all collapse to the same dedupe key.
50
+ *
51
+ * Exported so tests can drive the filesystem walk without monkey-
52
+ * patching `process.env.PATH` or `realpathSync`.
53
+ */
54
+ export declare function inspectMultiBinaryDrift(opts?: {
55
+ pathEnv?: string;
56
+ envReader?: (key: string) => string | undefined;
57
+ binaryExists?: (candidate: string) => boolean;
58
+ binaryRealpath?: (candidate: string) => string;
59
+ packageJsonReader?: (path: string) => Buffer | string | null;
60
+ }): MultiBinaryDriftInspection;
61
+ export declare const check: DoctorCheckPlugin;
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Check: PATH-scoped `peaks-loop` binary drift
3
+ * (`build:multi-binary-drift`).
4
+ *
5
+ * Slice 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
6
+ * (G3/G4). When more than one `peaks-loop` binary is discoverable on
7
+ * `process.env.PATH` AND the discovered versions disagree, the doctor
8
+ * emits a `PEAKS_MULTI_BINARY_DRIFT` warning. The user-reported
9
+ * production symptom — `peaks` resolving to an old version (e.g.
10
+ * 3.1.2 on `%AppData%\Roaming\npm`) while the freshly installed
11
+ * binary on another PATH entry (e.g. `C:\nvm4w\nodejs\peaks` 4.0.12)
12
+ * is what the user just bumped — produces "Hook JSON output
13
+ * validation failed" when the IDE statusline hook inherits the older
14
+ * binary via PATH ordering.
15
+ *
16
+ * Behavior contract (PRD AC6 / AC7 / AC8 / AC9):
17
+ * - AC6: ≥ 2 peaks-loop binaries with different versions → warning
18
+ * - AC7: warning severity only, doctor exit 0 (no other check
19
+ * flipped to error)
20
+ * - AC8: drift is scoped to peaks-loop (`package.json#name ===
21
+ * 'peaks-loop'`); sibling npm tools are NOT flagged
22
+ * - AC9: cross-platform — uses `process.env.PATH` + `path.delimiter`
23
+ * + binary naming `peaks` / `peaks.cmd` / `peaks.ps1`
24
+ *
25
+ * Pure `inspectMultiBinaryDrift` helper is exported so tests can drive
26
+ * the filesystem walk without monkey-patching `process.env.PATH` or
27
+ * `realpathSync`. Every probe call is wrapped in try/catch (read-only,
28
+ * must not throw across the doctor boundary).
29
+ */
30
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
31
+ import { delimiter as pathDelimiter, join } from 'node:path';
32
+ import { getErrorMessage } from 'peaks-loop-shared/result';
33
+ /**
34
+ * Pure helper. Inspects `process.env.PATH` (or the injected
35
+ * `pathEnv`) for `peaks-loop` binaries. Each found binary is walked
36
+ * to its package.json via `realpathSync` so symlinks / npm shims /
37
+ * `peaks.cmd` / `peaks.ps1` all collapse to the same dedupe key.
38
+ *
39
+ * Exported so tests can drive the filesystem walk without monkey-
40
+ * patching `process.env.PATH` or `realpathSync`.
41
+ */
42
+ export function inspectMultiBinaryDrift(opts) {
43
+ const envReader = opts?.envReader ?? ((k) => process.env[k]);
44
+ const exists = opts?.binaryExists ?? ((p) => {
45
+ try {
46
+ return existsSync(p);
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ });
52
+ const realpath = opts?.binaryRealpath ?? ((p) => {
53
+ try {
54
+ return realpathSync(p);
55
+ }
56
+ catch {
57
+ return p;
58
+ }
59
+ });
60
+ const reader = opts?.packageJsonReader ?? ((p) => {
61
+ try {
62
+ return readFileSync(p);
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ });
68
+ const pathEnv = opts?.pathEnv ?? envReader('PATH') ?? '';
69
+ if (pathEnv.length === 0) {
70
+ return { binaries: [], driftDetected: false, uniqueVersions: [] };
71
+ }
72
+ const dirs = pathEnv.split(pathDelimiter).filter((d) => d.length > 0);
73
+ const seen = new Map();
74
+ for (const dir of dirs) {
75
+ const candidates = candidateBinaryNames(dir);
76
+ for (const candidate of candidates) {
77
+ if (!exists(candidate))
78
+ continue;
79
+ const rp = realpath(candidate);
80
+ if (seen.has(rp))
81
+ continue;
82
+ const pkgPath = locatePackageJson(rp, candidate);
83
+ const record = readBinaryRecord(candidate, rp, pkgPath, reader);
84
+ seen.set(rp, record);
85
+ }
86
+ }
87
+ const binaries = Array.from(seen.values());
88
+ const uniqueVersions = dedupeVersions(binaries.map((b) => b.version));
89
+ return {
90
+ binaries,
91
+ driftDetected: uniqueVersions.length >= 2,
92
+ uniqueVersions
93
+ };
94
+ }
95
+ /**
96
+ * Cross-platform candidate names. Windows shims the executable as
97
+ * `peaks.cmd` and `peaks.ps1` (npm writes both); POSIX names the
98
+ * binary `peaks`. We probe all three names on every platform —
99
+ * probing a non-existent file is a no-op, so cross-list probing is
100
+ * safe.
101
+ */
102
+ function candidateBinaryNames(dir) {
103
+ return [join(dir, 'peaks'), join(dir, 'peaks.cmd'), join(dir, 'peaks.ps1')];
104
+ }
105
+ /**
106
+ * Walk from the binary to its `node_modules/peaks-loop/package.json`.
107
+ *
108
+ * Common layouts handled:
109
+ *
110
+ * A. `<root>/bin/peaks` (POSIX npm global / nvm) → parent is
111
+ * `<root>/bin/`, parent's parent is `<root>/`. package.json at
112
+ * `<root>/package.json`.
113
+ * B. `<root>/peaks` or `<root>/peaks.cmd` (Windows npm global shim)
114
+ * → parent is `<root>/`. package.json at `<root>/package.json`,
115
+ * OR `<root>/../node_modules/peaks-loop/package.json`.
116
+ * C. `<root>/node_modules/.bin/peaks` (local install) → parent is
117
+ * `node_modules/.bin/`, parent's parent is `node_modules/`.
118
+ * package.json at `<root>/node_modules/peaks-loop/package.json`.
119
+ *
120
+ * The locator walks up from the binary path (realpathPath first,
121
+ * then originalCandidate) and checks for the canonical
122
+ * `node_modules/peaks-loop/package.json` and a `package.json` at each
123
+ * ancestor. The probe is read-only and stops at the first hit. We do
124
+ * NOT trust `package.json` without verifying its `name === 'peaks-loop'`
125
+ * later (see {@link readBinaryRecord}) — a sibling package's
126
+ * `package.json` is filtered out at read time.
127
+ */
128
+ function locatePackageJson(realpathPath, originalCandidate) {
129
+ const ancestors = new Set();
130
+ for (const start of [realpathPath, originalCandidate]) {
131
+ let current = start;
132
+ for (let depth = 0; depth < 8; depth++) {
133
+ const parent = join(current, '..');
134
+ if (parent === current)
135
+ break;
136
+ ancestors.add(parent);
137
+ current = parent;
138
+ }
139
+ }
140
+ for (const ancestor of ancestors) {
141
+ const nmPkg = join(ancestor, 'node_modules', 'peaks-loop', 'package.json');
142
+ if (existsSafe(nmPkg))
143
+ return nmPkg;
144
+ }
145
+ for (const ancestor of ancestors) {
146
+ const pkg = join(ancestor, 'package.json');
147
+ if (existsSafe(pkg))
148
+ return pkg;
149
+ }
150
+ return null;
151
+ }
152
+ function existsSafe(p) {
153
+ try {
154
+ return existsSync(p);
155
+ }
156
+ catch {
157
+ return false;
158
+ }
159
+ }
160
+ function readBinaryRecord(candidate, realpathPath, pkgPath, reader) {
161
+ if (pkgPath === null) {
162
+ return {
163
+ path: candidate,
164
+ version: null,
165
+ installDate: null,
166
+ realpath: realpathPath
167
+ };
168
+ }
169
+ let version = null;
170
+ try {
171
+ const raw = reader(pkgPath);
172
+ if (raw !== null) {
173
+ const text = Buffer.isBuffer(raw) ? raw.toString('utf8') : raw;
174
+ const parsed = JSON.parse(text);
175
+ if (parsed.name === 'peaks-loop' && typeof parsed.version === 'string') {
176
+ version = parsed.version;
177
+ }
178
+ }
179
+ }
180
+ catch {
181
+ version = null;
182
+ }
183
+ let installDate = null;
184
+ try {
185
+ const stat = statSync(pkgPath);
186
+ installDate = stat.mtime.toISOString();
187
+ }
188
+ catch {
189
+ installDate = null;
190
+ }
191
+ return {
192
+ path: candidate,
193
+ version,
194
+ installDate,
195
+ realpath: realpathPath
196
+ };
197
+ }
198
+ /**
199
+ * `version === null` means we could not read the package.json (or
200
+ * its `name` did not equal `peaks-loop`). Those records stay in
201
+ * `binaries` for the report but do NOT contribute to
202
+ * `uniqueVersions` — including null would falsely trigger drift
203
+ * detection when the only failures are unreadable binaries.
204
+ */
205
+ function dedupeVersions(versions) {
206
+ const seen = new Set();
207
+ const out = [];
208
+ for (const v of versions) {
209
+ if (typeof v !== 'string' || v.length === 0)
210
+ continue;
211
+ if (seen.has(v))
212
+ continue;
213
+ seen.add(v);
214
+ out.push(v);
215
+ }
216
+ return out;
217
+ }
218
+ function run({ options }) {
219
+ const probe = options.multiBinaryDriftProbe ?? (() => inspectMultiBinaryDrift());
220
+ try {
221
+ const result = probe();
222
+ if (result.binaries.length === 0) {
223
+ return [{
224
+ id: 'build:multi-binary-drift',
225
+ ok: true,
226
+ message: 'no peaks-loop binary on PATH (statusLine may be unavailable)'
227
+ }];
228
+ }
229
+ if (!result.driftDetected) {
230
+ return [{
231
+ id: 'build:multi-binary-drift',
232
+ ok: true,
233
+ message: result.binaries.length === 1
234
+ ? `single peaks-loop binary on PATH at ${result.binaries[0].path} (version ${result.uniqueVersions[0] ?? 'unknown'})`
235
+ : `${result.binaries.length} peaks-loop binaries on PATH all at version ${result.uniqueVersions[0] ?? 'unknown'}`
236
+ }];
237
+ }
238
+ // Drift detected — WARN-ONLY. AC7: doctor still exit 0 unless
239
+ // another check escalates to error. `ok: false` so the operator
240
+ // sees the finding in the JSON report AND the check carries
241
+ // `severity: 'warning'` so `buildReport` does NOT count it as a
242
+ // failure when computing `summary.ok`. Slice
243
+ // 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
244
+ // repair cycle landed the severity-aware summary so the
245
+ // previously-handwaved "future severity-aware summary can
246
+ // downgrade the doctor exit code" actually fires.
247
+ const binaryTable = result.binaries
248
+ .map((b) => ` ${b.path} version=${b.version ?? 'unknown'} date=${b.installDate ?? 'unknown'}`)
249
+ .join('\n');
250
+ return [{
251
+ id: 'build:multi-binary-drift',
252
+ ok: false,
253
+ severity: 'warning',
254
+ message: `PEAKS_MULTI_BINARY_DRIFT: ${result.uniqueVersions.length} distinct peaks-loop versions on PATH (${result.uniqueVersions.join(', ')}). Run \`npm uninstall -g peaks-loop\` on the stale entries, or reorder PATH so the desired binary resolves first. Binaries:\n${binaryTable}`
255
+ }];
256
+ }
257
+ catch (error) {
258
+ return [{
259
+ id: 'build:multi-binary-drift',
260
+ ok: false,
261
+ message: `multi-binary drift check failed: ${getErrorMessage(error)}`
262
+ }];
263
+ }
264
+ }
265
+ export const check = {
266
+ name: 'multi-binary-drift',
267
+ run
268
+ };
@@ -43,6 +43,7 @@ import { check as statuslineInstall } from './checks/statusline-install.js';
43
43
  import { check as statuslineRuntime } from './checks/statusline-runtime.js';
44
44
  import { check as codegraphCapability } from './checks/codegraph-capability.js';
45
45
  import { check as distSourceVersion } from './checks/dist-source-version.js';
46
+ import { check as multiBinaryDrift } from './checks/multi-binary-drift.js';
46
47
  import { check as workspaceLayout } from './checks/workspace-layout.js';
47
48
  import { check as gateguardConflict } from './checks/gateguard-conflict.js';
48
49
  import { check as checkIdSchema } from './checks/check-id-schema.js';
@@ -69,6 +70,7 @@ export const PLUGINS = [
69
70
  statuslineRuntime, // id "statusline:runtime"
70
71
  codegraphCapability, // id "capability:codegraph"
71
72
  distSourceVersion, // id "build:dist-version-matches-source"
73
+ multiBinaryDrift, // id "build:multi-binary-drift"
72
74
  workspaceLayout, // id "build:workspace-layout-canonical"
73
75
  gateguardConflict, // id "integration:gateguard-peaks-conflict"
74
76
  checkIdSchema, // id "doctor-self:check-id-pattern"
@@ -2,11 +2,19 @@
2
2
  * Report aggregator: convert the accumulated `DoctorCheck[]` into the
3
3
  * public `DoctorReport` shape (checks + summary).
4
4
  *
5
- * The summary is a pure reduction over the checks — it counts the
6
- * passing and failing rows, derives `ok = failed === 0`, and
7
- * returns the `{ checks, summary }` tuple. Kept in its own module
8
- * so the dispatcher (`index.ts`) stays a thin orchestration loop
9
- * and the aggregation rule has a single test surface.
5
+ * The summary is a pure reduction over the checks — it separates
6
+ * `error`-severity findings from `warning`-severity findings, derives
7
+ * `ok = errors === 0`, and returns the `{ checks, summary }` tuple.
8
+ * Kept in its own module so the dispatcher (`index.ts`) stays a thin
9
+ * orchestration loop and the aggregation rule has a single test surface.
10
+ *
11
+ * Severity-aware aggregation (slice
12
+ * 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
13
+ * repair cycle): a check with `severity: 'warning'` reports `ok: false`
14
+ * (so operators see the finding in the JSON envelope) but does NOT
15
+ * flip `summary.ok` and therefore does NOT flip the doctor exit code.
16
+ * Genuine failures (severity omitted, or `severity: 'error'`) behave
17
+ * the same as the pre-slice `failed === 0` rule.
10
18
  */
11
19
  import type { DoctorCheck, DoctorReport } from '../types.js';
12
20
  /**
@@ -2,11 +2,19 @@
2
2
  * Report aggregator: convert the accumulated `DoctorCheck[]` into the
3
3
  * public `DoctorReport` shape (checks + summary).
4
4
  *
5
- * The summary is a pure reduction over the checks — it counts the
6
- * passing and failing rows, derives `ok = failed === 0`, and
7
- * returns the `{ checks, summary }` tuple. Kept in its own module
8
- * so the dispatcher (`index.ts`) stays a thin orchestration loop
9
- * and the aggregation rule has a single test surface.
5
+ * The summary is a pure reduction over the checks — it separates
6
+ * `error`-severity findings from `warning`-severity findings, derives
7
+ * `ok = errors === 0`, and returns the `{ checks, summary }` tuple.
8
+ * Kept in its own module so the dispatcher (`index.ts`) stays a thin
9
+ * orchestration loop and the aggregation rule has a single test surface.
10
+ *
11
+ * Severity-aware aggregation (slice
12
+ * 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
13
+ * repair cycle): a check with `severity: 'warning'` reports `ok: false`
14
+ * (so operators see the finding in the JSON envelope) but does NOT
15
+ * flip `summary.ok` and therefore does NOT flip the doctor exit code.
16
+ * Genuine failures (severity omitted, or `severity: 'error'`) behave
17
+ * the same as the pre-slice `failed === 0` rule.
10
18
  */
11
19
  /**
12
20
  * Build the final report from the accumulated checks.
@@ -17,13 +25,15 @@
17
25
  * report shape the public API exposes.
18
26
  */
19
27
  export function buildReport(checks) {
20
- const failed = checks.filter((check) => !check.ok).length;
28
+ const errors = checks.filter((check) => !check.ok && check.severity !== 'warning').length;
29
+ const warnings = checks.filter((check) => !check.ok && check.severity === 'warning').length;
21
30
  return {
22
31
  checks: [...checks],
23
32
  summary: {
24
- ok: failed === 0,
25
- passed: checks.length - failed,
26
- failed
33
+ ok: errors === 0,
34
+ passed: checks.length - errors - warnings,
35
+ failed: errors,
36
+ warnings
27
37
  }
28
38
  };
29
39
  }