peaks-loop 4.0.13 → 4.0.15

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,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.0.15 — 2026-08-06 (rotation guards tightening + caller-derived workflowId + caller-binding primary source + atomic write hygiene)
4
+
5
+ **Tightens rotation guards with 4th same-process re-resolve short-circuit** (slice `2026-08-06-rotation-guards-and-caller-binding`, commit `f38a796f`):
6
+ - `src/services/session/session-binding-bridge.ts` introduces a module-scoped `lastResolvedOuter: { value: string | undefined; resolvedAt: number } | null` populated on every non-throw `getCurrentOuterSessionId` call (env hit / cache hit / undefined fallback). `ensureSessionWithRotation` now short-circuits on a 4th additive guard: when `lastResolvedOuter.value === currentOuterSessionId === boundOuter`, the rotation path is skipped. The 3 legacy guards (currentOuterSessionId === undefined / boundOuter empty / boundOuter === currentOuter) are preserved verbatim per PRD PB3. Test seam `_resetLastResolvedOuterForTest()` exported.
7
+ - Tests: `tests/unit/session/rotation-guards-tightening.test.ts` (11 cases: 3 legacy-guard regressions + 4th-guard flip A→B same-process + per-process isolation + writeAtomic fallback + skipRotate + module-state integrity).
8
+
9
+ **Caller-derived workflowId in legacy compat shim** (slice same, commit `2f6322a3`):
10
+ - `src/services/skills/skill-presence-service.ts:391` switches the legacy compat shim's `workflowId` from `wf-${sessionId}-compat` to `wf-${projection.callerId.slice(0, 189)}-compat`. The slice(0, 189) protects against `WORKFLOW_ID_REGEX` overflow (3 + 200 callerId + 7 = 210 chars > 200 regex cap). `presence-lease-service.ts` and the sub-agent graph workflowId generators at `workflow-presence-lifecycle.ts:95` + `workflow-lifecycle-commands.ts:125` are untouched (per-invocation `wf-${Date.now().toString(36)}`, NOT legacy compat shims).
11
+ - No migration of legacy `wf-<sid>-compat` leases from 4.0.14 installs; they become orphans and are reaped by the 24h30m stale-started GC at `presence-lease-service.ts:42`.
12
+ - Tests: `tests/unit/services/skills/workflow-id-caller-derivation.test.ts` (8 cases: short / long / 200-char callerId boundary / graphRef D4a validation / 2-caller key isolation / legacy back-compat read / slice loss-free regression).
13
+
14
+ **Caller-binding becomes primary binding source + atomic write hygiene** (slice same, commit `97caa66b`):
15
+ - `src/services/session/session-binding-bridge.ts:184-310` `ensureSession` now uses a 3-tier read order: **`callers/<callerId>.json` (if `resolveCallerProjection` succeeds)** → `session.json` (fallback) → fresh-generate. Write path: BOTH files written on first bind or rotation, with `callers/<callerId>.json` FIRST (source of truth) + `session.json` SECOND (denormalized cache for legacy consumers). The lazy `resolveCallerProjection` import is wrapped in try/catch so `PEAKS_CALLER_NOT_RESOLVED` falls through to `session.json` (preserves legacy behavior).
16
+ - `src/services/session/session-manager.ts` adds internal helper `getSessionIdFromCallerBinding` (NOT exported; called from `getSessionId` and `getSessionIdCanonical` BEFORE `readSessionFile` / `readSessionFileCanonical`). Public signatures unchanged — all 14 `getSessionId(` consumers continue to compile and run without modification.
17
+ - `src/services/session/caller-binding-service.ts:113-133` `setCallerBinding` swaps non-atomic `writeFileSync` for `atomicWriteJson` (single-line, plus import + removal of the now-redundant `mkdirSync` block — atomicWriteJson does its own `mkdirSync(dir, { recursive: true })`).
18
+ - `src/cli/commands/outer-cache-commands.ts:125` `peaks outer-cache write` swaps non-atomic `writeFileSync` for `atomicWriteJson` (carry-forward bug from 4.0.14 QA issue #1: a power-loss mid-write left the cache in "permanent bad state" until the next SessionStart).
19
+ - Tests: `tests/unit/session/caller-binding-primary-source.test.ts` (11 cases: 3-tier read order / dual-write ordering / callerId-unresolved fallback / malformed file graceful fallback / `getSessionId` preservation / `skipRotate` honored / 14-consumer regression / `setCallerBinding` atomic / temp-file cleanup) + `tests/unit/cli/outer-cache-atomic-write.test.ts` (4 cases: writeFileSync residue check / atomicWriteJson call / 4.0.14 AC1-AC7 regression / simulated write failure).
20
+ - All 5 modified files stay under the 800 LOC cap: `session-binding-bridge.ts` 527 / `skill-presence-service.ts` 695 / `session-manager.ts` 618 / `caller-binding-service.ts` 204 / `outer-cache-commands.ts` 201. Regression sweep: 229/230 PASS / 1 SKIP (pre-existing Win-only conditional). `tsc -p tsconfig.json --noEmit` clean for slice files.
21
+
22
+ **Lockstep bump.** peaks-loop-shared `0.0.44 → 0.0.45` (CLI_VERSION re-stamped to 4.0.15); peaks-loop-mut `0.1.17 → 0.1.18`; peaks-loop-shared-channel `0.0.21 → 0.0.22`.
23
+
24
+ ## 4.0.14 — 2026-08-06 (outer-session cache + ensureSession meta over-coverage)
25
+
26
+ **Fixes the "5 terminals / 5 sessions stuck on 3fe1be" bug** (slice `2026-08-06-session-outer-cache-and-meta-coverage`, commit `f02a9b45`):
27
+ - `src/services/session/session-binding-bridge.ts` `getCurrentOuterSessionId(projectRoot)` now resolves `PEAKS_OUTER_SESSION_ID` → `CLAUDE_CODE_SESSION_ID` → file-cache at `.peaks/_runtime/.outer-session-cache.json` → `undefined`. Any IO error or non-string `outerSessionId` is a cache-miss (no throw). The cache file lives under the existing `.peaks/_runtime/` gitignore parent rule — no `.gitignore` modification.
28
+ - Same file: `ensureSession` early-return path now calls `setSessionMeta(projectRoot, existing.sessionId, { outerSessionId })` BEFORE returning. The on-disk `.peaks/_runtime/<sid>/session.json` always reflects the latest outerSessionId (not a stale value captured at first-bind time). All other meta fields (title / skill / mode / gate / createdAt) preserved via read-modify-write; `lastActivity` bumped to `now`.
29
+ - `src/services/skills/hooks-settings-service.ts` `resolveHookEntries('claude-code')` now appends a SessionStart entry that runs `peaks outer-cache write --project ...` to keep the cache in sync with the active Claude Code session. Uninstall strips the entry alongside the gate-enforce entry. Multi-binary drift guard (slice 2026-08-05) still fires correctly because the SessionStart entry uses the same `peaks` binary.
30
+ - New CLI: `peaks outer-cache write` (no flags) + `peaks outer-cache read` (returns `{ missing: true }` envelope on absent / malformed / IO error). Write exits 1 with `OUTER_CACHE_NO_ENV` when neither `PEAKS_OUTER_SESSION_ID` nor `CLAUDE_CODE_SESSION_ID` is set.
31
+ - Tests: `tests/unit/session/get-current-outer-session-id.test.ts` (10 cases pinning env > cache > undefined ordering, cache-miss / malformed JSON / IO error tolerance) + `tests/unit/session/ensure-session-meta-coverage.test.ts` (7 cases pinning AC8-AC11 over-coverage). Regression sweep: 179/179 PASS / 1 SKIP (pre-existing Win-only conditional).
32
+ - A.3 / A.4 / A.5 (workflowId by callerId + `session.json` migration + cross-outer rotation cases) explicitly reserved for a future session per PRD NG1-NG4.
33
+
34
+ **Lockstep bump.** peaks-loop-shared `0.0.43 → 0.0.44` (CLI_VERSION re-stamped to 4.0.14); peaks-loop-mut `0.1.16 → 0.1.17`; peaks-loop-shared-channel `0.0.20 → 0.0.21`.
35
+
3
36
  ## 4.0.13 — 2026-08-06 (statusline empty-render fix + sid-only marker + multi-binary drift guard)
4
37
 
5
38
  **Statusline callerId fallback + active `[short-sid]` suffix** (slice `2026-08-05-statusline-empty-render-and-short-sid-suffix`, commit `4be37d08`):
@@ -36,6 +36,7 @@ import { registerWorkflowEvalCommands } from './loop-eval-commands.js';
36
36
  import { registerMutCommands } from './mut-commands.js';
37
37
  import { registerObservabilityCommands } from './observability-commands.js';
38
38
  import { registerOpenSpecCommands } from './openspec-commands.js';
39
+ import { registerOuterCacheCommands } from './outer-cache-commands.js';
39
40
  import { registerPerfAuditCommands } from './perf-audit-commands.js';
40
41
  import { registerPerfCommands } from './perf-commands.js';
41
42
  import { registerPlaywrightCommands } from './playwright-commands.js';
@@ -127,6 +128,7 @@ const REGISTRATIONS = [
127
128
  ['adapter-commands-s2a', registerAdapterS2ACommands], ['polyrepo-commands', registerPolyrepoCommands],
128
129
  ['worktree-auth-commands', registerWorktreeAuthCommand],
129
130
  ['session-spill-demo', registerSpillDemoCommand],
131
+ ['outer-cache-commands', registerOuterCacheCommands],
130
132
  ];
131
133
  function dispatchRegister(register, program, io) {
132
134
  if (register.length <= 1) {
@@ -0,0 +1,11 @@
1
+ import type { Command } from 'commander';
2
+ import { type ProgramIO } from '../cli-helpers.js';
3
+ export type OuterCacheWriteOptions = {
4
+ readonly project?: string;
5
+ readonly json?: boolean;
6
+ };
7
+ export type OuterCacheReadOptions = {
8
+ readonly project?: string;
9
+ readonly json?: boolean;
10
+ };
11
+ export declare function registerOuterCacheCommands(program: Command, io: ProgramIO): void;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Slice 2026-08-06-session-outer-cache (G1 / G2) — per-project outer-session
3
+ * cache CLI surface.
4
+ *
5
+ * Writes and reads `.peaks/_runtime/.outer-session-cache.json` so that
6
+ * peaks CLI sub-processes (which typically do NOT inherit
7
+ * `CLAUDE_CODE_SESSION_ID` from the parent shell) can resolve the
8
+ * current outer session id via `getCurrentOuterSessionId(projectRoot)`
9
+ * in `src/services/session/session-binding-bridge.ts`.
10
+ *
11
+ * The SessionStart hook wired by `peaks hooks install` invokes
12
+ * `peaks outer-cache write` to keep this file in sync with the active
13
+ * Claude Code / Trae / IDE session. The file is under `.peaks/_runtime/`
14
+ * (gitignored), so no `.gitignore` change is required.
15
+ *
16
+ * Subcommand surface:
17
+ * - `peaks outer-cache write` (no flags) — read PEAKS_OUTER_SESSION_ID
18
+ * ?? CLAUDE_CODE_SESSION_ID, write the cache file, return JSON
19
+ * envelope with `{ outerSessionId, capturedAt, cachePath, written }`.
20
+ * - `peaks outer-cache read` — return
21
+ * `{ outerSessionId, capturedAt, cachePath }` when present or
22
+ * `{ missing: true, cachePath }` when not. Never throws on
23
+ * malformed JSON / IO error.
24
+ *
25
+ * Both subcommands accept `--project <path>` (defaults to cwd / git
26
+ * root) and `--json` (default in TTY-less invocations).
27
+ */
28
+ import { existsSync, readFileSync } from 'node:fs';
29
+ import { join } from 'node:path';
30
+ import { fail, ok } from 'peaks-loop-shared/result';
31
+ import { atomicWriteJson } from '../../services/ide/shared/atomic-json.js';
32
+ import { addJsonOption, printResult } from '../cli-helpers.js';
33
+ import { findProjectRoot } from '../../services/config/config-safety.js';
34
+ const OUTER_SESSION_CACHE_REL = join('.peaks', '_runtime', '.outer-session-cache.json');
35
+ function resolveCachePath(projectRoot) {
36
+ return join(projectRoot, OUTER_SESSION_CACHE_REL);
37
+ }
38
+ function readEnvOuter() {
39
+ const peaks = process.env.PEAKS_OUTER_SESSION_ID;
40
+ if (typeof peaks === 'string' && peaks.length > 0)
41
+ return peaks;
42
+ const claude = process.env.CLAUDE_CODE_SESSION_ID;
43
+ if (typeof claude === 'string' && claude.length > 0)
44
+ return claude;
45
+ return undefined;
46
+ }
47
+ function readCacheFile(cachePath) {
48
+ if (!existsSync(cachePath))
49
+ return { ok: false, missing: true };
50
+ try {
51
+ const raw = readFileSync(cachePath, 'utf8');
52
+ const parsed = JSON.parse(raw);
53
+ if (parsed !== null &&
54
+ typeof parsed === 'object' &&
55
+ typeof parsed.outerSessionId === 'string' &&
56
+ (parsed.outerSessionId).length > 0 &&
57
+ typeof parsed.capturedAt === 'string') {
58
+ return {
59
+ ok: true,
60
+ outerSessionId: parsed.outerSessionId,
61
+ capturedAt: parsed.capturedAt
62
+ };
63
+ }
64
+ return { ok: false, missing: true };
65
+ }
66
+ catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
67
+ return { ok: false, missing: true };
68
+ }
69
+ }
70
+ export function registerOuterCacheCommands(program, io) {
71
+ const outerCache = program
72
+ .command('outer-cache')
73
+ .description('Read / write the per-project outer-session cache file (.peaks/_runtime/.outer-session-cache.json). The SessionStart hook installed by `peaks hooks install` keeps this file in sync with the live Claude Code / Trae / IDE session so peaks CLI sub-processes (which typically do NOT inherit CLAUDE_CODE_SESSION_ID) can resolve the current outer session via getCurrentOuterSessionId(projectRoot).');
74
+ addJsonOption(outerCache
75
+ .command('write')
76
+ .description('Write the current outer-session-id (PEAKS_OUTER_SESSION_ID ?? CLAUDE_CODE_SESSION_ID) into the per-project cache file. Idempotent: re-runs overwrite. Exits 1 with OUTER_CACHE_NO_ENV when neither env var is set.')
77
+ .option('--project <path>', 'target project root (defaults to git root or cwd)')).action((options) => {
78
+ const projectRoot = options.project ?? (findProjectRoot(process.cwd()) ?? process.cwd());
79
+ const cachePath = resolveCachePath(projectRoot);
80
+ const outerSessionId = readEnvOuter();
81
+ if (outerSessionId === undefined) {
82
+ printResult(io, fail('outer-cache.write', 'OUTER_CACHE_NO_ENV', 'Neither PEAKS_OUTER_SESSION_ID nor CLAUDE_CODE_SESSION_ID is set; nothing to write', { cachePath, written: false, projectRoot }, [
83
+ 'Set PEAKS_OUTER_SESSION_ID=<id> or run from inside Claude Code / Trae / IDE so CLAUDE_CODE_SESSION_ID is exported',
84
+ 'Re-run `peaks hooks install` to wire the SessionStart hook that writes this cache automatically'
85
+ ]), options.json === true);
86
+ process.exitCode = 1;
87
+ return;
88
+ }
89
+ const capturedAt = new Date().toISOString();
90
+ const payload = { outerSessionId, capturedAt };
91
+ try {
92
+ // Slice 2026-08-06-session-cacde8-A.5c: atomic write (temp +
93
+ // rename) so a power-loss mid-write cannot leave the cache file
94
+ // truncated. `atomicWriteJson` owns its own
95
+ // `mkdirSync(dir, { recursive: true })` so the inline mkdir
96
+ // block is removed. The previous `writeFileSync` path was the
97
+ // 4.0.14 carry-forward QA issue #1; the bridge's cache-miss
98
+ // fallback treated the truncated state as a miss (safe) but
99
+ // the file was stuck in "permanent bad state" until the next
100
+ // SessionStart fired.
101
+ atomicWriteJson(cachePath, payload);
102
+ }
103
+ catch (error) {
104
+ const message = error instanceof Error ? error.message : String(error);
105
+ printResult(io, fail('outer-cache.write', 'OUTER_CACHE_WRITE_FAILED', `Failed to write outer-session cache: ${message}`, { cachePath, written: false, projectRoot, outerSessionId, capturedAt }, [message]), options.json === true);
106
+ process.exitCode = 1;
107
+ return;
108
+ }
109
+ printResult(io, ok('outer-cache.write', { cachePath, written: true, projectRoot, outerSessionId, capturedAt }, [], [`Wrote ${cachePath}; getCurrentOuterSessionId() will resolve to "${outerSessionId}" until the next SessionStart fires.`]), options.json === true);
110
+ });
111
+ addJsonOption(outerCache
112
+ .command('read')
113
+ .description('Read the current value of the per-project outer-session cache. Returns { missing: true, cachePath } when the file is absent / malformed / empty; never throws on IO error.')
114
+ .option('--project <path>', 'target project root (defaults to git root or cwd)')).action((options) => {
115
+ const projectRoot = options.project ?? (findProjectRoot(process.cwd()) ?? process.cwd());
116
+ const cachePath = resolveCachePath(projectRoot);
117
+ const result = readCacheFile(cachePath);
118
+ if (result.ok) {
119
+ printResult(io, ok('outer-cache.read', { cachePath, missing: false, projectRoot, outerSessionId: result.outerSessionId, capturedAt: result.capturedAt }, [], [`outer-session-id resolved: ${result.outerSessionId} (captured ${result.capturedAt})`]), options.json === true);
120
+ return;
121
+ }
122
+ printResult(io, ok('outer-cache.read', { cachePath, missing: true, projectRoot }, ['No outer-session cache present — getCurrentOuterSessionId() will return undefined for this project.'], [
123
+ 'Re-run `peaks hooks install` to wire the SessionStart hook that writes this cache automatically',
124
+ 'Or set PEAKS_OUTER_SESSION_ID=<id> so env-first resolution wins without touching the cache'
125
+ ]), options.json === true);
126
+ });
127
+ }
@@ -24,14 +24,14 @@ export declare const InstanceRecordSchema: z.ZodObject<{
24
24
  callerId: z.ZodString;
25
25
  lastHeartbeat: z.ZodString;
26
26
  }, "strip", z.ZodTypeAny, {
27
+ callerId: string;
27
28
  lastHeartbeat: string;
28
29
  startedAt: string;
29
30
  roles: string[];
30
- callerId: string;
31
31
  }, {
32
+ callerId: string;
32
33
  lastHeartbeat: string;
33
34
  startedAt: string;
34
- callerId: string;
35
35
  roles?: string[] | undefined;
36
36
  }>;
37
37
  export declare const BindingSchema: z.ZodObject<{
@@ -45,14 +45,14 @@ export declare const BindingSchema: z.ZodObject<{
45
45
  callerId: z.ZodString;
46
46
  lastHeartbeat: z.ZodString;
47
47
  }, "strip", z.ZodTypeAny, {
48
+ callerId: string;
48
49
  lastHeartbeat: string;
49
50
  startedAt: string;
50
51
  roles: string[];
51
- callerId: string;
52
52
  }, {
53
+ callerId: string;
53
54
  lastHeartbeat: string;
54
55
  startedAt: string;
55
- callerId: string;
56
56
  roles?: string[] | undefined;
57
57
  }>>;
58
58
  }, "strip", z.ZodTypeAny, {
@@ -61,10 +61,10 @@ export declare const BindingSchema: z.ZodObject<{
61
61
  pid: number;
62
62
  scope: string;
63
63
  instances: Record<string, {
64
+ callerId: string;
64
65
  lastHeartbeat: string;
65
66
  startedAt: string;
66
67
  roles: string[];
67
- callerId: string;
68
68
  }>;
69
69
  }, {
70
70
  lastHeartbeat: string;
@@ -72,9 +72,9 @@ export declare const BindingSchema: z.ZodObject<{
72
72
  pid: number;
73
73
  scope: string;
74
74
  instances: Record<string, {
75
+ callerId: string;
75
76
  lastHeartbeat: string;
76
77
  startedAt: string;
77
- callerId: string;
78
78
  roles?: string[] | undefined;
79
79
  }>;
80
80
  }>;
@@ -59,6 +59,13 @@ export declare function getCallerBinding(projectRoot: string, callerId: string):
59
59
  * for the binding object (callerId must match the file stem, peakSessionId
60
60
  * must be a valid session id, projectRoot is canonicalized). Idempotent:
61
61
  * re-writing the same callerId overwrites the file.
62
+ *
63
+ * Slice 2026-08-06-session-cacde8-A.5b: writes via `atomicWriteJson`
64
+ * (temp-file-then-rename) so a power-loss mid-write cannot leave the
65
+ * caller-binding file in a half-truncated state. `atomicWriteJson`
66
+ * owns its own `mkdirSync(dir, { recursive: true })`, so the inline
67
+ * mkdir block is removed. The previous `writeFileSync` path was a
68
+ * documented 4.0.14 carry-forward micro-fix from QA's issue #1.
62
69
  */
63
70
  export declare function setCallerBinding(projectRoot: string, callerId: string, binding: CallerBinding): void;
64
71
  /**
@@ -16,9 +16,10 @@
16
16
  * See `.peaks/_runtime/2026-06-09-session-8bfe7d/prd/source/caller-id-contract.md`
17
17
  * for the freeze-in contract.
18
18
  */
19
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
19
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
20
20
  import { createHash } from 'node:crypto';
21
- import { dirname, join, resolve } from 'node:path';
21
+ import { join, resolve } from 'node:path';
22
+ import { atomicWriteJson } from '../ide/shared/atomic-json.js';
22
23
  import { CALLER_ID_REGEX } from './caller-id-types.js';
23
24
  import { getSessionDir } from './getSessionDir.js';
24
25
  /**
@@ -100,21 +101,24 @@ export function getCallerBinding(projectRoot, callerId) {
100
101
  * for the binding object (callerId must match the file stem, peakSessionId
101
102
  * must be a valid session id, projectRoot is canonicalized). Idempotent:
102
103
  * re-writing the same callerId overwrites the file.
104
+ *
105
+ * Slice 2026-08-06-session-cacde8-A.5b: writes via `atomicWriteJson`
106
+ * (temp-file-then-rename) so a power-loss mid-write cannot leave the
107
+ * caller-binding file in a half-truncated state. `atomicWriteJson`
108
+ * owns its own `mkdirSync(dir, { recursive: true })`, so the inline
109
+ * mkdir block is removed. The previous `writeFileSync` path was a
110
+ * documented 4.0.14 carry-forward micro-fix from QA's issue #1.
103
111
  */
104
112
  export function setCallerBinding(projectRoot, callerId, binding) {
105
113
  if (binding.callerId !== callerId) {
106
114
  throw new Error(`setCallerBinding: binding.callerId "${binding.callerId}" does not match callerId "${callerId}"`);
107
115
  }
108
116
  const bindingPath = getCallerBindingFile(projectRoot, callerId);
109
- const dir = dirname(bindingPath);
110
- if (!existsSync(dir)) {
111
- mkdirSync(dir, { recursive: true });
112
- }
113
117
  const payload = {
114
118
  ...binding,
115
119
  projectRoot: resolve(binding.projectRoot)
116
120
  };
117
- writeFileSync(bindingPath, JSON.stringify(payload, null, 2), 'utf8');
121
+ atomicWriteJson(bindingPath, payload);
118
122
  }
119
123
  /**
120
124
  * Enumerate the per-caller binding files under
@@ -18,6 +18,12 @@
18
18
  * Body of every function moved verbatim per Karpathy #3 (Surgical
19
19
  * Changes). No behavior change. The bridge adds nothing of its own.
20
20
  */
21
+ /**
22
+ * Test-only seam: reset the module-scoped `lastResolvedOuter` so
23
+ * individual tests start with a clean slate. NOT exported as part
24
+ * of the public API; marked TODO(g2) for the v2.14.0 grace window.
25
+ */
26
+ export declare function _resetLastResolvedOuterForTest(): void;
21
27
  export type EnsureSessionOptions = {
22
28
  /**
23
29
  * When `true`, suppress the outer-session-mismatch auto-rotation.
@@ -52,7 +58,7 @@ export declare function ensureSession(projectRoot: string): Promise<string>;
52
58
  * binding changes — and the rotation is surfaced in the return value
53
59
  * so the CLI can include it in the JSON envelope.
54
60
  *
55
- * Rotation is suppressed in three cases (all false-positive guards):
61
+ * Rotation is suppressed in four cases (all false-positive guards):
56
62
  *
57
63
  * 1. The current outer session id is undefined (no env var set) —
58
64
  * there is no signal to compare against, defaulting to "do not
@@ -63,6 +69,16 @@ export declare function ensureSession(projectRoot: string): Promise<string>;
63
69
  * 3. The bound session's recorded outer session id matches the
64
70
  * current one (reconnect within the same Claude session) — this
65
71
  * is the common case, not a swap.
72
+ * 4. Slice 2026-08-06-session-cacde8-A.3: same-process re-resolve —
73
+ * `lastResolvedOuter.value` was already set to `currentOuterSessionId`
74
+ * in this process and `boundOuter` equals that same value. Long-
75
+ * running presence-lease writers that re-resolve within a single
76
+ * process must not rotate on every CLI heartbeat. The module-scoped
77
+ * state is per-process; each fresh CLI invocation starts with
78
+ * `lastResolvedOuter === null`, so this guard cannot leak across
79
+ * processes. The 4th guard ONLY fires when `lastResolvedOuter.value`
80
+ * is a defined string (an undefined last-resolved value still
81
+ * falls through to the legacy comparison path).
66
82
  *
67
83
  * When `options.skipRotateOnOuterMismatch === true`, the rotation
68
84
  * check is short-circuited and the binding is preserved (opt-out for
@@ -23,11 +23,20 @@ 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 { getCallerBinding, setCallerBinding, } from './caller-binding-service.js';
27
+ import { resolveCallerProjection } from './resolve-caller-id.js';
28
+ import { getSessionId, getSessionIdCanonical, getSessionMeta, rotateSessionBinding, setSessionMeta } from './session-manager.js';
27
29
  // --- Lower-level helpers the bridge needs (moved verbatim) ---
28
30
  const SESSION_FILE = join('_runtime', 'session.json');
29
31
  const LEGACY_SESSION_FILE = '.session.json';
30
32
  const META_FILE = 'session.json';
33
+ // Slice 2026-08-06-session-outer-cache: per-project file cache for the
34
+ // outer (Claude Code / Trae / IDE) session id. Written by the
35
+ // SessionStart hook via `peaks outer-cache write` so that peaks CLI
36
+ // sub-processes (which do NOT inherit CLAUDE_CODE_SESSION_ID) can still
37
+ // resolve the current outer session. Lives under the gitignored
38
+ // `.peaks/_runtime/` tree, so no .gitignore change is required.
39
+ const OUTER_SESSION_CACHE_FILE = join('_runtime', '.outer-session-cache.json');
31
40
  function getLegacySessionFilePath(projectRoot) {
32
41
  return join(projectRoot, '.peaks', LEGACY_SESSION_FILE);
33
42
  }
@@ -124,18 +133,149 @@ function writeSessionMeta(projectRoot, sessionId, meta) {
124
133
  }
125
134
  writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf8');
126
135
  }
127
- function getCurrentOuterSessionId() {
136
+ // Slice 2026-08-06-session-cacde8-A.3: module-scoped state populated on
137
+ // every non-throw `getCurrentOuterSessionId` call. The 4th rotation
138
+ // guard in `ensureSessionWithRotation` short-circuits when both
139
+ // `currentOuterSessionId` and `boundOuter` equal the last-resolved
140
+ // value in this process — a common case for long-running presence-
141
+ // lease writers that re-resolve the outer session id within the same
142
+ // process. Each CLI invocation is a fresh process, so this guard is
143
+ // per-invocation and cannot leak across processes.
144
+ let lastResolvedOuter = null;
145
+ function getCurrentOuterSessionId(projectRoot) {
146
+ let resolved;
128
147
  const peaks = process.env.PEAKS_OUTER_SESSION_ID;
129
- if (typeof peaks === 'string' && peaks.length > 0)
130
- return peaks;
131
- const claude = process.env.CLAUDE_CODE_SESSION_ID;
132
- if (typeof claude === 'string' && claude.length > 0)
133
- return claude;
134
- return undefined;
148
+ if (typeof peaks === 'string' && peaks.length > 0) {
149
+ resolved = peaks;
150
+ }
151
+ else {
152
+ const claude = process.env.CLAUDE_CODE_SESSION_ID;
153
+ if (typeof claude === 'string' && claude.length > 0) {
154
+ resolved = claude;
155
+ }
156
+ else {
157
+ // Slice 2026-08-06-session-outer-cache (G1): when the peaks CLI runs
158
+ // as a sub-process of Claude Code (or any IDE that does not export
159
+ // CLAUDE_CODE_SESSION_ID into the child env), the env vars above are
160
+ // undefined. Fall back to the per-project file cache written by the
161
+ // SessionStart hook via `peaks outer-cache write`. The file lives
162
+ // under `.peaks/_runtime/` (gitignored) so no .gitignore change is
163
+ // required. Any IO error or non-string `outerSessionId` field is
164
+ // treated as a cache miss — never throw.
165
+ if (projectRoot !== undefined) {
166
+ const cachePath = join(projectRoot, '.peaks', OUTER_SESSION_CACHE_FILE);
167
+ if (existsSync(cachePath)) {
168
+ try {
169
+ const raw = readFileSync(cachePath, 'utf8');
170
+ const parsed = JSON.parse(raw);
171
+ if (parsed !== null &&
172
+ typeof parsed === 'object' &&
173
+ typeof parsed.outerSessionId === 'string' &&
174
+ (parsed.outerSessionId).length > 0) {
175
+ resolved = parsed.outerSessionId;
176
+ }
177
+ }
178
+ catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
179
+ // fall through — file missing / malformed JSON / IO error → undefined
180
+ }
181
+ }
182
+ }
183
+ }
184
+ }
185
+ // Record the resolved value on every non-throw call (env hit,
186
+ // cache hit, or undefined fallback). The 4th rotation guard reads
187
+ // this field to short-circuit same-process re-resolves.
188
+ lastResolvedOuter = { value: resolved, resolvedAt: Date.now() };
189
+ return resolved;
190
+ }
191
+ /**
192
+ * Test-only seam: reset the module-scoped `lastResolvedOuter` so
193
+ * individual tests start with a clean slate. NOT exported as part
194
+ * of the public API; marked TODO(g2) for the v2.14.0 grace window.
195
+ */
196
+ export function _resetLastResolvedOuterForTest() {
197
+ lastResolvedOuter = null;
198
+ }
199
+ /**
200
+ * Slice 2026-08-06-session-cacde8-A.5a: resolve the current callerId
201
+ * (if any) and look up the per-caller binding. Returns the bound
202
+ * sessionId + creation timestamp, or `null` when caller-id
203
+ * resolution fails (`PEAKS_CALLER_NOT_RESOLVED`) or no per-caller
204
+ * file exists. The per-caller file is the 4.0.8 source of truth;
205
+ * `ensureSession` consults it BEFORE `readSessionFile` so a binding
206
+ * written by a previous call from the same caller is preferred over
207
+ * a stale `session.json` (the legacy single-file binding).
208
+ */
209
+ function resolveCallerBindingForEnsure(projectRoot) {
210
+ let projection;
211
+ try {
212
+ projection = resolveCallerProjection({ projectRoot, env: process.env });
213
+ }
214
+ catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
215
+ return null;
216
+ }
217
+ try {
218
+ const binding = getCallerBinding(projectRoot, projection.callerId);
219
+ if (binding === null)
220
+ return null;
221
+ return {
222
+ sessionId: binding.peakSessionId,
223
+ createdAt: binding.createdAt,
224
+ callerId: projection.callerId
225
+ };
226
+ }
227
+ catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
228
+ return null;
229
+ }
135
230
  }
136
231
  export async function ensureSession(projectRoot) {
232
+ // Slice 2026-08-06-session-cacde8-A.5a: 3-tier read order —
233
+ // (1) per-caller binding (caller-keyed, primary), (2) legacy
234
+ // `session.json`, (3) fresh-generate. The per-caller lookup is
235
+ // preferred when `resolveCallerProjection` succeeds AND the
236
+ // per-caller file exists. When `resolveCallerProjection` throws
237
+ // `PEAKS_CALLER_NOT_RESOLVED` we fall through to the session.json
238
+ // path; the legacy fallback preserves existing CLI behaviour for
239
+ // projects without an active IDE adapter.
240
+ const callerBinding = resolveCallerBindingForEnsure(projectRoot);
241
+ if (callerBinding !== null) {
242
+ // Already-bound path via caller-binding (primary). Stamp the
243
+ // current outer-session-id onto the bound session's meta (G3).
244
+ const outerSessionId = getCurrentOuterSessionId(projectRoot);
245
+ if (outerSessionId !== undefined) {
246
+ setSessionMeta(projectRoot, callerBinding.sessionId, { outerSessionId });
247
+ }
248
+ // Slice 2026-08-06-session-cacde8-A.5a: dual-write — also write
249
+ // the legacy `session.json` (denormalized cache) when missing,
250
+ // so legacy consumers (e.g. `getSessionId` callers without a
251
+ // resolved callerId) still find the binding. The per-caller
252
+ // file is the source of truth; `session.json` is regenerated
253
+ // here for back-compat.
254
+ if (readSessionFile(projectRoot) === null) {
255
+ writeSessionFile(projectRoot, {
256
+ sessionId: callerBinding.sessionId,
257
+ createdAt: callerBinding.createdAt,
258
+ projectRoot
259
+ });
260
+ }
261
+ return callerBinding.sessionId;
262
+ }
137
263
  const existing = readSessionFile(projectRoot);
138
264
  if (existing) {
265
+ // Slice 2026-08-06-session-outer-cache (G3 / AC8-AC11): on every
266
+ // already-bound invocation, stamp the current outer-session-id
267
+ // onto the bound session's meta so the on-disk
268
+ // `.peaks/_runtime/<sid>/session.json` always reflects the latest
269
+ // outer signal — not a stale value captured at session creation
270
+ // time. `setSessionMeta` is read-modify-write: every other field
271
+ // (title / skill / mode / gate / createdAt / lastActivity) is
272
+ // preserved. `getCurrentOuterSessionId(projectRoot)` reads env
273
+ // → file-cache → undefined; an undefined value is NOT written,
274
+ // so the previously-stamped outer (if any) survives.
275
+ const outerSessionId = getCurrentOuterSessionId(projectRoot);
276
+ if (outerSessionId !== undefined) {
277
+ setSessionMeta(projectRoot, existing.sessionId, { outerSessionId });
278
+ }
139
279
  return existing.sessionId;
140
280
  }
141
281
  // Slice 007 — sub-agent session sharing. When the strict-equality
@@ -163,10 +303,46 @@ export async function ensureSession(projectRoot) {
163
303
  createdAt: now,
164
304
  projectRoot
165
305
  };
306
+ // Slice 2026-08-06-session-cacde8-A.5a: dual-write on fresh-generate.
307
+ // The per-caller file is the 4.0.8 source of truth; the legacy
308
+ // `session.json` is a denormalized cache for consumers that have
309
+ // not migrated to `getCallerBinding`. Per-caller file is written
310
+ // FIRST; on partial-write failure of either file, the next
311
+ // `ensureSession` will re-derive `session.json` from the
312
+ // per-caller file via the tier-1 read path above.
313
+ const newCallerId = (() => {
314
+ try {
315
+ return resolveCallerProjection({ projectRoot, env: process.env }).callerId;
316
+ }
317
+ catch {
318
+ return null;
319
+ }
320
+ })();
321
+ if (newCallerId !== null) {
322
+ const payload = {
323
+ callerId: newCallerId,
324
+ peakSessionId: sessionId,
325
+ projectRoot,
326
+ createdAt: now,
327
+ lastActivityAt: now,
328
+ skill: 'peaks-code',
329
+ mode: 'unknown',
330
+ gate: 'startup'
331
+ };
332
+ try {
333
+ setCallerBinding(projectRoot, newCallerId, payload);
334
+ }
335
+ catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
336
+ // best effort; session.json write below is the legacy fallback
337
+ }
338
+ }
166
339
  writeSessionFile(projectRoot, info);
167
340
  await initWorkspace({ projectRoot, sessionId });
168
- // Initialize session metadata inside the session directory
169
- const outerSessionId = getCurrentOuterSessionId();
341
+ // Initialize session metadata inside the session directory.
342
+ // Slice 2026-08-06-session-outer-cache (G1): pass `projectRoot` so
343
+ // the env → file-cache → undefined resolution chain also reads the
344
+ // SessionStart-written cache on first bind.
345
+ const outerSessionId = getCurrentOuterSessionId(projectRoot);
170
346
  writeSessionMeta(projectRoot, sessionId, {
171
347
  sessionId,
172
348
  projectRoot,
@@ -188,7 +364,7 @@ export async function ensureSession(projectRoot) {
188
364
  * binding changes — and the rotation is surfaced in the return value
189
365
  * so the CLI can include it in the JSON envelope.
190
366
  *
191
- * Rotation is suppressed in three cases (all false-positive guards):
367
+ * Rotation is suppressed in four cases (all false-positive guards):
192
368
  *
193
369
  * 1. The current outer session id is undefined (no env var set) —
194
370
  * there is no signal to compare against, defaulting to "do not
@@ -199,6 +375,16 @@ export async function ensureSession(projectRoot) {
199
375
  * 3. The bound session's recorded outer session id matches the
200
376
  * current one (reconnect within the same Claude session) — this
201
377
  * is the common case, not a swap.
378
+ * 4. Slice 2026-08-06-session-cacde8-A.3: same-process re-resolve —
379
+ * `lastResolvedOuter.value` was already set to `currentOuterSessionId`
380
+ * in this process and `boundOuter` equals that same value. Long-
381
+ * running presence-lease writers that re-resolve within a single
382
+ * process must not rotate on every CLI heartbeat. The module-scoped
383
+ * state is per-process; each fresh CLI invocation starts with
384
+ * `lastResolvedOuter === null`, so this guard cannot leak across
385
+ * processes. The 4th guard ONLY fires when `lastResolvedOuter.value`
386
+ * is a defined string (an undefined last-resolved value still
387
+ * falls through to the legacy comparison path).
202
388
  *
203
389
  * When `options.skipRotateOnOuterMismatch === true`, the rotation
204
390
  * check is short-circuited and the binding is preserved (opt-out for
@@ -211,7 +397,10 @@ export async function ensureSession(projectRoot) {
211
397
  */
212
398
  export async function ensureSessionWithRotation(projectRoot, options) {
213
399
  const skipRotate = options?.skipRotateOnOuterMismatch === true;
214
- const currentOuterSessionId = getCurrentOuterSessionId();
400
+ // Slice 2026-08-06-session-outer-cache (G1): pass `projectRoot` so
401
+ // the rotation-decision comparison sees the SessionStart cache
402
+ // when the env vars are unset (sub-process / nested invocation).
403
+ const currentOuterSessionId = getCurrentOuterSessionId(projectRoot);
215
404
  // Compute the rotation decision up front. We only rotate when ALL
216
405
  // three pre-conditions hold: (a) the current outer session id is
217
406
  // defined, (b) the bound session has a recorded outer session id,
@@ -224,6 +413,26 @@ export async function ensureSessionWithRotation(projectRoot, options) {
224
413
  if (boundSessionId !== null && currentOuterSessionId !== undefined) {
225
414
  const boundMeta = getSessionMeta(projectRoot, boundSessionId);
226
415
  const boundOuter = boundMeta?.outerSessionId;
416
+ // Slice 2026-08-06-session-cacde8-A.3: 4th guard — same-process
417
+ // re-resolve. When `lastResolvedOuter` was already set to the
418
+ // current outer AND the bound session's recorded outer equals
419
+ // the same value, the rotation decision was already evaluated
420
+ // in this process. Without this short-circuit, a long-running
421
+ // presence-lease writer would rotate on every heartbeat within
422
+ // the same process. Skipping only fires when `lastResolvedOuter`
423
+ // is a defined string (undefined last-resolved values fall
424
+ // through to the legacy comparison path so the first-ever
425
+ // resolve still works).
426
+ if (lastResolvedOuter?.value !== undefined &&
427
+ lastResolvedOuter.value === currentOuterSessionId &&
428
+ lastResolvedOuter.value === boundOuter) {
429
+ const sessionId = await ensureSession(projectRoot);
430
+ return {
431
+ sessionId,
432
+ previousSessionId: null,
433
+ rotationReason: null
434
+ };
435
+ }
227
436
  if (typeof boundOuter === 'string' &&
228
437
  boundOuter.length > 0 &&
229
438
  boundOuter !== currentOuterSessionId &&
@@ -11,6 +11,8 @@ import { dirname, join, resolve } from 'node:path';
11
11
  import { randomBytes } from 'node:crypto';
12
12
  import { projectRootsMatch, stableRealPath } from '../../shared/path-utils.js';
13
13
  import { ensureSession } from './session-binding-bridge.js';
14
+ import { getCallerBinding } from './caller-binding-service.js';
15
+ import { resolveCallerProjection } from './resolve-caller-id.js';
14
16
  // As of slice 2026-06-05-peaks-runtime-layer the project-level session
15
17
  // binding lives under `.peaks/_runtime/session.json`. The legacy
16
18
  // `.peaks/.session.json` path is preserved as a read-only fallback for one
@@ -398,6 +400,18 @@ export { ensureSession, ensureSessionWithRotation } from './session-binding-brid
398
400
  * @returns Session ID or null
399
401
  */
400
402
  export function getSessionId(projectRoot) {
403
+ // Slice 2026-08-06-session-cacde8-A.5a: caller-binding becomes
404
+ // primary source. Read order is (1) `getCallerBinding` if
405
+ // `resolveCallerProjection` succeeds, (2) `readSessionFile`
406
+ // (the legacy session.json), (3) null (no binding). The legacy
407
+ // session.json is preserved as a fallback so a project without
408
+ // a caller-id resolution still resolves its binding (e.g. CLI
409
+ // run from a stock shell with no IDE adapter). `PEAKS_CALLER_NOT_RESOLVED`
410
+ // falls through to the session.json path; no caller-facing exit
411
+ // change.
412
+ const fromCallerBinding = getSessionIdFromCallerBinding(projectRoot);
413
+ if (fromCallerBinding !== null)
414
+ return fromCallerBinding;
401
415
  const info = readSessionFile(projectRoot);
402
416
  return info?.sessionId ?? null;
403
417
  }
@@ -429,9 +443,32 @@ export function getSessionId(projectRoot) {
429
443
  * so this variant is opt-in.
430
444
  */
431
445
  export function getSessionIdCanonical(projectRoot) {
446
+ // Slice 2026-08-06-session-cacde8-A.5a: same caller-binding primary
447
+ // lookup as `getSessionId`; fall back to the canonical-fallback
448
+ // `readSessionFileCanonical` if caller-binding is absent / unresolved.
449
+ const fromCallerBinding = getSessionIdFromCallerBinding(projectRoot);
450
+ if (fromCallerBinding !== null)
451
+ return fromCallerBinding;
432
452
  const info = readSessionFileCanonical(projectRoot);
433
453
  return info?.sessionId ?? null;
434
454
  }
455
+ /**
456
+ * Internal helper: read the caller-binding primary source.
457
+ * Returns `null` when caller-id resolution fails
458
+ * (`PEAKS_CALLER_NOT_RESOLVED`), when no per-caller file exists,
459
+ * or when the file is malformed. NOT exported; the public surface
460
+ * is still `getSessionId` / `getSessionIdCanonical`.
461
+ */
462
+ function getSessionIdFromCallerBinding(projectRoot) {
463
+ try {
464
+ const projection = resolveCallerProjection({ projectRoot, env: process.env });
465
+ const binding = getCallerBinding(projectRoot, projection.callerId);
466
+ return binding?.peakSessionId ?? null;
467
+ }
468
+ catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
469
+ return null;
470
+ }
471
+ }
435
472
  /**
436
473
  * Get the absolute path to the current session directory.
437
474
  * Creates the session if it doesn't exist.
@@ -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';
@@ -319,7 +319,19 @@ export function setSkillPresence(skill, mode, gate, projectRootOverride) {
319
319
  import('./presence-lease-service.js'),
320
320
  ]);
321
321
  const projection = resolveCallerProjection({ projectRoot, env: process.env });
322
- const workflowId = `wf-${sessionId}-compat`;
322
+ // Slice 2026-08-06-session-cacde8-A.4: derive the legacy compat
323
+ // shim's workflowId from `projection.callerId` (caller-keyed,
324
+ // matching the 4.0.8 caller-binding direction) instead of
325
+ // `sessionId` (sid-keyed, pre-4.0.8). `slice(0, 189)` caps the
326
+ // callerId to 189 chars so the resulting workflowId
327
+ // (`wf-` + callerId + `-compat`) stays under the 200-char
328
+ // `WORKFLOW_ID_REGEX = /^[a-zA-Z0-9._-]{1,200}$/` cap. The
329
+ // truncation is loss-free for regex purposes — callerId is
330
+ // already regex-conformant before the slice. Existing 4.0.14
331
+ // leases with `wf-<sid>-compat` become orphans and are GC'd
332
+ // by the 24h30m stale-started GC at
333
+ // presence-lease-service.ts:42.
334
+ const workflowId = `wf-${projection.callerId.slice(0, 189)}-compat`;
323
335
  const result = leaseMod.setPresenceLease({
324
336
  projectRoot,
325
337
  sessionId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-loop",
3
- "version": "4.0.13",
3
+ "version": "4.0.15",
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-shared": "0.0.44",
104
- "peaks-loop-mut": "0.1.17",
105
- "peaks-loop-shared-channel": "0.0.21"
103
+ "peaks-loop-mut": "0.1.19",
104
+ "peaks-loop-shared-channel": "0.0.23",
105
+ "peaks-loop-shared": "0.0.46"
106
106
  },
107
107
  "peerDependencies": {
108
108
  "@alibaba-group/open-code-review": "1.3.1"