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.
- package/CHANGELOG.md +47 -0
- package/dist/cli/commands/code-commands.js +2 -0
- package/dist/cli/commands/code-orchestrator-can-do.d.ts +17 -0
- package/dist/cli/commands/code-orchestrator-can-do.js +75 -0
- package/dist/cli/commands/core/doctor-command.js +26 -3
- package/dist/cli/commands/workspace/reconcile-command.js +5 -2
- package/dist/services/code/orchestrator-can-do.d.ts +114 -0
- package/dist/services/code/orchestrator-can-do.js +205 -0
- package/dist/services/doctor/doctor-service/checks/multi-binary-drift.d.ts +61 -0
- package/dist/services/doctor/doctor-service/checks/multi-binary-drift.js +268 -0
- package/dist/services/doctor/doctor-service/checks/skill-presence.js +1 -1
- package/dist/services/doctor/doctor-service/checks/workspace-layout.d.ts +5 -2
- package/dist/services/doctor/doctor-service/checks/workspace-layout.js +70 -9
- package/dist/services/doctor/doctor-service/plugin-registry.js +2 -0
- package/dist/services/doctor/doctor-service/report/final-summary.d.ts +13 -5
- package/dist/services/doctor/doctor-service/report/final-summary.js +19 -9
- package/dist/services/doctor/doctor-service/types.d.ts +64 -0
- package/dist/services/doctor/doctor-service.d.ts +2 -1
- package/dist/services/doctor/doctor-service.js +1 -0
- package/dist/services/doctor/index.d.ts +1 -1
- package/dist/services/doctor/index.js +1 -1
- package/dist/services/hooks/presence-marker-detector.d.ts +7 -5
- package/dist/services/hooks/presence-marker-detector.js +45 -85
- package/dist/services/migration/v2-10-to-v2-11-service.js +1 -3
- package/dist/services/sc/sc-service.d.ts +11 -7
- package/dist/services/sc/sc-service.js +29 -26
- package/dist/services/session/binding-store.d.ts +6 -6
- package/dist/services/skills/hooks-settings-service.d.ts +1 -1
- package/dist/services/skills/hooks-settings-service.js +1 -1
- package/dist/services/skills/skill-presence-service.d.ts +0 -1
- package/dist/services/skills/skill-presence-service.js +112 -102
- package/dist/services/skills/skill-statusline-renderer.d.ts +3 -1
- package/dist/services/skills/skill-statusline-renderer.js +37 -1
- package/dist/services/skills/skill-statusline-service.d.ts +14 -0
- package/dist/services/skills/skill-statusline-service.js +109 -53
- package/dist/services/skills/skill-statusline-sid-suffix.d.ts +55 -0
- package/dist/services/skills/skill-statusline-sid-suffix.js +65 -0
- package/package.json +4 -4
- package/skills/peaks-code/SKILL.md +6 -0
- 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 (
|
|
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
|
|
8
|
-
*
|
|
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
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
6
|
-
*
|
|
7
|
-
* returns the `{ checks, summary }` tuple.
|
|
8
|
-
* so the dispatcher (`index.ts`) stays a thin
|
|
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
|
|
6
|
-
*
|
|
7
|
-
* returns the `{ checks, summary }` tuple.
|
|
8
|
-
* so the dispatcher (`index.ts`) stays a thin
|
|
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
|
|
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:
|
|
25
|
-
passed: checks.length -
|
|
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';
|