peaks-loop 4.0.11 → 4.0.13

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 (40) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/cli/commands/code-commands.js +2 -0
  3. package/dist/cli/commands/code-orchestrator-can-do.d.ts +17 -0
  4. package/dist/cli/commands/code-orchestrator-can-do.js +75 -0
  5. package/dist/cli/commands/core/doctor-command.js +26 -3
  6. package/dist/cli/commands/workspace/reconcile-command.js +5 -2
  7. package/dist/services/code/orchestrator-can-do.d.ts +114 -0
  8. package/dist/services/code/orchestrator-can-do.js +205 -0
  9. package/dist/services/doctor/doctor-service/checks/multi-binary-drift.d.ts +61 -0
  10. package/dist/services/doctor/doctor-service/checks/multi-binary-drift.js +268 -0
  11. package/dist/services/doctor/doctor-service/checks/skill-presence.js +1 -1
  12. package/dist/services/doctor/doctor-service/checks/workspace-layout.d.ts +5 -2
  13. package/dist/services/doctor/doctor-service/checks/workspace-layout.js +70 -9
  14. package/dist/services/doctor/doctor-service/plugin-registry.js +2 -0
  15. package/dist/services/doctor/doctor-service/report/final-summary.d.ts +13 -5
  16. package/dist/services/doctor/doctor-service/report/final-summary.js +19 -9
  17. package/dist/services/doctor/doctor-service/types.d.ts +64 -0
  18. package/dist/services/doctor/doctor-service.d.ts +2 -1
  19. package/dist/services/doctor/doctor-service.js +1 -0
  20. package/dist/services/doctor/index.d.ts +1 -1
  21. package/dist/services/doctor/index.js +1 -1
  22. package/dist/services/hooks/presence-marker-detector.d.ts +7 -5
  23. package/dist/services/hooks/presence-marker-detector.js +45 -85
  24. package/dist/services/migration/v2-10-to-v2-11-service.js +1 -3
  25. package/dist/services/sc/sc-service.d.ts +11 -7
  26. package/dist/services/sc/sc-service.js +29 -26
  27. package/dist/services/session/binding-store.d.ts +6 -6
  28. package/dist/services/skills/hooks-settings-service.d.ts +1 -1
  29. package/dist/services/skills/hooks-settings-service.js +1 -1
  30. package/dist/services/skills/skill-presence-service.d.ts +0 -1
  31. package/dist/services/skills/skill-presence-service.js +112 -102
  32. package/dist/services/skills/skill-statusline-renderer.d.ts +3 -1
  33. package/dist/services/skills/skill-statusline-renderer.js +37 -1
  34. package/dist/services/skills/skill-statusline-service.d.ts +14 -0
  35. package/dist/services/skills/skill-statusline-service.js +109 -53
  36. package/dist/services/skills/skill-statusline-sid-suffix.d.ts +55 -0
  37. package/dist/services/skills/skill-statusline-sid-suffix.js +65 -0
  38. package/package.json +4 -4
  39. package/skills/peaks-code/SKILL.md +6 -0
  40. package/skills/peaks-code/references/session-overload-signal-index.md +60 -0
@@ -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
+ };
@@ -33,7 +33,7 @@ function run({ options, presence }) {
33
33
  {
34
34
  id: 'skill-presence:current',
35
35
  ok: true,
36
- message: 'No active Peaks skill presence (.peaks/.active-skill.json absent or invalid)'
36
+ message: 'No active Peaks skill presence (no canonical lease under .peaks/_runtime/<sid>/leases/)'
37
37
  },
38
38
  {
39
39
  id: 'skill-presence:freshness',
@@ -4,8 +4,10 @@
4
4
  *
5
5
  * Build-hygiene check that surfaces any leftover top-level session
6
6
  * dirs (`.peaks/<YYYY-MM-DD-session-<hex>>/`), the legacy runtime
7
- * dotfiles (`.peaks/.session.json`, `.peaks/.active-skill.json`),
8
- * OR per-change-id top-level dirs (`.peaks/NNN-YYYY-MM-DD-<slug>/`).
7
+ * dotfiles (`.peaks/.session.json`), the stale single-slot
8
+ * presence file (`.peaks/_runtime/active-skill.json`, the deprecated
9
+ * project-level marker removed in slice 4.0.11), OR per-change-id
10
+ * top-level dirs (`.peaks/NNN-YYYY-MM-DD-<slug>/`).
9
11
  *
10
12
  * The post-F3 canonical layout puts session dirs under
11
13
  * `.peaks/_runtime/<sid>/` and the runtime binding at
@@ -29,5 +31,6 @@ export declare function inspectWorkspaceLayout(opts: {
29
31
  topLevelScanner?: (root: string) => string[];
30
32
  dotfileScanner?: (root: string) => string[];
31
33
  perChangeIdScanner?: (root: string) => string[];
34
+ staleSingleSlotScanner?: (root: string) => string[];
32
35
  }): WorkspaceLayoutInspection;
33
36
  export declare const check: DoctorCheckPlugin;
@@ -4,8 +4,10 @@
4
4
  *
5
5
  * Build-hygiene check that surfaces any leftover top-level session
6
6
  * dirs (`.peaks/<YYYY-MM-DD-session-<hex>>/`), the legacy runtime
7
- * dotfiles (`.peaks/.session.json`, `.peaks/.active-skill.json`),
8
- * OR per-change-id top-level dirs (`.peaks/NNN-YYYY-MM-DD-<slug>/`).
7
+ * dotfiles (`.peaks/.session.json`), the stale single-slot
8
+ * presence file (`.peaks/_runtime/active-skill.json`, the deprecated
9
+ * project-level marker removed in slice 4.0.11), OR per-change-id
10
+ * top-level dirs (`.peaks/NNN-YYYY-MM-DD-<slug>/`).
9
11
  *
10
12
  * The post-F3 canonical layout puts session dirs under
11
13
  * `.peaks/_runtime/<sid>/` and the runtime binding at
@@ -31,7 +33,24 @@ const SESSION_DIR_PATTERN = /^\d{4}-\d{2}-\d{2}-session-[a-f0-9]+$/;
31
33
  * `0NN-session-...`, `YYYY-MM-DD-session-...`).
32
34
  */
33
35
  const PER_CHANGE_ID_PATTERN = /^\d{3}-\d{4}-\d{2}-\d{2}-[a-z][a-z0-9-]*[a-z0-9]$/;
34
- const LEGACY_DOTFILES = ['.session.json', '.active-skill.json'];
36
+ // Slice 4.0.11 statusline-sid-scoped-lease C: `.peaks/.active-skill.json`
37
+ // is REMOVED from LEGACY_DOTFILES (the file should not exist on
38
+ // healthy post-4.0.11 projects; treating it as a generic legacy
39
+ // dotfile is misleading). The new single-slot presence file
40
+ // `.peaks/_runtime/active-skill.json` is reported separately under
41
+ // `STALE_SINGLE_SLOT_FILES` so the operator gets a clear "stale
42
+ // single-slot presence" message instead of a generic legacy warning.
43
+ const LEGACY_DOTFILES = ['.session.json'];
44
+ // Top-level single-slot presence files that are stale after the
45
+ // sid-scoped lease projection (slice 4.0.8) shipped. Reported
46
+ // separately from LEGACY_DOTFILES so the doctor message names the
47
+ // specific issue ("stale single-slot presence") and the operator
48
+ // can delete the file. The canonical sid-scoped lease at
49
+ // `.peaks/_runtime/<sid>/leases/presence-*.json` is the source of truth.
50
+ const STALE_SINGLE_SLOT_FILES = [
51
+ 'active-skill.json',
52
+ '.active-skill.json'
53
+ ];
35
54
  /**
36
55
  * Pure helper that inspects the on-disk workspace layout for
37
56
  * post-F3-canonical violations. Exported so tests can drive the
@@ -42,10 +61,12 @@ export function inspectWorkspaceLayout(opts) {
42
61
  const topLevel = opts.topLevelScanner ?? defaultTopLevelSessionDirScanner;
43
62
  const dotfiles = opts.dotfileScanner ?? defaultLegacyDotfileScanner;
44
63
  const perChangeId = opts.perChangeIdScanner ?? defaultPerChangeIdDirScanner;
64
+ const staleSingleSlot = opts.staleSingleSlotScanner ?? defaultStaleSingleSlotScanner;
45
65
  return {
46
66
  topLevelSessionDirs: safeList(() => topLevel(opts.projectRoot)),
47
67
  legacyDotfiles: safeList(() => dotfiles(opts.projectRoot)),
48
- perChangeIdDirs: safeList(() => perChangeId(opts.projectRoot))
68
+ perChangeIdDirs: safeList(() => perChangeId(opts.projectRoot)),
69
+ staleSingleSlotFiles: safeList(() => staleSingleSlot(opts.projectRoot))
49
70
  };
50
71
  }
51
72
  function safeList(reader) {
@@ -103,6 +124,39 @@ function defaultLegacyDotfileScanner(projectRoot) {
103
124
  }
104
125
  return offenders;
105
126
  }
127
+ /**
128
+ * Slice 4.0.11 statusline-sid-scoped-lease C: surface the deprecated
129
+ * single-slot presence files (`.peaks/_runtime/active-skill.json` and
130
+ * the legacy `.peaks/.active-skill.json`) as a separate offender
131
+ * category. The canonical sid-scoped lease projection
132
+ * (`.peaks/_runtime/<sid>/leases/presence-*.json`) is the source of
133
+ * truth; these single-slot files should be deleted so the doctor
134
+ * report is clean.
135
+ *
136
+ * The reported path is the project-relative form so the doctor's
137
+ * human-readable output stays consistent with `legacyDotfiles`.
138
+ */
139
+ function defaultStaleSingleSlotScanner(projectRoot) {
140
+ const offenders = [];
141
+ const candidates = [
142
+ join(projectRoot, '.peaks', '_runtime', 'active-skill.json'),
143
+ join(projectRoot, '.peaks', '.active-skill.json')
144
+ ];
145
+ const reported = [
146
+ join('.peaks', '_runtime', 'active-skill.json'),
147
+ join('.peaks', '.active-skill.json')
148
+ ];
149
+ for (let i = 0; i < candidates.length; i++) {
150
+ const candidate = candidates[i];
151
+ const reportedPath = reported[i];
152
+ if (candidate === undefined || reportedPath === undefined)
153
+ continue;
154
+ if (existsSync(candidate)) {
155
+ offenders.push(reportedPath);
156
+ }
157
+ }
158
+ return offenders;
159
+ }
106
160
  function defaultPerChangeIdDirScanner(projectRoot) {
107
161
  const peaksRoot = join(projectRoot, '.peaks');
108
162
  if (!existsSync(peaksRoot))
@@ -136,7 +190,7 @@ function defaultPerChangeIdDirScanner(projectRoot) {
136
190
  function defaultWorkspaceLayoutProbe(projectRootResolver) {
137
191
  const projectRoot = projectRootResolver();
138
192
  if (projectRoot === null) {
139
- return { topLevelSessionDirs: [], legacyDotfiles: [], perChangeIdDirs: [] };
193
+ return { topLevelSessionDirs: [], legacyDotfiles: [], perChangeIdDirs: [], staleSingleSlotFiles: [] };
140
194
  }
141
195
  return inspectWorkspaceLayout({ projectRoot });
142
196
  }
@@ -148,22 +202,29 @@ function run({ options, projectRootResolver }) {
148
202
  // return a 2-field shape (no perChangeIdDirs). Treat missing
149
203
  // field as empty.
150
204
  const perChangeIdDirs = layout.perChangeIdDirs ?? [];
151
- if (layout.topLevelSessionDirs.length === 0 && layout.legacyDotfiles.length === 0 && perChangeIdDirs.length === 0) {
205
+ // Slice 4.0.11: probes injected by pre-4.0.11 tests also omit
206
+ // staleSingleSlotFiles. Treat missing field as empty.
207
+ const staleSingleSlotFiles = layout.staleSingleSlotFiles ?? [];
208
+ if (layout.topLevelSessionDirs.length === 0 &&
209
+ layout.legacyDotfiles.length === 0 &&
210
+ perChangeIdDirs.length === 0 &&
211
+ staleSingleSlotFiles.length === 0) {
152
212
  return [{
153
213
  id: 'build:workspace-layout-canonical',
154
214
  ok: true,
155
- message: 'Workspace layout is canonical: no top-level session dirs, no legacy runtime dotfiles, no per-change-id top-level dirs'
215
+ message: 'Workspace layout is canonical: no top-level session dirs, no legacy runtime dotfiles, no per-change-id top-level dirs, no stale single-slot presence files'
156
216
  }];
157
217
  }
158
218
  const offenders = [
159
219
  ...layout.topLevelSessionDirs.map((p) => `top-level session dir: ${p}`),
160
220
  ...layout.legacyDotfiles.map((p) => `legacy dotfile: ${p}`),
161
- ...perChangeIdDirs.map((p) => `per-change-id top-level dir: ${p}`)
221
+ ...perChangeIdDirs.map((p) => `per-change-id top-level dir: ${p}`),
222
+ ...staleSingleSlotFiles.map((p) => `stale single-slot presence: ${p}`)
162
223
  ];
163
224
  return [{
164
225
  id: 'build:workspace-layout-canonical',
165
226
  ok: false,
166
- message: `Workspace layout is not canonical. Offenders: ${offenders.join('; ')}. Run \`peaks workspace migrate --to-runtime --project <repo> --apply\` to consolidate.`
227
+ message: `Workspace layout is not canonical. Offenders: ${offenders.join('; ')}. Run \`peaks workspace migrate --to-runtime --project <repo> --apply\` to consolidate; delete stale single-slot presence files manually after migration.`
167
228
  }];
168
229
  }
169
230
  catch (error) {
@@ -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
  }
@@ -12,10 +12,32 @@
12
12
  * flow through the same names so the main package's `import { runDoctor }`
13
13
  * keeps working unchanged.
14
14
  */
15
+ /**
16
+ * Severity for a single doctor check. `'error'` flips the exit code
17
+ * (via `buildReport` and the CLI dispatcher); `'warning'` surfaces
18
+ * the finding in the JSON envelope (`ok: false` + `severity: 'warning'`)
19
+ * but does NOT escalate the doctor exit code to 1.
20
+ *
21
+ * Optional in the type for back-compat with older check plugins that
22
+ * pre-date the severity-aware summary (slice
23
+ * 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
24
+ * repair cycle). When omitted, the dispatcher treats the check as
25
+ * `'error'` — i.e. `ok: false` escalates the exit code the same way
26
+ * it did before this slice.
27
+ */
28
+ export type DoctorCheckSeverity = 'error' | 'warning';
15
29
  export type DoctorCheck = {
16
30
  id: string;
17
31
  ok: boolean;
18
32
  message: string;
33
+ /**
34
+ * Optional severity tag. When `'warning'`, the check still reports
35
+ * `ok: false` (so operators see the finding in the JSON envelope)
36
+ * but `buildReport` does NOT count it as a failure for the
37
+ * `summary.ok` exit-code calculation. Default-omitted checks
38
+ * behave as `'error'`.
39
+ */
40
+ severity?: DoctorCheckSeverity;
19
41
  };
20
42
  export type DoctorReport = {
21
43
  checks: DoctorCheck[];
@@ -23,6 +45,15 @@ export type DoctorReport = {
23
45
  ok: boolean;
24
46
  passed: number;
25
47
  failed: number;
48
+ /**
49
+ * Severity-aware summary (slice
50
+ * 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
51
+ * repair cycle): count of findings tagged
52
+ * `severity: 'warning'`. Warnings surface in the JSON envelope
53
+ * (`ok: false`) but do NOT flip `summary.ok` and therefore do
54
+ * NOT flip the doctor exit code.
55
+ */
56
+ warnings: number;
26
57
  };
27
58
  };
28
59
  export type CodegraphCapabilityProbe = {
@@ -38,6 +69,24 @@ export type DistVersionComparison = {
38
69
  distReadable: boolean;
39
70
  };
40
71
  export type DistVersionProbe = () => DistVersionComparison;
72
+ /**
73
+ * Slice 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
74
+ * (G3/G4) — probe for the multi-binary drift check. The probe returns
75
+ * the discovered peaks-loop binaries on PATH (with their resolved
76
+ * version + install date) and a `driftDetected` flag. Injected so tests
77
+ * can drive the filesystem walk without monkey-patching
78
+ * `process.env.PATH` or `realpathSync`.
79
+ */
80
+ export type MultiBinaryDriftInspection = {
81
+ readonly binaries: ReadonlyArray<{
82
+ readonly path: string;
83
+ readonly version: string | null;
84
+ readonly installDate: string | null;
85
+ }>;
86
+ readonly driftDetected: boolean;
87
+ readonly uniqueVersions: ReadonlyArray<string>;
88
+ };
89
+ export type MultiBinaryDriftProbe = () => MultiBinaryDriftInspection;
41
90
  export type WorkspaceLayoutInspection = {
42
91
  topLevelSessionDirs: string[];
43
92
  legacyDotfiles: string[];
@@ -55,6 +104,15 @@ export type WorkspaceLayoutInspection = {
55
104
  * to an empty array when the field is missing.
56
105
  */
57
106
  perChangeIdDirs?: string[];
107
+ /**
108
+ * Slice 4.0.11 statusline-sid-scoped-lease C — single-slot
109
+ * presence files that are stale after the sid-scoped lease
110
+ * projection shipped in 4.0.8. Reported separately from
111
+ * `legacyDotfiles` so the doctor message names the specific
112
+ * "stale single-slot presence" condition. Optional for back-compat
113
+ * with probes injected by older tests.
114
+ */
115
+ staleSingleSlotFiles?: string[];
58
116
  };
59
117
  export type WorkspaceLayoutProbe = () => WorkspaceLayoutInspection;
60
118
  /**
@@ -141,6 +199,12 @@ export type DoctorOptions = {
141
199
  platform?: NodeJS.Platform;
142
200
  /** Injected for the build:dist-version-matches-source check (defaults to compareDistVersion on disk). */
143
201
  distVersionProbe?: DistVersionProbe;
202
+ /**
203
+ * Slice 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
204
+ * (G3/G4) — injected for the build:multi-binary-drift check (defaults
205
+ * to inspectMultiBinaryDrift against `process.env.PATH`).
206
+ */
207
+ multiBinaryDriftProbe?: MultiBinaryDriftProbe;
144
208
  /** Injected for the build:workspace-layout-canonical check (defaults to inspectWorkspaceLayout on disk). */
145
209
  workspaceLayoutProbe?: WorkspaceLayoutProbe;
146
210
  /** Injected for the integration:gateguard-peaks-conflict check (defaults to defaultGateguardProbe on disk). */
@@ -14,5 +14,6 @@ export * from './doctor-service/index.js';
14
14
  export { compareDistVersion } from './doctor-service/checks/dist-source-version.js';
15
15
  export { inspectWorkspaceLayout } from './doctor-service/checks/workspace-layout.js';
16
16
  export { collectGateguardEntries } from './doctor-service/checks/gateguard-conflict.js';
17
+ export { inspectMultiBinaryDrift, type PeaksBinaryRecord } from './doctor-service/checks/multi-binary-drift.js';
17
18
  export { PLUGINS } from './doctor-service/plugin-registry.js';
18
- export type { DoctorCheck, DoctorReport, DoctorOptions, DoctorCheckPlugin, DoctorContext, DoctorSkillEntry, DoctorSkillLoadFailure, DoctorSkillsResult, DoctorSkillPresence, CodegraphCapabilityProbe, DistVersionComparison, DistVersionProbe, WorkspaceLayoutInspection, WorkspaceLayoutProbe, GateguardHookLocation, GateguardProbeResult, GateguardProbe } from './doctor-service/types.js';
19
+ export type { DoctorCheck, DoctorReport, DoctorOptions, DoctorCheckPlugin, DoctorContext, DoctorSkillEntry, DoctorSkillLoadFailure, DoctorSkillsResult, DoctorSkillPresence, CodegraphCapabilityProbe, DistVersionComparison, DistVersionProbe, MultiBinaryDriftInspection, MultiBinaryDriftProbe, WorkspaceLayoutInspection, WorkspaceLayoutProbe, GateguardHookLocation, GateguardProbeResult, GateguardProbe } from './doctor-service/types.js';
@@ -19,6 +19,7 @@ export * from './doctor-service/index.js';
19
19
  export { compareDistVersion } from './doctor-service/checks/dist-source-version.js';
20
20
  export { inspectWorkspaceLayout } from './doctor-service/checks/workspace-layout.js';
21
21
  export { collectGateguardEntries } from './doctor-service/checks/gateguard-conflict.js';
22
+ export { inspectMultiBinaryDrift } from './doctor-service/checks/multi-binary-drift.js';
22
23
  // Re-export the fixed plugin registry so characterization tests and
23
24
  // future CLI surfaces (e.g. `peaks doctor --list-checks`) can enumerate
24
25
  // the check pipeline without reaching into a private module path.
@@ -19,4 +19,4 @@
19
19
  * See .peaks/_runtime/2026-07-17-session-1d5ac0/rd/slice-3b-doctor.md
20
20
  * for the slice-3b Option C decision rationale.
21
21
  */
22
- export { runDoctor, isWorkspaceInitializedAt, compareDistVersion, inspectWorkspaceLayout, collectGateguardEntries, type DoctorCheck, type DoctorReport, type DoctorOptions, type CodegraphCapabilityProbe, type DistVersionComparison, type DistVersionProbe, type WorkspaceLayoutInspection, type WorkspaceLayoutProbe, type GateguardHookLocation, type GateguardProbeResult, type GateguardProbe, } from './doctor-service.js';
22
+ export { runDoctor, isWorkspaceInitializedAt, compareDistVersion, inspectWorkspaceLayout, inspectMultiBinaryDrift, collectGateguardEntries, type DoctorCheck, type DoctorReport, type DoctorOptions, type CodegraphCapabilityProbe, type DistVersionComparison, type DistVersionProbe, type MultiBinaryDriftInspection, type MultiBinaryDriftProbe, type PeaksBinaryRecord, type WorkspaceLayoutInspection, type WorkspaceLayoutProbe, type GateguardHookLocation, type GateguardProbeResult, type GateguardProbe, } from './doctor-service.js';
@@ -19,4 +19,4 @@
19
19
  * See .peaks/_runtime/2026-07-17-session-1d5ac0/rd/slice-3b-doctor.md
20
20
  * for the slice-3b Option C decision rationale.
21
21
  */
22
- export { runDoctor, isWorkspaceInitializedAt, compareDistVersion, inspectWorkspaceLayout, collectGateguardEntries, } from './doctor-service.js';
22
+ export { runDoctor, isWorkspaceInitializedAt, compareDistVersion, inspectWorkspaceLayout, inspectMultiBinaryDrift, collectGateguardEntries, } from './doctor-service.js';