peaks-loop 4.0.12 → 4.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/cli/commands/_register.js +2 -0
  3. package/dist/cli/commands/core/doctor-command.js +26 -3
  4. package/dist/cli/commands/outer-cache-commands.d.ts +11 -0
  5. package/dist/cli/commands/outer-cache-commands.js +120 -0
  6. package/dist/services/doctor/doctor-service/checks/multi-binary-drift.d.ts +61 -0
  7. package/dist/services/doctor/doctor-service/checks/multi-binary-drift.js +268 -0
  8. package/dist/services/doctor/doctor-service/plugin-registry.js +2 -0
  9. package/dist/services/doctor/doctor-service/report/final-summary.d.ts +13 -5
  10. package/dist/services/doctor/doctor-service/report/final-summary.js +19 -9
  11. package/dist/services/doctor/doctor-service/types.d.ts +55 -0
  12. package/dist/services/doctor/doctor-service.d.ts +2 -1
  13. package/dist/services/doctor/doctor-service.js +1 -0
  14. package/dist/services/doctor/index.d.ts +1 -1
  15. package/dist/services/doctor/index.js +1 -1
  16. package/dist/services/session/session-binding-bridge.js +58 -5
  17. package/dist/services/skills/hooks-settings-service.js +22 -2
  18. package/dist/services/skills/outer-cache-hook-constants.d.ts +24 -0
  19. package/dist/services/skills/outer-cache-hook-constants.js +24 -0
  20. package/dist/services/skills/skill-statusline-renderer.d.ts +3 -1
  21. package/dist/services/skills/skill-statusline-renderer.js +37 -1
  22. package/dist/services/skills/skill-statusline-service.d.ts +14 -0
  23. package/dist/services/skills/skill-statusline-service.js +40 -16
  24. package/dist/services/skills/skill-statusline-sid-suffix.d.ts +55 -0
  25. package/dist/services/skills/skill-statusline-sid-suffix.js +65 -0
  26. package/package.json +4 -4
@@ -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';
@@ -23,11 +23,18 @@ import { dirname, join } from 'node:path';
23
23
  import { randomBytes } from 'node:crypto';
24
24
  import { initWorkspace } from '../workspace/workspace-service.js';
25
25
  import { projectRootsMatch, stableRealPath } from '../../shared/path-utils.js';
26
- import { getSessionId, getSessionIdCanonical, getSessionMeta, rotateSessionBinding } from './session-manager.js';
26
+ import { getSessionId, getSessionIdCanonical, getSessionMeta, rotateSessionBinding, setSessionMeta } from './session-manager.js';
27
27
  // --- Lower-level helpers the bridge needs (moved verbatim) ---
28
28
  const SESSION_FILE = join('_runtime', 'session.json');
29
29
  const LEGACY_SESSION_FILE = '.session.json';
30
30
  const META_FILE = 'session.json';
31
+ // Slice 2026-08-06-session-outer-cache: per-project file cache for the
32
+ // outer (Claude Code / Trae / IDE) session id. Written by the
33
+ // SessionStart hook via `peaks outer-cache write` so that peaks CLI
34
+ // sub-processes (which do NOT inherit CLAUDE_CODE_SESSION_ID) can still
35
+ // resolve the current outer session. Lives under the gitignored
36
+ // `.peaks/_runtime/` tree, so no .gitignore change is required.
37
+ const OUTER_SESSION_CACHE_FILE = join('_runtime', '.outer-session-cache.json');
31
38
  function getLegacySessionFilePath(projectRoot) {
32
39
  return join(projectRoot, '.peaks', LEGACY_SESSION_FILE);
33
40
  }
@@ -124,18 +131,58 @@ function writeSessionMeta(projectRoot, sessionId, meta) {
124
131
  }
125
132
  writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf8');
126
133
  }
127
- function getCurrentOuterSessionId() {
134
+ function getCurrentOuterSessionId(projectRoot) {
128
135
  const peaks = process.env.PEAKS_OUTER_SESSION_ID;
129
136
  if (typeof peaks === 'string' && peaks.length > 0)
130
137
  return peaks;
131
138
  const claude = process.env.CLAUDE_CODE_SESSION_ID;
132
139
  if (typeof claude === 'string' && claude.length > 0)
133
140
  return claude;
141
+ // Slice 2026-08-06-session-outer-cache (G1): when the peaks CLI runs
142
+ // as a sub-process of Claude Code (or any IDE that does not export
143
+ // CLAUDE_CODE_SESSION_ID into the child env), the env vars above are
144
+ // undefined. Fall back to the per-project file cache written by the
145
+ // SessionStart hook via `peaks outer-cache write`. The file lives
146
+ // under `.peaks/_runtime/` (gitignored) so no .gitignore change is
147
+ // required. Any IO error or non-string `outerSessionId` field is
148
+ // treated as a cache miss — never throw.
149
+ if (projectRoot !== undefined) {
150
+ const cachePath = join(projectRoot, '.peaks', OUTER_SESSION_CACHE_FILE);
151
+ if (existsSync(cachePath)) {
152
+ try {
153
+ const raw = readFileSync(cachePath, 'utf8');
154
+ const parsed = JSON.parse(raw);
155
+ if (parsed !== null &&
156
+ typeof parsed === 'object' &&
157
+ typeof parsed.outerSessionId === 'string' &&
158
+ (parsed.outerSessionId).length > 0) {
159
+ return parsed.outerSessionId;
160
+ }
161
+ }
162
+ catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
163
+ // fall through — file missing / malformed JSON / IO error → undefined
164
+ }
165
+ }
166
+ }
134
167
  return undefined;
135
168
  }
136
169
  export async function ensureSession(projectRoot) {
137
170
  const existing = readSessionFile(projectRoot);
138
171
  if (existing) {
172
+ // Slice 2026-08-06-session-outer-cache (G3 / AC8-AC11): on every
173
+ // already-bound invocation, stamp the current outer-session-id
174
+ // onto the bound session's meta so the on-disk
175
+ // `.peaks/_runtime/<sid>/session.json` always reflects the latest
176
+ // outer signal — not a stale value captured at session creation
177
+ // time. `setSessionMeta` is read-modify-write: every other field
178
+ // (title / skill / mode / gate / createdAt / lastActivity) is
179
+ // preserved. `getCurrentOuterSessionId(projectRoot)` reads env
180
+ // → file-cache → undefined; an undefined value is NOT written,
181
+ // so the previously-stamped outer (if any) survives.
182
+ const outerSessionId = getCurrentOuterSessionId(projectRoot);
183
+ if (outerSessionId !== undefined) {
184
+ setSessionMeta(projectRoot, existing.sessionId, { outerSessionId });
185
+ }
139
186
  return existing.sessionId;
140
187
  }
141
188
  // Slice 007 — sub-agent session sharing. When the strict-equality
@@ -165,8 +212,11 @@ export async function ensureSession(projectRoot) {
165
212
  };
166
213
  writeSessionFile(projectRoot, info);
167
214
  await initWorkspace({ projectRoot, sessionId });
168
- // Initialize session metadata inside the session directory
169
- const outerSessionId = getCurrentOuterSessionId();
215
+ // Initialize session metadata inside the session directory.
216
+ // Slice 2026-08-06-session-outer-cache (G1): pass `projectRoot` so
217
+ // the env → file-cache → undefined resolution chain also reads the
218
+ // SessionStart-written cache on first bind.
219
+ const outerSessionId = getCurrentOuterSessionId(projectRoot);
170
220
  writeSessionMeta(projectRoot, sessionId, {
171
221
  sessionId,
172
222
  projectRoot,
@@ -211,7 +261,10 @@ export async function ensureSession(projectRoot) {
211
261
  */
212
262
  export async function ensureSessionWithRotation(projectRoot, options) {
213
263
  const skipRotate = options?.skipRotateOnOuterMismatch === true;
214
- const currentOuterSessionId = getCurrentOuterSessionId();
264
+ // Slice 2026-08-06-session-outer-cache (G1): pass `projectRoot` so
265
+ // the rotation-decision comparison sees the SessionStart cache
266
+ // when the env vars are unset (sub-process / nested invocation).
267
+ const currentOuterSessionId = getCurrentOuterSessionId(projectRoot);
215
268
  // Compute the rotation decision up front. We only rotate when ALL
216
269
  // three pre-conditions hold: (a) the current outer session id is
217
270
  // defined, (b) the bound session has a recorded outer session id,
@@ -4,6 +4,7 @@ import { resolve } from 'node:path';
4
4
  import { assertSafeSettingsFile } from '../ide/shared/safe-path.js';
5
5
  import { atomicWriteJson, readJsonObjectFile } from '../ide/shared/atomic-json.js';
6
6
  import { getAdapter } from '../ide/ide-registry.js';
7
+ import { HOOK_OUTER_CACHE_COMMAND, HOOK_OUTER_CACHE_EVENT, HOOK_OUTER_CACHE_SENTINEL } from './outer-cache-hook-constants.js';
7
8
  // --- Module-level defaults (claude-code) -----------------------------------
8
9
  // These exports remain for backward compat — tests and downstream callers
9
10
  // that only care about Claude Code can keep importing them. The per-IDE
@@ -181,12 +182,25 @@ export function readInstalledEntriesFromSettings(settings, ide) {
181
182
  * entry's sentinel is included in the legacy sentinel set so uninstall
182
183
  * can find + remove any stale progress-start entry that an older
183
184
  * `peaks hooks install` may have written before this slice.
185
+ *
186
+ * Slice 2026-08-06-session-outer-cache: a SessionStart outer-cache primer
187
+ * entry is appended for Claude Code only (SessionStart is a Claude-Code-
188
+ * specific event key today).
184
189
  */
185
190
  function resolveHookEntries(ide, _skipProgress = false) {
186
191
  const spec = resolveHookSpec(ide);
187
- return [
192
+ const entries = [
188
193
  { sentinel: spec.hookEnforceSentinel, matcher: spec.hookEnforceMatcher, command: spec.hookEnforceCommand, event: spec.hookEnforceEvent }
189
194
  ];
195
+ if (ide === 'claude-code') {
196
+ entries.push({
197
+ sentinel: HOOK_OUTER_CACHE_SENTINEL,
198
+ matcher: '',
199
+ command: HOOK_OUTER_CACHE_COMMAND,
200
+ event: HOOK_OUTER_CACHE_EVENT
201
+ });
202
+ }
203
+ return entries;
190
204
  }
191
205
  /**
192
206
  * Legacy sentinel set used by uninstall + status to find and remove stale
@@ -198,7 +212,13 @@ function resolveLegacySentinels(ide) {
198
212
  if (ide === 'trae') {
199
213
  return ['peaks hook handle', LEGACY_PROGRESS_START_SENTINEL];
200
214
  }
201
- return [HOOK_ENFORCE_SENTINEL, LEGACY_PROGRESS_START_SENTINEL];
215
+ // Slice 2026-08-06-session-outer-cache: include the SessionStart outer-cache
216
+ // sentinel so uninstall strips it alongside the gate-enforce entry.
217
+ const base = [HOOK_ENFORCE_SENTINEL, LEGACY_PROGRESS_START_SENTINEL];
218
+ if (ide === 'claude-code') {
219
+ return [...base, HOOK_OUTER_CACHE_SENTINEL];
220
+ }
221
+ return base;
202
222
  }
203
223
  // --- Slice 2026-07-29-worktree-layer3-deny -----------------------------------
204
224
  // Layer 3 of the Worktree Governance 3-layer design (see
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Slice 2026-08-06-session-outer-cache (G2): per-IDE SessionStart hook
3
+ * constants for the outer-session cache primer. Extracted from
4
+ * `hooks-settings-service.ts` to keep that file under the Karpathy
5
+ * 800 LOC cap.
6
+ *
7
+ * The SessionStart hook is fired by Claude Code (and any future IDE
8
+ * that ships a SessionStart-style event) every time a new outer
9
+ * session begins. It runs `peaks outer-cache write`, which reads
10
+ * PEAKS_OUTER_SESSION_ID ?? CLAUDE_CODE_SESSION_ID from env and
11
+ * persists it to `.peaks/_runtime/.outer-session-cache.json` so the
12
+ * next peaks CLI sub-process can resolve the current outer session
13
+ * via `getCurrentOuterSessionId(projectRoot)`.
14
+ *
15
+ * Only Claude Code supports SessionStart today; future adapters opt
16
+ * in by extending `resolveHookEntries` and adding an adapter-driven
17
+ * substitution for the `${...}` project-dir placeholder.
18
+ */
19
+ /** Sentinel substring identifying a SessionStart outer-cache hook entry. */
20
+ export declare const HOOK_OUTER_CACHE_SENTINEL = "peaks outer-cache write";
21
+ /** Default (claude-code) SessionStart hook command. */
22
+ export declare const HOOK_OUTER_CACHE_COMMAND = "peaks outer-cache write --project \"${CLAUDE_PROJECT_DIR}\"";
23
+ /** SessionStart hook event key for Claude Code. */
24
+ export declare const HOOK_OUTER_CACHE_EVENT = "SessionStart";
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Slice 2026-08-06-session-outer-cache (G2): per-IDE SessionStart hook
3
+ * constants for the outer-session cache primer. Extracted from
4
+ * `hooks-settings-service.ts` to keep that file under the Karpathy
5
+ * 800 LOC cap.
6
+ *
7
+ * The SessionStart hook is fired by Claude Code (and any future IDE
8
+ * that ships a SessionStart-style event) every time a new outer
9
+ * session begins. It runs `peaks outer-cache write`, which reads
10
+ * PEAKS_OUTER_SESSION_ID ?? CLAUDE_CODE_SESSION_ID from env and
11
+ * persists it to `.peaks/_runtime/.outer-session-cache.json` so the
12
+ * next peaks CLI sub-process can resolve the current outer session
13
+ * via `getCurrentOuterSessionId(projectRoot)`.
14
+ *
15
+ * Only Claude Code supports SessionStart today; future adapters opt
16
+ * in by extending `resolveHookEntries` and adding an adapter-driven
17
+ * substitution for the `${...}` project-dir placeholder.
18
+ */
19
+ /** Sentinel substring identifying a SessionStart outer-cache hook entry. */
20
+ export const HOOK_OUTER_CACHE_SENTINEL = 'peaks outer-cache write';
21
+ /** Default (claude-code) SessionStart hook command. */
22
+ export const HOOK_OUTER_CACHE_COMMAND = `peaks outer-cache write --project "\${CLAUDE_PROJECT_DIR}"`;
23
+ /** SessionStart hook event key for Claude Code. */
24
+ export const HOOK_OUTER_CACHE_EVENT = 'SessionStart';
@@ -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
+ }