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 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
- const icon = check.ok ? '+' : '×';
229
- io.stdout(` ${icon} ${check.message}`);
236
+ if (check.ok) {
237
+ io.stdout(` + ${check.message}`);
238
+ }
239
+ else if (check.severity === 'warning') {
240
+ io.stdout(` ! ${check.message}`);
241
+ }
242
+ else {
243
+ io.stdout(` × ${check.message}`);
244
+ }
230
245
  }
231
246
  if (logsSection !== null) {
232
247
  io.stdout('\n logs:');
@@ -252,11 +267,19 @@ export function registerDoctorCommand(program, io) {
252
267
  io.stdout(' rerun with --cleanup-stale to drop them');
253
268
  }
254
269
  }
255
- io.stdout(`\n ${report.summary.passed} passed, ${report.summary.failed} failed`);
270
+ io.stdout(`\n ${report.summary.passed} passed, ${report.summary.failed} failed${report.summary.warnings > 0 ? `, ${report.summary.warnings} warning(s)` : ''}`);
256
271
  if (!report.summary.ok || staleInstances.length > 0) {
257
272
  io.stderr(`\nDOCTOR_FAILED: ${staleInstances.length > 0 ? 'stale binding present' : `${report.summary.failed} check(s) failed`}.`);
258
273
  }
274
+ else if (report.summary.warnings > 0) {
275
+ io.stdout(`\n ${report.summary.warnings} warning(s) present — exit code 0 (warn-only).`);
276
+ }
259
277
  }
278
+ // Slice 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
279
+ // repair cycle: `report.summary.ok` already factors in the
280
+ // severity-aware aggregation in `buildReport` (warnings do NOT
281
+ // flip `ok`). The exit-code gate remains `summary.ok &&
282
+ // !staleInstances` — no separate warning special-case needed here.
260
283
  if (!report.summary.ok || staleInstances.length > 0) {
261
284
  process.exitCode = 1;
262
285
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Check: PATH-scoped `peaks-loop` binary drift
3
+ * (`build:multi-binary-drift`).
4
+ *
5
+ * Slice 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
6
+ * (G3/G4). When more than one `peaks-loop` binary is discoverable on
7
+ * `process.env.PATH` AND the discovered versions disagree, the doctor
8
+ * emits a `PEAKS_MULTI_BINARY_DRIFT` warning. The user-reported
9
+ * production symptom — `peaks` resolving to an old version (e.g.
10
+ * 3.1.2 on `%AppData%\Roaming\npm`) while the freshly installed
11
+ * binary on another PATH entry (e.g. `C:\nvm4w\nodejs\peaks` 4.0.12)
12
+ * is what the user just bumped — produces "Hook JSON output
13
+ * validation failed" when the IDE statusline hook inherits the older
14
+ * binary via PATH ordering.
15
+ *
16
+ * Behavior contract (PRD AC6 / AC7 / AC8 / AC9):
17
+ * - AC6: ≥ 2 peaks-loop binaries with different versions → warning
18
+ * - AC7: warning severity only, doctor exit 0 (no other check
19
+ * flipped to error)
20
+ * - AC8: drift is scoped to peaks-loop (`package.json#name ===
21
+ * 'peaks-loop'`); sibling npm tools are NOT flagged
22
+ * - AC9: cross-platform — uses `process.env.PATH` + `path.delimiter`
23
+ * + binary naming `peaks` / `peaks.cmd` / `peaks.ps1`
24
+ *
25
+ * Pure `inspectMultiBinaryDrift` helper is exported so tests can drive
26
+ * the filesystem walk without monkey-patching `process.env.PATH` or
27
+ * `realpathSync`. Every probe call is wrapped in try/catch (read-only,
28
+ * must not throw across the doctor boundary).
29
+ */
30
+ import type { DoctorCheckPlugin, MultiBinaryDriftInspection } from '../types.js';
31
+ /**
32
+ * Local record shape — same as the canonical
33
+ * `MultiBinaryDriftInspection.binaries[number]`. Re-declared so the
34
+ * helper signature carries the concrete shape (the canonical
35
+ * `MultiBinaryDriftInspection` widens `version` + `installDate` to
36
+ * `string | null` so external consumers do not depend on the
37
+ * field being nullable).
38
+ */
39
+ export type PeaksBinaryRecord = {
40
+ readonly path: string;
41
+ readonly version: string | null;
42
+ readonly installDate: string | null;
43
+ readonly realpath: string;
44
+ };
45
+ /**
46
+ * Pure helper. Inspects `process.env.PATH` (or the injected
47
+ * `pathEnv`) for `peaks-loop` binaries. Each found binary is walked
48
+ * to its package.json via `realpathSync` so symlinks / npm shims /
49
+ * `peaks.cmd` / `peaks.ps1` all collapse to the same dedupe key.
50
+ *
51
+ * Exported so tests can drive the filesystem walk without monkey-
52
+ * patching `process.env.PATH` or `realpathSync`.
53
+ */
54
+ export declare function inspectMultiBinaryDrift(opts?: {
55
+ pathEnv?: string;
56
+ envReader?: (key: string) => string | undefined;
57
+ binaryExists?: (candidate: string) => boolean;
58
+ binaryRealpath?: (candidate: string) => string;
59
+ packageJsonReader?: (path: string) => Buffer | string | null;
60
+ }): MultiBinaryDriftInspection;
61
+ export declare const check: DoctorCheckPlugin;
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Check: PATH-scoped `peaks-loop` binary drift
3
+ * (`build:multi-binary-drift`).
4
+ *
5
+ * Slice 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
6
+ * (G3/G4). When more than one `peaks-loop` binary is discoverable on
7
+ * `process.env.PATH` AND the discovered versions disagree, the doctor
8
+ * emits a `PEAKS_MULTI_BINARY_DRIFT` warning. The user-reported
9
+ * production symptom — `peaks` resolving to an old version (e.g.
10
+ * 3.1.2 on `%AppData%\Roaming\npm`) while the freshly installed
11
+ * binary on another PATH entry (e.g. `C:\nvm4w\nodejs\peaks` 4.0.12)
12
+ * is what the user just bumped — produces "Hook JSON output
13
+ * validation failed" when the IDE statusline hook inherits the older
14
+ * binary via PATH ordering.
15
+ *
16
+ * Behavior contract (PRD AC6 / AC7 / AC8 / AC9):
17
+ * - AC6: ≥ 2 peaks-loop binaries with different versions → warning
18
+ * - AC7: warning severity only, doctor exit 0 (no other check
19
+ * flipped to error)
20
+ * - AC8: drift is scoped to peaks-loop (`package.json#name ===
21
+ * 'peaks-loop'`); sibling npm tools are NOT flagged
22
+ * - AC9: cross-platform — uses `process.env.PATH` + `path.delimiter`
23
+ * + binary naming `peaks` / `peaks.cmd` / `peaks.ps1`
24
+ *
25
+ * Pure `inspectMultiBinaryDrift` helper is exported so tests can drive
26
+ * the filesystem walk without monkey-patching `process.env.PATH` or
27
+ * `realpathSync`. Every probe call is wrapped in try/catch (read-only,
28
+ * must not throw across the doctor boundary).
29
+ */
30
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
31
+ import { delimiter as pathDelimiter, join } from 'node:path';
32
+ import { getErrorMessage } from 'peaks-loop-shared/result';
33
+ /**
34
+ * Pure helper. Inspects `process.env.PATH` (or the injected
35
+ * `pathEnv`) for `peaks-loop` binaries. Each found binary is walked
36
+ * to its package.json via `realpathSync` so symlinks / npm shims /
37
+ * `peaks.cmd` / `peaks.ps1` all collapse to the same dedupe key.
38
+ *
39
+ * Exported so tests can drive the filesystem walk without monkey-
40
+ * patching `process.env.PATH` or `realpathSync`.
41
+ */
42
+ export function inspectMultiBinaryDrift(opts) {
43
+ const envReader = opts?.envReader ?? ((k) => process.env[k]);
44
+ const exists = opts?.binaryExists ?? ((p) => {
45
+ try {
46
+ return existsSync(p);
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ });
52
+ const realpath = opts?.binaryRealpath ?? ((p) => {
53
+ try {
54
+ return realpathSync(p);
55
+ }
56
+ catch {
57
+ return p;
58
+ }
59
+ });
60
+ const reader = opts?.packageJsonReader ?? ((p) => {
61
+ try {
62
+ return readFileSync(p);
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ });
68
+ const pathEnv = opts?.pathEnv ?? envReader('PATH') ?? '';
69
+ if (pathEnv.length === 0) {
70
+ return { binaries: [], driftDetected: false, uniqueVersions: [] };
71
+ }
72
+ const dirs = pathEnv.split(pathDelimiter).filter((d) => d.length > 0);
73
+ const seen = new Map();
74
+ for (const dir of dirs) {
75
+ const candidates = candidateBinaryNames(dir);
76
+ for (const candidate of candidates) {
77
+ if (!exists(candidate))
78
+ continue;
79
+ const rp = realpath(candidate);
80
+ if (seen.has(rp))
81
+ continue;
82
+ const pkgPath = locatePackageJson(rp, candidate);
83
+ const record = readBinaryRecord(candidate, rp, pkgPath, reader);
84
+ seen.set(rp, record);
85
+ }
86
+ }
87
+ const binaries = Array.from(seen.values());
88
+ const uniqueVersions = dedupeVersions(binaries.map((b) => b.version));
89
+ return {
90
+ binaries,
91
+ driftDetected: uniqueVersions.length >= 2,
92
+ uniqueVersions
93
+ };
94
+ }
95
+ /**
96
+ * Cross-platform candidate names. Windows shims the executable as
97
+ * `peaks.cmd` and `peaks.ps1` (npm writes both); POSIX names the
98
+ * binary `peaks`. We probe all three names on every platform —
99
+ * probing a non-existent file is a no-op, so cross-list probing is
100
+ * safe.
101
+ */
102
+ function candidateBinaryNames(dir) {
103
+ return [join(dir, 'peaks'), join(dir, 'peaks.cmd'), join(dir, 'peaks.ps1')];
104
+ }
105
+ /**
106
+ * Walk from the binary to its `node_modules/peaks-loop/package.json`.
107
+ *
108
+ * Common layouts handled:
109
+ *
110
+ * A. `<root>/bin/peaks` (POSIX npm global / nvm) → parent is
111
+ * `<root>/bin/`, parent's parent is `<root>/`. package.json at
112
+ * `<root>/package.json`.
113
+ * B. `<root>/peaks` or `<root>/peaks.cmd` (Windows npm global shim)
114
+ * → parent is `<root>/`. package.json at `<root>/package.json`,
115
+ * OR `<root>/../node_modules/peaks-loop/package.json`.
116
+ * C. `<root>/node_modules/.bin/peaks` (local install) → parent is
117
+ * `node_modules/.bin/`, parent's parent is `node_modules/`.
118
+ * package.json at `<root>/node_modules/peaks-loop/package.json`.
119
+ *
120
+ * The locator walks up from the binary path (realpathPath first,
121
+ * then originalCandidate) and checks for the canonical
122
+ * `node_modules/peaks-loop/package.json` and a `package.json` at each
123
+ * ancestor. The probe is read-only and stops at the first hit. We do
124
+ * NOT trust `package.json` without verifying its `name === 'peaks-loop'`
125
+ * later (see {@link readBinaryRecord}) — a sibling package's
126
+ * `package.json` is filtered out at read time.
127
+ */
128
+ function locatePackageJson(realpathPath, originalCandidate) {
129
+ const ancestors = new Set();
130
+ for (const start of [realpathPath, originalCandidate]) {
131
+ let current = start;
132
+ for (let depth = 0; depth < 8; depth++) {
133
+ const parent = join(current, '..');
134
+ if (parent === current)
135
+ break;
136
+ ancestors.add(parent);
137
+ current = parent;
138
+ }
139
+ }
140
+ for (const ancestor of ancestors) {
141
+ const nmPkg = join(ancestor, 'node_modules', 'peaks-loop', 'package.json');
142
+ if (existsSafe(nmPkg))
143
+ return nmPkg;
144
+ }
145
+ for (const ancestor of ancestors) {
146
+ const pkg = join(ancestor, 'package.json');
147
+ if (existsSafe(pkg))
148
+ return pkg;
149
+ }
150
+ return null;
151
+ }
152
+ function existsSafe(p) {
153
+ try {
154
+ return existsSync(p);
155
+ }
156
+ catch {
157
+ return false;
158
+ }
159
+ }
160
+ function readBinaryRecord(candidate, realpathPath, pkgPath, reader) {
161
+ if (pkgPath === null) {
162
+ return {
163
+ path: candidate,
164
+ version: null,
165
+ installDate: null,
166
+ realpath: realpathPath
167
+ };
168
+ }
169
+ let version = null;
170
+ try {
171
+ const raw = reader(pkgPath);
172
+ if (raw !== null) {
173
+ const text = Buffer.isBuffer(raw) ? raw.toString('utf8') : raw;
174
+ const parsed = JSON.parse(text);
175
+ if (parsed.name === 'peaks-loop' && typeof parsed.version === 'string') {
176
+ version = parsed.version;
177
+ }
178
+ }
179
+ }
180
+ catch {
181
+ version = null;
182
+ }
183
+ let installDate = null;
184
+ try {
185
+ const stat = statSync(pkgPath);
186
+ installDate = stat.mtime.toISOString();
187
+ }
188
+ catch {
189
+ installDate = null;
190
+ }
191
+ return {
192
+ path: candidate,
193
+ version,
194
+ installDate,
195
+ realpath: realpathPath
196
+ };
197
+ }
198
+ /**
199
+ * `version === null` means we could not read the package.json (or
200
+ * its `name` did not equal `peaks-loop`). Those records stay in
201
+ * `binaries` for the report but do NOT contribute to
202
+ * `uniqueVersions` — including null would falsely trigger drift
203
+ * detection when the only failures are unreadable binaries.
204
+ */
205
+ function dedupeVersions(versions) {
206
+ const seen = new Set();
207
+ const out = [];
208
+ for (const v of versions) {
209
+ if (typeof v !== 'string' || v.length === 0)
210
+ continue;
211
+ if (seen.has(v))
212
+ continue;
213
+ seen.add(v);
214
+ out.push(v);
215
+ }
216
+ return out;
217
+ }
218
+ function run({ options }) {
219
+ const probe = options.multiBinaryDriftProbe ?? (() => inspectMultiBinaryDrift());
220
+ try {
221
+ const result = probe();
222
+ if (result.binaries.length === 0) {
223
+ return [{
224
+ id: 'build:multi-binary-drift',
225
+ ok: true,
226
+ message: 'no peaks-loop binary on PATH (statusLine may be unavailable)'
227
+ }];
228
+ }
229
+ if (!result.driftDetected) {
230
+ return [{
231
+ id: 'build:multi-binary-drift',
232
+ ok: true,
233
+ message: result.binaries.length === 1
234
+ ? `single peaks-loop binary on PATH at ${result.binaries[0].path} (version ${result.uniqueVersions[0] ?? 'unknown'})`
235
+ : `${result.binaries.length} peaks-loop binaries on PATH all at version ${result.uniqueVersions[0] ?? 'unknown'}`
236
+ }];
237
+ }
238
+ // Drift detected — WARN-ONLY. AC7: doctor still exit 0 unless
239
+ // another check escalates to error. `ok: false` so the operator
240
+ // sees the finding in the JSON report AND the check carries
241
+ // `severity: 'warning'` so `buildReport` does NOT count it as a
242
+ // failure when computing `summary.ok`. Slice
243
+ // 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
244
+ // repair cycle landed the severity-aware summary so the
245
+ // previously-handwaved "future severity-aware summary can
246
+ // downgrade the doctor exit code" actually fires.
247
+ const binaryTable = result.binaries
248
+ .map((b) => ` ${b.path} version=${b.version ?? 'unknown'} date=${b.installDate ?? 'unknown'}`)
249
+ .join('\n');
250
+ return [{
251
+ id: 'build:multi-binary-drift',
252
+ ok: false,
253
+ severity: 'warning',
254
+ message: `PEAKS_MULTI_BINARY_DRIFT: ${result.uniqueVersions.length} distinct peaks-loop versions on PATH (${result.uniqueVersions.join(', ')}). Run \`npm uninstall -g peaks-loop\` on the stale entries, or reorder PATH so the desired binary resolves first. Binaries:\n${binaryTable}`
255
+ }];
256
+ }
257
+ catch (error) {
258
+ return [{
259
+ id: 'build:multi-binary-drift',
260
+ ok: false,
261
+ message: `multi-binary drift check failed: ${getErrorMessage(error)}`
262
+ }];
263
+ }
264
+ }
265
+ export const check = {
266
+ name: 'multi-binary-drift',
267
+ run
268
+ };
@@ -43,6 +43,7 @@ import { check as statuslineInstall } from './checks/statusline-install.js';
43
43
  import { check as statuslineRuntime } from './checks/statusline-runtime.js';
44
44
  import { check as codegraphCapability } from './checks/codegraph-capability.js';
45
45
  import { check as distSourceVersion } from './checks/dist-source-version.js';
46
+ import { check as multiBinaryDrift } from './checks/multi-binary-drift.js';
46
47
  import { check as workspaceLayout } from './checks/workspace-layout.js';
47
48
  import { check as gateguardConflict } from './checks/gateguard-conflict.js';
48
49
  import { check as checkIdSchema } from './checks/check-id-schema.js';
@@ -69,6 +70,7 @@ export const PLUGINS = [
69
70
  statuslineRuntime, // id "statusline:runtime"
70
71
  codegraphCapability, // id "capability:codegraph"
71
72
  distSourceVersion, // id "build:dist-version-matches-source"
73
+ multiBinaryDrift, // id "build:multi-binary-drift"
72
74
  workspaceLayout, // id "build:workspace-layout-canonical"
73
75
  gateguardConflict, // id "integration:gateguard-peaks-conflict"
74
76
  checkIdSchema, // id "doctor-self:check-id-pattern"
@@ -2,11 +2,19 @@
2
2
  * Report aggregator: convert the accumulated `DoctorCheck[]` into the
3
3
  * public `DoctorReport` shape (checks + summary).
4
4
  *
5
- * The summary is a pure reduction over the checks — it counts the
6
- * passing and failing rows, derives `ok = failed === 0`, and
7
- * returns the `{ checks, summary }` tuple. Kept in its own module
8
- * so the dispatcher (`index.ts`) stays a thin orchestration loop
9
- * and the aggregation rule has a single test surface.
5
+ * The summary is a pure reduction over the checks — it separates
6
+ * `error`-severity findings from `warning`-severity findings, derives
7
+ * `ok = errors === 0`, and returns the `{ checks, summary }` tuple.
8
+ * Kept in its own module so the dispatcher (`index.ts`) stays a thin
9
+ * orchestration loop and the aggregation rule has a single test surface.
10
+ *
11
+ * Severity-aware aggregation (slice
12
+ * 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
13
+ * repair cycle): a check with `severity: 'warning'` reports `ok: false`
14
+ * (so operators see the finding in the JSON envelope) but does NOT
15
+ * flip `summary.ok` and therefore does NOT flip the doctor exit code.
16
+ * Genuine failures (severity omitted, or `severity: 'error'`) behave
17
+ * the same as the pre-slice `failed === 0` rule.
10
18
  */
11
19
  import type { DoctorCheck, DoctorReport } from '../types.js';
12
20
  /**
@@ -2,11 +2,19 @@
2
2
  * Report aggregator: convert the accumulated `DoctorCheck[]` into the
3
3
  * public `DoctorReport` shape (checks + summary).
4
4
  *
5
- * The summary is a pure reduction over the checks — it counts the
6
- * passing and failing rows, derives `ok = failed === 0`, and
7
- * returns the `{ checks, summary }` tuple. Kept in its own module
8
- * so the dispatcher (`index.ts`) stays a thin orchestration loop
9
- * and the aggregation rule has a single test surface.
5
+ * The summary is a pure reduction over the checks — it separates
6
+ * `error`-severity findings from `warning`-severity findings, derives
7
+ * `ok = errors === 0`, and returns the `{ checks, summary }` tuple.
8
+ * Kept in its own module so the dispatcher (`index.ts`) stays a thin
9
+ * orchestration loop and the aggregation rule has a single test surface.
10
+ *
11
+ * Severity-aware aggregation (slice
12
+ * 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
13
+ * repair cycle): a check with `severity: 'warning'` reports `ok: false`
14
+ * (so operators see the finding in the JSON envelope) but does NOT
15
+ * flip `summary.ok` and therefore does NOT flip the doctor exit code.
16
+ * Genuine failures (severity omitted, or `severity: 'error'`) behave
17
+ * the same as the pre-slice `failed === 0` rule.
10
18
  */
11
19
  /**
12
20
  * Build the final report from the accumulated checks.
@@ -17,13 +25,15 @@
17
25
  * report shape the public API exposes.
18
26
  */
19
27
  export function buildReport(checks) {
20
- const failed = checks.filter((check) => !check.ok).length;
28
+ const errors = checks.filter((check) => !check.ok && check.severity !== 'warning').length;
29
+ const warnings = checks.filter((check) => !check.ok && check.severity === 'warning').length;
21
30
  return {
22
31
  checks: [...checks],
23
32
  summary: {
24
- ok: failed === 0,
25
- passed: checks.length - failed,
26
- failed
33
+ ok: errors === 0,
34
+ passed: checks.length - errors - warnings,
35
+ failed: errors,
36
+ warnings
27
37
  }
28
38
  };
29
39
  }
@@ -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
- const rootSuffix = root ? `${palette.trailSeparator}${root}` : '';
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
- const resolution = resolveActiveSkillForCaller(projectRoot, { callerId });
139
- if (resolution.source === 'none' || resolution.skill === null) {
140
- return { presence: null, invalid: false };
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: resolution.skill,
145
- ...(resolution.mode !== null ? { mode: resolution.mode } : {}),
153
+ skill: firstResolution.skill,
154
+ ...(firstResolution.mode !== null ? { mode: firstResolution.mode } : {}),
146
155
  },
147
156
  invalid: false,
148
157
  };
149
158
  }
150
- catch {
151
- return { presence: null, invalid: true };
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.12",
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-mut": "0.1.16",
104
- "peaks-loop-shared-channel": "0.0.20",
105
- "peaks-loop-shared": "0.0.43"
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"