peaks-loop 4.0.12 → 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 +17 -0
- package/dist/cli/commands/core/doctor-command.js +26 -3
- 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/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 +55 -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/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 +40 -16
- 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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.0.13 — 2026-08-06 (statusline empty-render fix + sid-only marker + multi-binary drift guard)
|
|
4
|
+
|
|
5
|
+
**Statusline callerId fallback + active `[short-sid]` suffix** (slice `2026-08-05-statusline-empty-render-and-short-sid-suffix`, commit `4be37d08`):
|
|
6
|
+
- `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).
|
|
7
|
+
- `src/services/skills/skill-statusline-renderer.ts` introduces `formatShortSid(sessionId) = sessionId.split('-').pop()` helper. Active state now renders `Peaks ● peaks-code → peaks-loop [3fe1be]`.
|
|
8
|
+
- 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.
|
|
9
|
+
- Fixes the "new session statusline empty despite active peaks-code lease" bug (root cause: outerSessionId drift between caller shell and lease's callerId).
|
|
10
|
+
|
|
11
|
+
**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):
|
|
12
|
+
- `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).
|
|
13
|
+
- 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).
|
|
14
|
+
- 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.
|
|
15
|
+
- **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.
|
|
16
|
+
- 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.
|
|
17
|
+
|
|
18
|
+
**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`.
|
|
19
|
+
|
|
3
20
|
## 4.0.12 — 2026-08-05 (5-slice optimization bundle)
|
|
4
21
|
|
|
5
22
|
**publish.yml strict tag gate** (slice 1, commit `f60f7597`):
|
|
@@ -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
|
-
|
|
229
|
-
|
|
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,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
|
|
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[];
|
|
@@ -150,6 +199,12 @@ export type DoctorOptions = {
|
|
|
150
199
|
platform?: NodeJS.Platform;
|
|
151
200
|
/** Injected for the build:dist-version-matches-source check (defaults to compareDistVersion on disk). */
|
|
152
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;
|
|
153
208
|
/** Injected for the build:workspace-layout-canonical check (defaults to inspectWorkspaceLayout on disk). */
|
|
154
209
|
workspaceLayoutProbe?: WorkspaceLayoutProbe;
|
|
155
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';
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { StatusLineModel } from './skill-statusline-service.js';
|
|
2
|
+
import { computeRootSuffix as computeRootSuffixImpl } from './skill-statusline-sid-suffix.js';
|
|
3
|
+
export { formatShortSid } from './skill-statusline-sid-suffix.js';
|
|
4
|
+
export declare const computeRootSuffix: typeof computeRootSuffixImpl;
|
|
2
5
|
/**
|
|
3
6
|
* Pure formatting layer for the Peaks statusLine. Takes the read-only status
|
|
4
7
|
* model and produces the single line Claude Code paints at the bottom of the
|
|
@@ -76,4 +79,3 @@ export declare function applyMarquee(s: string, nowMs: number, capability: Statu
|
|
|
76
79
|
* states all keep the marquee.
|
|
77
80
|
*/
|
|
78
81
|
export declare function renderStatusLine(model: StatusLineModel, options?: StatusLineRenderOptions, env?: NodeJS.ProcessEnv): string;
|
|
79
|
-
export {};
|
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import { basename } from 'node:path';
|
|
2
|
+
import { computeRootSuffix as computeRootSuffixImpl, } from './skill-statusline-sid-suffix.js';
|
|
3
|
+
// Re-export so existing test imports
|
|
4
|
+
// (`import { formatShortSid, computeRootSuffix } from '.../skill-statusline-renderer'`)
|
|
5
|
+
// keep working byte-identically after the helper extraction in slice
|
|
6
|
+
// 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
|
|
7
|
+
// repair cycle.
|
|
8
|
+
export { formatShortSid } from './skill-statusline-sid-suffix.js';
|
|
9
|
+
export const computeRootSuffix = computeRootSuffixImpl;
|
|
2
10
|
/**
|
|
3
11
|
* Internal: the brand prefix is plain ASCII so a tiny terminal without
|
|
4
12
|
* monospaced UTF-8 still sees a recognizable string. The mountain glyphs
|
|
@@ -186,6 +194,14 @@ function rootLabel(projectRoot) {
|
|
|
186
194
|
return '';
|
|
187
195
|
return basename(projectRoot);
|
|
188
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* `formatShortSid` + `computeRootSuffix` were extracted to
|
|
199
|
+
* `./skill-statusline-sid-suffix.ts` in the slice
|
|
200
|
+
* 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
|
|
201
|
+
* repair cycle so the renderer file stays under the Karpathy
|
|
202
|
+
* 800-line cap. Both helpers are re-exported above for back-compat
|
|
203
|
+
* with existing test imports.
|
|
204
|
+
*/
|
|
189
205
|
/**
|
|
190
206
|
* Build the "middle" of the status line — everything between the brand
|
|
191
207
|
* prefix and the project root label. Kept separate so the token layout
|
|
@@ -568,7 +584,27 @@ export function renderStatusLine(model, options, env) {
|
|
|
568
584
|
const noColor = env !== undefined ? isNoColor(env) : false;
|
|
569
585
|
const palette = paletteFor(capability, noColor);
|
|
570
586
|
const root = rootLabel(model.projectRoot);
|
|
571
|
-
|
|
587
|
+
// short-sid suffix (slices
|
|
588
|
+
// - 2026-08-05-statusline-empty-render-and-short-sid-suffix (active only)
|
|
589
|
+
// - 2026-08-05-statusline-sid-only-marker (idle + stale also)
|
|
590
|
+
// ):
|
|
591
|
+
// the project root cell carries ` [shortSid]` after `peaks-loop` whenever
|
|
592
|
+
// a canonical session id resolves. Per-state matrix:
|
|
593
|
+
//
|
|
594
|
+
// active → append sid when sessionId !== null (AC5/PB1)
|
|
595
|
+
// idle → append sid when sessionId !== null (AC1)
|
|
596
|
+
// stale → append sid when sessionId !== null (AC2)
|
|
597
|
+
// invalid-presence → NEVER append sid (G2 — read-error signal must
|
|
598
|
+
// stay loud; showing sid would mask the warning)
|
|
599
|
+
// (AC4)
|
|
600
|
+
//
|
|
601
|
+
// Compact state with presence is still rendered with the active sid logic
|
|
602
|
+
// (the active branch already covered this in the prior slice). Compact state
|
|
603
|
+
// without presence is the same as the underlying idle / invalid-presence /
|
|
604
|
+
// stale branch; the helper inherits that. Pure ASCII (no narrow space /
|
|
605
|
+
// smart quotes), so Windows PowerShell + Git Bash + zsh render byte-
|
|
606
|
+
// identical (AC7 of the prior slice + AC10 of this slice).
|
|
607
|
+
const rootSuffix = computeRootSuffix(model, root, palette);
|
|
572
608
|
const brand = brandText(capability, noColor);
|
|
573
609
|
// Breathing pulse: key off a single wall-clock read at the start of
|
|
574
610
|
// render so the output is deterministic for a given invocation. The
|
|
@@ -27,6 +27,20 @@ export type StatusLineModel = {
|
|
|
27
27
|
ageMs: number | null;
|
|
28
28
|
compact: CompactStatuslineState;
|
|
29
29
|
activeLeaf: StatusLineActiveLeaf | null;
|
|
30
|
+
/**
|
|
31
|
+
* Slice 2026-08-05-statusline-sid-only-marker: the canonical session
|
|
32
|
+
* id resolved for the project root (`getSessionIdCanonical`), or
|
|
33
|
+
* `null` when no project root is bound or no `.peaks/_runtime/<sid>/`
|
|
34
|
+
* session is on disk. The renderer reads this to append ` [shortSid]`
|
|
35
|
+
* to the project name cell for idle / stale states (G1); invalid-
|
|
36
|
+
* presence still suppresses the suffix (G2).
|
|
37
|
+
*
|
|
38
|
+
* Carry-forward for AC1/AC2/AC3: the value is `null` whenever
|
|
39
|
+
* `projectRoot === null` — the renderer relies on this to skip the
|
|
40
|
+
* suffix without re-running `getSessionIdCanonical` against a non-
|
|
41
|
+
* existent root.
|
|
42
|
+
*/
|
|
43
|
+
sessionId: string | null;
|
|
30
44
|
};
|
|
31
45
|
export declare function parseStatusLineStdin(raw: string): StatusLineStdin | null;
|
|
32
46
|
export declare function buildStatusLineModel(stdin: StatusLineStdin | null, nowMs: number): StatusLineModel;
|
|
@@ -122,7 +122,13 @@ function readActiveLeaf(projectRoot, sessionId) {
|
|
|
122
122
|
* Both branches now route through the canonical sid-scoped lease projection
|
|
123
123
|
* (slice 2026-08-05-statusline-sid-scoped-lease-B):
|
|
124
124
|
* - `callerId !== null` → `resolveActiveSkillForCaller` with the canonical
|
|
125
|
-
* (non-legacy) lease projection, filtered to this callerId.
|
|
125
|
+
* (non-legacy) lease projection, filtered to this callerId. When the
|
|
126
|
+
* callerId-filtered resolution returns `source: 'none'` (no lease under
|
|
127
|
+
* this callerId), retry once with `callerId: null` so the read falls back
|
|
128
|
+
* to the session's most-recent in-flight lease. AC4 multi-tenant isolation
|
|
129
|
+
* is preserved: when callerId A DOES have a lease, the first call returns
|
|
130
|
+
* it and the fallback never fires — callerId B's lease is never surfaced
|
|
131
|
+
* to callerId A. (Slice 2026-08-05-statusline-empty-render-and-short-sid-suffix.)
|
|
126
132
|
* - `callerId === null` → enumerate `listPresenceLeases` for the
|
|
127
133
|
* canonical session and pick the most recent in-flight lease. Back-compat
|
|
128
134
|
* for non-IDE callers (e.g. legacy CLI invocations) that have no callerId.
|
|
@@ -134,22 +140,28 @@ function readActiveLeaf(projectRoot, sessionId) {
|
|
|
134
140
|
*/
|
|
135
141
|
function readPresenceReadOnly(projectRoot, callerId) {
|
|
136
142
|
if (callerId !== null) {
|
|
143
|
+
let firstResolution = null;
|
|
137
144
|
try {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
145
|
+
firstResolution = resolveActiveSkillForCaller(projectRoot, { callerId });
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return { presence: null, invalid: true };
|
|
149
|
+
}
|
|
150
|
+
if (firstResolution.source !== 'none' && firstResolution.skill !== null) {
|
|
142
151
|
return {
|
|
143
152
|
presence: {
|
|
144
|
-
skill:
|
|
145
|
-
...(
|
|
153
|
+
skill: firstResolution.skill,
|
|
154
|
+
...(firstResolution.mode !== null ? { mode: firstResolution.mode } : {}),
|
|
146
155
|
},
|
|
147
156
|
invalid: false,
|
|
148
157
|
};
|
|
149
158
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
159
|
+
// callerId didn't match any lease — fall back to the session's most
|
|
160
|
+
// recent in-flight lease (the callerId === null branch below). This
|
|
161
|
+
// rescues the case where the harness pipes a `CLAUDE_CODE_SESSION_ID`
|
|
162
|
+
// that differs from the active lease's callerId (peaks-code sessions
|
|
163
|
+
// started from a different outer session id, etc.).
|
|
164
|
+
return readPresenceReadOnly(projectRoot, null);
|
|
153
165
|
}
|
|
154
166
|
// callerId === null branch: enumerate the canonical session dir's leases
|
|
155
167
|
// and pick the most recent in-flight lease. This is the back-compat path
|
|
@@ -208,17 +220,30 @@ export function buildStatusLineModel(stdin, nowMs) {
|
|
|
208
220
|
// and reads `.peaks/_runtime/<sid>/compact-lifecycle.json` (or its legacy
|
|
209
221
|
// fallbacks). It replaces the active skill content when kind != 'none'.
|
|
210
222
|
const compact = readCompactState(projectRoot, nowMs);
|
|
223
|
+
// Slice 2026-08-05-statusline-sid-only-marker: resolve the canonical
|
|
224
|
+
// session id once, up front, so the renderer can read it from the model
|
|
225
|
+
// without re-running `getSessionIdCanonical`. `null` when the project
|
|
226
|
+
// root is unbound OR when no `.peaks/_runtime/<sid>/` session exists.
|
|
227
|
+
let sessionId = null;
|
|
228
|
+
if (projectRoot !== null) {
|
|
229
|
+
try {
|
|
230
|
+
sessionId = getSessionIdCanonical(projectRoot);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
sessionId = null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
211
236
|
if (projectRoot === null) {
|
|
212
|
-
return { state: 'idle', projectRoot: null, presence: null, ageMs: null, compact, activeLeaf: null };
|
|
237
|
+
return { state: 'idle', projectRoot: null, presence: null, ageMs: null, compact, activeLeaf: null, sessionId: null };
|
|
213
238
|
}
|
|
214
239
|
// callerId resolves the read-side isolation; back-compat is `null`.
|
|
215
240
|
const callerId = resolveCallerId(stdin);
|
|
216
241
|
const { presence, invalid } = readPresenceReadOnly(projectRoot, callerId);
|
|
217
242
|
if (invalid) {
|
|
218
|
-
return { state: 'invalid-presence', projectRoot, presence: null, ageMs: null, compact, activeLeaf: null };
|
|
243
|
+
return { state: 'invalid-presence', projectRoot, presence: null, ageMs: null, compact, activeLeaf: null, sessionId };
|
|
219
244
|
}
|
|
220
245
|
if (presence === null) {
|
|
221
|
-
return { state: 'idle', projectRoot, presence: null, ageMs: null, compact, activeLeaf: null };
|
|
246
|
+
return { state: 'idle', projectRoot, presence: null, ageMs: null, compact, activeLeaf: null, sessionId };
|
|
222
247
|
}
|
|
223
248
|
// Session binding: when the presence was stamped with a Claude session id and
|
|
224
249
|
// the live session (from stdin) is a different one, the recorded skill belongs
|
|
@@ -227,7 +252,7 @@ export function buildStatusLineModel(stdin, nowMs) {
|
|
|
227
252
|
// fall back to the time-based behavior below for backward compatibility.
|
|
228
253
|
const liveSessionId = typeof stdin?.session_id === 'string' && stdin.session_id.length > 0 ? stdin.session_id : null;
|
|
229
254
|
if (presence.claudeSessionId && liveSessionId && presence.claudeSessionId !== liveSessionId) {
|
|
230
|
-
return { state: 'idle', projectRoot, presence: null, ageMs: null, compact, activeLeaf: null };
|
|
255
|
+
return { state: 'idle', projectRoot, presence: null, ageMs: null, compact, activeLeaf: null, sessionId };
|
|
231
256
|
}
|
|
232
257
|
const setAtMs = presence.setAt ? Date.parse(presence.setAt) : Number.NaN;
|
|
233
258
|
const ageMs = Number.isNaN(setAtMs) ? null : nowMs - setAtMs;
|
|
@@ -238,13 +263,12 @@ export function buildStatusLineModel(stdin, nowMs) {
|
|
|
238
263
|
// bee skill (e.g. `peaks-rd`) alongside the orchestrator (e.g. `peaks-code`).
|
|
239
264
|
let activeLeaf = null;
|
|
240
265
|
try {
|
|
241
|
-
const sessionId = getSessionIdCanonical(projectRoot);
|
|
242
266
|
activeLeaf = readActiveLeaf(projectRoot, sessionId);
|
|
243
267
|
}
|
|
244
268
|
catch {
|
|
245
269
|
activeLeaf = null;
|
|
246
270
|
}
|
|
247
|
-
return { state, projectRoot, presence, ageMs, compact, activeLeaf };
|
|
271
|
+
return { state, projectRoot, presence, ageMs, compact, activeLeaf, sessionId };
|
|
248
272
|
}
|
|
249
273
|
/**
|
|
250
274
|
* Read-only compact state resolver. Resolves the canonical session id for the
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure short-sid suffix helpers for the Peaks statusLine.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `skill-statusline-renderer.ts` (slice
|
|
5
|
+
* 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
|
|
6
|
+
* repair cycle) to keep the renderer under the Karpathy 800-line cap.
|
|
7
|
+
* The module is I/O-free and only depends on the read-only
|
|
8
|
+
* `StatusLineModel` shape from `skill-statusline-service.ts` plus the
|
|
9
|
+
* `StatusPalette` interface still owned by the renderer. Re-exports
|
|
10
|
+
* both helpers so the existing test surface (`formatShortSid` direct
|
|
11
|
+
* tests + `computeRootSuffix` helper-level tests in
|
|
12
|
+
* `tests/unit/skills/skill-statusline-sid-only-marker.test.ts`) keeps
|
|
13
|
+
* importing from the renderer for back-compat.
|
|
14
|
+
*
|
|
15
|
+
* Pure module — no I/O, no clock dependency, no side effects.
|
|
16
|
+
*/
|
|
17
|
+
import type { StatusLineModel } from './skill-statusline-service.js';
|
|
18
|
+
/**
|
|
19
|
+
* Minimal palette slice consumed by {@link computeRootSuffix}. The full
|
|
20
|
+
* `StatusPalette` is defined as a module-private interface inside the
|
|
21
|
+
* renderer; the suffix helper only needs `trailSeparator`. Re-declared
|
|
22
|
+
* structurally so the helper has no upward import on the renderer.
|
|
23
|
+
*/
|
|
24
|
+
export interface SidSuffixPalette {
|
|
25
|
+
readonly trailSeparator: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Format a session id as a short, terminal-friendly tag.
|
|
29
|
+
*
|
|
30
|
+
* Returns the last kebab-segment of `sessionId` (e.g.
|
|
31
|
+
* `2026-08-04-session-3fe1be` → `3fe1be`), or the empty string when
|
|
32
|
+
* the input is empty. Pure; safe for ASCII rendering across
|
|
33
|
+
* PowerShell, Git Bash, and zsh (AC7).
|
|
34
|
+
*
|
|
35
|
+
* Slice 2026-08-05-statusline-empty-render-and-short-sid-suffix.
|
|
36
|
+
*/
|
|
37
|
+
export declare function formatShortSid(sessionId: string): string;
|
|
38
|
+
/**
|
|
39
|
+
* Compute the project-root cell of the status line. Returns the
|
|
40
|
+
* `<trailSep><root>` portion (and optionally the ` [shortSid]` suffix).
|
|
41
|
+
*
|
|
42
|
+
* Pure helper — no I/O. The `sessionId` is read from the model that the
|
|
43
|
+
* service already resolved once via `getSessionIdCanonical`. Per-state
|
|
44
|
+
* matrix (see the docstring inside `renderStatusLine` in
|
|
45
|
+
* `skill-statusline-renderer.ts` for the AC/PB references):
|
|
46
|
+
*
|
|
47
|
+
* - state === 'invalid-presence' → no sid (G2 invariant)
|
|
48
|
+
* - state === 'idle' | 'stale' | 'active' → sid iff model.sessionId resolves
|
|
49
|
+
* to a non-empty shortSid string
|
|
50
|
+
* - rootLabelText === '' → empty string
|
|
51
|
+
*
|
|
52
|
+
* Exported so tests can drive the helper without spinning up the full
|
|
53
|
+
* renderer (marquee, palette, etc.).
|
|
54
|
+
*/
|
|
55
|
+
export declare function computeRootSuffix(model: StatusLineModel, rootLabelText: string, palette: SidSuffixPalette): string;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure short-sid suffix helpers for the Peaks statusLine.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `skill-statusline-renderer.ts` (slice
|
|
5
|
+
* 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
|
|
6
|
+
* repair cycle) to keep the renderer under the Karpathy 800-line cap.
|
|
7
|
+
* The module is I/O-free and only depends on the read-only
|
|
8
|
+
* `StatusLineModel` shape from `skill-statusline-service.ts` plus the
|
|
9
|
+
* `StatusPalette` interface still owned by the renderer. Re-exports
|
|
10
|
+
* both helpers so the existing test surface (`formatShortSid` direct
|
|
11
|
+
* tests + `computeRootSuffix` helper-level tests in
|
|
12
|
+
* `tests/unit/skills/skill-statusline-sid-only-marker.test.ts`) keeps
|
|
13
|
+
* importing from the renderer for back-compat.
|
|
14
|
+
*
|
|
15
|
+
* Pure module — no I/O, no clock dependency, no side effects.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Format a session id as a short, terminal-friendly tag.
|
|
19
|
+
*
|
|
20
|
+
* Returns the last kebab-segment of `sessionId` (e.g.
|
|
21
|
+
* `2026-08-04-session-3fe1be` → `3fe1be`), or the empty string when
|
|
22
|
+
* the input is empty. Pure; safe for ASCII rendering across
|
|
23
|
+
* PowerShell, Git Bash, and zsh (AC7).
|
|
24
|
+
*
|
|
25
|
+
* Slice 2026-08-05-statusline-empty-render-and-short-sid-suffix.
|
|
26
|
+
*/
|
|
27
|
+
export function formatShortSid(sessionId) {
|
|
28
|
+
if (sessionId.length === 0)
|
|
29
|
+
return '';
|
|
30
|
+
const last = sessionId.split('-').pop();
|
|
31
|
+
return last ?? sessionId;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Compute the project-root cell of the status line. Returns the
|
|
35
|
+
* `<trailSep><root>` portion (and optionally the ` [shortSid]` suffix).
|
|
36
|
+
*
|
|
37
|
+
* Pure helper — no I/O. The `sessionId` is read from the model that the
|
|
38
|
+
* service already resolved once via `getSessionIdCanonical`. Per-state
|
|
39
|
+
* matrix (see the docstring inside `renderStatusLine` in
|
|
40
|
+
* `skill-statusline-renderer.ts` for the AC/PB references):
|
|
41
|
+
*
|
|
42
|
+
* - state === 'invalid-presence' → no sid (G2 invariant)
|
|
43
|
+
* - state === 'idle' | 'stale' | 'active' → sid iff model.sessionId resolves
|
|
44
|
+
* to a non-empty shortSid string
|
|
45
|
+
* - rootLabelText === '' → empty string
|
|
46
|
+
*
|
|
47
|
+
* Exported so tests can drive the helper without spinning up the full
|
|
48
|
+
* renderer (marquee, palette, etc.).
|
|
49
|
+
*/
|
|
50
|
+
export function computeRootSuffix(model, rootLabelText, palette) {
|
|
51
|
+
if (!rootLabelText)
|
|
52
|
+
return '';
|
|
53
|
+
let suffix = `${palette.trailSeparator}${rootLabelText}`;
|
|
54
|
+
if (model.state === 'invalid-presence')
|
|
55
|
+
return suffix;
|
|
56
|
+
// Defensive: older callers may build a model without `sessionId`
|
|
57
|
+
// (the field is optional in some test fixtures predating the
|
|
58
|
+
// sid-only-marker slice). Treat undefined the same as null.
|
|
59
|
+
if (model.sessionId === null || model.sessionId === undefined)
|
|
60
|
+
return suffix;
|
|
61
|
+
const short = formatShortSid(model.sessionId);
|
|
62
|
+
if (short.length === 0)
|
|
63
|
+
return suffix;
|
|
64
|
+
return `${suffix} [${short}]`;
|
|
65
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "peaks-loop",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.13",
|
|
4
4
|
"description": "Loop Engineering CLI — workflow primitive / loop guards / evaluators / slice orchestration",
|
|
5
5
|
"author": "SquabbyZ",
|
|
6
6
|
"keywords": [
|
|
@@ -100,9 +100,9 @@
|
|
|
100
100
|
"headroom-ai": "0.22.4",
|
|
101
101
|
"yaml": "^2.9.0",
|
|
102
102
|
"zod": "^3.25.76",
|
|
103
|
-
"peaks-loop-
|
|
104
|
-
"peaks-loop-
|
|
105
|
-
"peaks-loop-shared": "0.0.
|
|
103
|
+
"peaks-loop-shared": "0.0.44",
|
|
104
|
+
"peaks-loop-mut": "0.1.17",
|
|
105
|
+
"peaks-loop-shared-channel": "0.0.21"
|
|
106
106
|
},
|
|
107
107
|
"peerDependencies": {
|
|
108
108
|
"@alibaba-group/open-code-review": "1.3.1"
|