peaks-cli 1.3.7 → 1.3.8

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.
@@ -1,3 +1,5 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
1
3
  import { createArtifactInitPlan, getArtifactStatus, createGuidedArtifactSetup } from '../../services/artifacts/artifact-service.js';
2
4
  import { getArtifactWorkspaceStatus, planArtifactSync } from '../../services/artifacts/workspace-service.js';
3
5
  import { executeProjectMemoryBackup, executeProjectMemoryExtract, summarizeProjectMemoryBackupResult, summarizeProjectMemoryExtractResult } from '../../services/memory/project-memory-service.js';
@@ -206,26 +208,129 @@ export function registerCoreAndArtifactCommands(program, io) {
206
208
  });
207
209
  addJsonOption(session
208
210
  .command('info [sessionId]')
209
- .description('Show full metadata for a session directory. Pass --active to resolve the canonical binding from .peaks/_runtime/session.json (the "one command a sub-agent runs to find the parent\'s sid" primitive).')
210
- .option('--active', 'resolve the canonical session id from .peaks/_runtime/session.json (ignores [sessionId] when set)')).action(async (sessionId, options) => {
211
- const projectRoot = findProjectRoot(process.cwd()) ?? process.cwd();
212
- // Slice 007sub-agent session sharing. A sub-agent that does
213
- // not know the parent's sid reads it from the binding via
214
- // `peaks session info --active`. The call uses the
215
- // canonicalize-on-read path so a stored "projectRoot: '.'" and a
216
- // caller-passed absolute realpath both resolve to the same
217
- // binding. Without this primitive the sub-agent has no way to
218
- // discover the parent sid short of scanning the filesystem.
211
+ .description('Show full metadata for a session directory. Pass --active to resolve the canonical binding from .peaks/_runtime/session.json (the "one command a sub-agent runs to find the parent\'s sid" primitive). Slice 021: --active is the SOLE authoritative way to look up the active session id; the on-disk file path is internal and must NOT be `cat`-ed directly.')
212
+ .option('--active', 'resolve the canonical session id from .peaks/_runtime/session.json (ignores [sessionId] when set)')
213
+ .option('--project <path>', 'target project root (defaults to git root or cwd). Slice 021: lets sub-agents skip the cwd heuristic and look up the binding for a specific repo.')
214
+ // Slice 020caller-keyed session binding. The --caller-id flag
215
+ // overrides the per-process PEAKS_CALLER_ID env var and the
216
+ // PLATFORM_FALLBACKS table (D4 priority). The resolved callerId is
217
+ // surfaced in the JSON envelope so callers can confirm what was
218
+ // resolved without re-deriving it.
219
+ .option('--caller-id <id>', 'Override the caller id for this invocation (D4 priority: flag beats env beats platform fallback). When set, the response envelope includes the resolved callerId.')).action(async (sessionId, options) => {
220
+ // Slice 021: --project wins; otherwise the git-root / cwd fallback
221
+ // (matches the pre-021 behaviour so the existing slice-020 / slice-007
222
+ // sub-agent flow keeps working unchanged).
223
+ const projectRoot = options.project !== undefined
224
+ ? resolveCanonicalProjectRoot(options.project)
225
+ : (findProjectRoot(process.cwd()) ?? process.cwd());
226
+ // Slice 020 — resolve the callerId up front when the flag was passed.
227
+ // We use `resolveCallerId` for D1/D5 validation; an invalid flag
228
+ // throws CallerIdError (D5 → exit 65). A missing flag and no env
229
+ // and no fallback also throws (D2 → exit 64). The resolved id is
230
+ // surfaced in the envelope so the caller can audit.
231
+ if (options.callerId !== undefined) {
232
+ const { resolveCallerId, CallerIdError } = await import('../../services/session/resolve-caller-id.js');
233
+ let callerId;
234
+ try {
235
+ callerId = resolveCallerId({ flagValue: options.callerId });
236
+ }
237
+ catch (error) {
238
+ if (error instanceof CallerIdError) {
239
+ const code = error.code === 'EX_USAGE' ? 64 : 65;
240
+ printResult(io, fail('session.info', 'CALLER_ID_INVALID', error.message, { source: error.source }, [`Set --caller-id to a value matching ^[a-zA-Z0-9._-]{1,200}$`, 'Or set PEAKS_CALLER_ID env var (or CLAUDE_CODE_SESSION_ID for Claude Code)']), options.json);
241
+ process.exitCode = code;
242
+ return;
243
+ }
244
+ throw error;
245
+ }
246
+ // Surface the resolved id in the envelope. When --active is also
247
+ // passed, look up the binding for this callerId; otherwise
248
+ // just emit the resolved id so the caller knows what was used.
249
+ if (options.active === true) {
250
+ const { getSessionIdCanonical } = await import('../../services/session/session-manager.js');
251
+ const { getCallerBinding } = await import('../../services/session/caller-binding-service.js');
252
+ const activeSid = getSessionIdCanonical(projectRoot);
253
+ const callerBinding = getCallerBinding(projectRoot, callerId);
254
+ // Slice 021: source is the enum that the unified --active primitive
255
+ // reports. Callers / migration tooling can detect pre-migration trees
256
+ // by inspecting `source === 'legacy'`.
257
+ const bindingSource = existsSync(join(projectRoot, '.peaks', '_runtime', 'session.json'))
258
+ ? 'canonical'
259
+ : 'legacy';
260
+ printResult(io, ok('session.info', {
261
+ active: true,
262
+ sessionId: activeSid,
263
+ callerId,
264
+ callerBindingPeakSessionId: callerBinding?.peakSessionId ?? null,
265
+ source: bindingSource
266
+ }), options.json);
267
+ return;
268
+ }
269
+ printResult(io, ok('session.info', { callerId, note: '--caller-id resolved; pass --active to also look up the bound peak session' }), options.json);
270
+ return;
271
+ }
272
+ // Slice 007 + slice 021 — sub-agent session sharing. A sub-agent that
273
+ // does not know the parent's sid reads it from the binding via
274
+ // `peaks session info --active`. Slice 021 turned this into the
275
+ // SOLE authoritative discovery primitive: it composes on
276
+ // getSessionIdCanonical (canonicalize-on-read; handles the stored
277
+ // "projectRoot: '.'" vs caller-passed absolute realpath mismatch
278
+ // that the F22 fix addressed) AND falls through to getSessionId
279
+ // (strict-equality) for callers on the original contract. NEITHER
280
+ // path may call ensureSession() — that would side-effect-create a
281
+ // fresh binding on miss, erasing the "no active session" signal
282
+ // sub-agents rely on.
219
283
  if (options.active === true) {
220
284
  // Import lazily to avoid a cycle with workspace-commands.
221
- const { getSessionIdCanonical } = await import('../../services/session/session-manager.js');
222
- const activeSid = getSessionIdCanonical(projectRoot);
285
+ const { getSessionIdCanonical, getSessionId } = await import('../../services/session/session-manager.js');
286
+ // 1. Canonicalize-on-read first.
287
+ let activeSid = getSessionIdCanonical(projectRoot);
288
+ // 2. Fall through to the strict-equality reader if the canonical
289
+ // miss is a projectRoot-form mismatch (e.g. the binding was
290
+ // written with the absolute realpath but the caller's form
291
+ // normalizes differently). The 2-read fan-out mirrors the
292
+ // fallback ensureSession() uses.
293
+ if (activeSid === null) {
294
+ activeSid = getSessionId(projectRoot);
295
+ }
223
296
  if (activeSid === null) {
224
- printResult(io, fail('session.info', 'NO_ACTIVE_BINDING', 'No canonical session binding at .peaks/_runtime/session.json', { projectRoot }, ['Run `peaks workspace init` or `peaks skill presence:set` to anchor a session']), options.json);
297
+ // 3. No binding at all fail loudly, NO crash, NO side-effect,
298
+ // exit 1, message must point at `peaks workspace init`
299
+ // (the canonical "first action" command, not the legacy
300
+ // "or `peaks skill presence:set`" hedge that the pre-021
301
+ // wording used — presence:set would also need a binding to
302
+ // resolve the parent sid, so it's not actually a bootstrap
303
+ // path).
304
+ printResult(io, fail('session.info', 'NO_ACTIVE_SESSION', 'No session bound. Run `peaks workspace init --project <repo> --json` to bind one.', { projectRoot }, [`peaks workspace init --project ${projectRoot} --json`]), options.json);
225
305
  process.exitCode = 1;
226
306
  return;
227
307
  }
228
- printResult(io, ok('session.info', { active: true, sessionId: activeSid, source: '.peaks/_runtime/session.json' }), options.json);
308
+ // 4. Determine the on-disk source so callers (and future
309
+ // migration tooling) can detect pre-migration trees. The
310
+ // canonical file is preferred when both exist (slice 005
311
+ // contract).
312
+ const bindingSource = existsSync(join(projectRoot, '.peaks', '_runtime', 'session.json'))
313
+ ? 'canonical'
314
+ : 'legacy';
315
+ // Slice 021: when only the legacy back-compat path is present,
316
+ // surface a warning so callers (and humans tailing the JSON)
317
+ // see "this tree has not been reconciled to the canonical
318
+ // home yet". The warning is informational; the binding is
319
+ // still valid for one minor release (slice 005 / 006
320
+ // contract). `warnings` is a top-level envelope field, not a
321
+ // data field, so it goes through ok()'s 3rd positional arg.
322
+ const legacyWarnings = bindingSource === 'legacy'
323
+ ? ['Read from legacy back-compat path .peaks/.session.json. Run `peaks workspace reconcile --apply` to migrate to the canonical home (.peaks/_runtime/session.json).']
324
+ : [];
325
+ printResult(io, ok('session.info', {
326
+ active: true,
327
+ sessionId: activeSid,
328
+ bindingPath: bindingSource === 'canonical'
329
+ ? join(projectRoot, '.peaks', '_runtime', 'session.json')
330
+ : join(projectRoot, '.peaks', '.session.json'),
331
+ projectRoot,
332
+ source: bindingSource
333
+ }, legacyWarnings), options.json);
229
334
  return;
230
335
  }
231
336
  if (sessionId === undefined) {
@@ -36,7 +36,12 @@ export function registerRequestCommands(program, io) {
36
36
  .requiredOption('--project <path>', 'target project root')
37
37
  .option('--session-id <session>', 'override the default date-stamped session id')
38
38
  .option('--apply', 'write the artifact file (default: preview only)')
39
- .option('--type <type>', `request type (${VALID_REQUEST_TYPES.join(' | ')}); default: feature`, parseRequestType)).action(async (options) => {
39
+ .option('--type <type>', `request type (${VALID_REQUEST_TYPES.join(' | ')}); default: feature`, parseRequestType)
40
+ // Slice 020 — caller-keyed session binding. Per-invocation override
41
+ // (D4 priority level 1). When set, the resolved callerId is surfaced
42
+ // in the JSON envelope; the on-disk artifact records it in the
43
+ // artifact body so future reads know which caller produced it.
44
+ .option('--caller-id <id>', 'Override the caller id for this invocation (D4 priority: flag beats env beats platform fallback). The resolved callerId is stamped on the artifact body and surfaced in the response envelope.')).action(async (options) => {
40
45
  try {
41
46
  const serviceOptions = {
42
47
  role: options.role,
@@ -57,6 +62,31 @@ export function registerRequestCommands(program, io) {
57
62
  if (options.type !== undefined) {
58
63
  serviceOptions.requestType = options.type;
59
64
  }
65
+ // Slice 020.1 — resolve the callerId via D4 priority (flag > env >
66
+ // platform fallback > reject). The CLI integration layer is the
67
+ // single entry point for the resolver; we do not pre-judge whether
68
+ // the caller passed a flag. D2 (no callerId available) and D5
69
+ // (regex fail) both surface as `CALLER_ID_INVALID` with the inner
70
+ // `CallerIdError.source` propagated for caller-side audit.
71
+ const { resolveCallerId, CallerIdError } = await import('../../services/session/resolve-caller-id.js');
72
+ try {
73
+ const callerId = resolveCallerId(options.callerId !== undefined ? { flagValue: options.callerId } : {});
74
+ serviceOptions.callerId = callerId;
75
+ }
76
+ catch (error) {
77
+ if (error instanceof CallerIdError) {
78
+ // D2 (EX_USAGE, exit 64) = nothing usable; D5 (EX_DATAERR, exit 65)
79
+ // = something was passed but did not match the D1 regex.
80
+ const code = error.code === 'EX_USAGE' ? 64 : 65;
81
+ printResult(io, fail('request.init', 'CALLER_ID_INVALID', error.message, { source: error.source }, [
82
+ 'Set --caller-id to a value matching ^[a-zA-Z0-9._-]{1,200}$',
83
+ 'Or set PEAKS_CALLER_ID env var (or CLAUDE_CODE_SESSION_ID for Claude Code)'
84
+ ]), options.json);
85
+ process.exitCode = code;
86
+ return;
87
+ }
88
+ throw error;
89
+ }
60
90
  const result = await createRequestArtifact(serviceOptions);
61
91
  printResult(io, ok('request.init', result, [], result.applied ? [] : [`Re-run with --apply to write ${result.path}`]), options.json);
62
92
  }
@@ -17,6 +17,16 @@ export type CreateRequestArtifactOptions = {
17
17
  apply?: boolean;
18
18
  requestType?: RequestType;
19
19
  clock?: () => string;
20
+ /**
21
+ * Slice 020 — caller-keyed session binding. The callerId that initiated
22
+ * this artifact creation (resolved via `resolveCallerId` by the CLI).
23
+ * Recorded in the JSON envelope and on the artifact body's frontmatter
24
+ * so a future reader knows which caller produced it. The caller-keyed
25
+ * binding file itself is at `.peaks/_runtime/callers/<callerId>.json`;
26
+ * the per-caller active-skill marker is at
27
+ * `.peaks/_runtime/<peakSid>/active-skill-<callerId>.json` (D6).
28
+ */
29
+ callerId?: string;
20
30
  };
21
31
  export type CreateRequestArtifactResult = {
22
32
  role: RequestArtifactRole;
@@ -25,6 +35,12 @@ export type CreateRequestArtifactResult = {
25
35
  path: string;
26
36
  content: string;
27
37
  applied: boolean;
38
+ /**
39
+ * Slice 020 — caller-keyed session binding. The resolved callerId
40
+ * (D4 priority: flag > env > platform fallback) when --caller-id
41
+ * was passed. Omitted from the result when no callerId was set.
42
+ */
43
+ callerId?: string;
28
44
  };
29
45
  export declare function createRequestArtifact(options: CreateRequestArtifactOptions): Promise<CreateRequestArtifactResult>;
30
46
  export type RequestArtifactSummary = {
@@ -374,7 +374,15 @@ export async function createRequestArtifact(options) {
374
374
  const path = join(requestsDir, filename);
375
375
  const content = renderTemplate(options.role, options.requestId, changeId, sessionId, timestamp, requestType);
376
376
  if (options.apply !== true) {
377
- return { role: options.role, requestId: options.requestId, sessionId, path, content, applied: false };
377
+ return {
378
+ role: options.role,
379
+ requestId: options.requestId,
380
+ sessionId,
381
+ path,
382
+ content,
383
+ applied: false,
384
+ ...(options.callerId !== undefined ? { callerId: options.callerId } : {})
385
+ };
378
386
  }
379
387
  await mkdir(dirname(path), { recursive: true });
380
388
  await writeFile(path, content, 'utf8');
@@ -390,7 +398,15 @@ export async function createRequestArtifact(options) {
390
398
  await writeFile(initiatedPath, '', 'utf8');
391
399
  }
392
400
  }
393
- return { role: options.role, requestId: options.requestId, sessionId, path, content, applied: true };
401
+ return {
402
+ role: options.role,
403
+ requestId: options.requestId,
404
+ sessionId,
405
+ path,
406
+ content,
407
+ applied: true,
408
+ ...(options.callerId !== undefined ? { callerId: options.callerId } : {})
409
+ };
394
410
  }
395
411
  function extractMetadata(markdown) {
396
412
  let state = 'unknown';
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Caller-Binding Service (slice 020 — caller-keyed session binding).
3
+ *
4
+ * Each caller has its own on-disk binding file at
5
+ * `.peaks/_runtime/callers/<callerId>.json`. This service is the
6
+ * single read/write surface for that file. Legacy single-file bindings
7
+ * (`.peaks/_runtime/session.json` and `.peaks/.session.json`) remain
8
+ * readable for one minor release (M1 / M4); the read path falls back
9
+ * to them with a `legacy-fallback-used: true` flag.
10
+ *
11
+ * M2: legacy bindings resolve into a synthetic callerId of the form
12
+ * `legacy-<8hex-of-sha256(outerSessionId)>`, with `claudeSessionId`
13
+ * and `projectRoot` as fallback hash inputs. The synthetic id is
14
+ * permanent and recognisable by the `legacy-` prefix.
15
+ *
16
+ * See `.peaks/_runtime/2026-06-09-session-8bfe7d/prd/source/caller-id-contract.md`
17
+ * for the freeze-in contract.
18
+ */
19
+ import { type CallerBinding } from './caller-id-types.js';
20
+ /**
21
+ * On-disk location of the per-caller binding file. P4 invariant:
22
+ * the `callers/<callerId>.json` lives at `.peaks/_runtime/callers/`,
23
+ * the same root as the per-peak session dirs.
24
+ */
25
+ export declare function getCallerBindingFile(projectRoot: string, callerId: string): string;
26
+ /**
27
+ * On-disk location of the per-(peak, caller) active-skill file. D6:
28
+ * one file per (peakSessionId, callerId) pair; two callers bound to
29
+ * the same peak session never clobber each other.
30
+ */
31
+ export declare function getActiveSkillFileForCaller(projectRoot: string, peakSessionId: string, callerId: string): string;
32
+ /**
33
+ * Resolve a stable, deterministic callerId for a legacy single-file
34
+ * binding (M2). The hash input priority is:
35
+ *
36
+ * 1. `outerSessionId` (slice 018 stamped this on the per-peak
37
+ * session.json for sessions created after the slice shipped).
38
+ * 2. `claudeSessionId` (legacy field name on pre-018 presence
39
+ * files; honour the read side so v1.2.x data does not lose its
40
+ * binding).
41
+ * 3. `projectRoot` (truly anonymous case: pre-018 sessions that
42
+ * never recorded an outer / claude id).
43
+ *
44
+ * The synthetic id is `legacy-<first 8 hex chars of sha256(input)>`.
45
+ * 32 bits of entropy is enough for typical on-disk state
46
+ * (R1: <100 legacy peak sessions per project; the test asserts
47
+ * uniqueness across 1000 synthetic ids).
48
+ */
49
+ export declare function synthesiseLegacyCallerId(input: string): string;
50
+ /**
51
+ * Read a per-caller binding file. Returns `null` if the file does
52
+ * not exist, is malformed, or is for a different project (M1 back-compat
53
+ * read returns the legacy file but only after the per-caller file is
54
+ * absent).
55
+ */
56
+ export declare function getCallerBinding(projectRoot: string, callerId: string): CallerBinding | null;
57
+ /**
58
+ * Write or update a per-caller binding file. The caller is responsible
59
+ * for the binding object (callerId must match the file stem, peakSessionId
60
+ * must be a valid session id, projectRoot is canonicalized). Idempotent:
61
+ * re-writing the same callerId overwrites the file.
62
+ */
63
+ export declare function setCallerBinding(projectRoot: string, callerId: string, binding: CallerBinding): void;
64
+ /**
65
+ * Enumerate the per-caller binding files under
66
+ * `.peaks/_runtime/callers/`. Returns the parsed bindings plus the
67
+ * raw filenames (so callers can list orphan / legacy files without
68
+ * re-reading).
69
+ */
70
+ export declare function listCallerBindings(projectRoot: string): CallerBinding[];
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Caller-Binding Service (slice 020 — caller-keyed session binding).
3
+ *
4
+ * Each caller has its own on-disk binding file at
5
+ * `.peaks/_runtime/callers/<callerId>.json`. This service is the
6
+ * single read/write surface for that file. Legacy single-file bindings
7
+ * (`.peaks/_runtime/session.json` and `.peaks/.session.json`) remain
8
+ * readable for one minor release (M1 / M4); the read path falls back
9
+ * to them with a `legacy-fallback-used: true` flag.
10
+ *
11
+ * M2: legacy bindings resolve into a synthetic callerId of the form
12
+ * `legacy-<8hex-of-sha256(outerSessionId)>`, with `claudeSessionId`
13
+ * and `projectRoot` as fallback hash inputs. The synthetic id is
14
+ * permanent and recognisable by the `legacy-` prefix.
15
+ *
16
+ * See `.peaks/_runtime/2026-06-09-session-8bfe7d/prd/source/caller-id-contract.md`
17
+ * for the freeze-in contract.
18
+ */
19
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
20
+ import { createHash } from 'node:crypto';
21
+ import { dirname, join, resolve } from 'node:path';
22
+ import { CALLER_ID_REGEX } from './caller-id-types.js';
23
+ import { getSessionDir } from './getSessionDir.js';
24
+ /**
25
+ * On-disk location of the per-caller binding file. P4 invariant:
26
+ * the `callers/<callerId>.json` lives at `.peaks/_runtime/callers/`,
27
+ * the same root as the per-peak session dirs.
28
+ */
29
+ export function getCallerBindingFile(projectRoot, callerId) {
30
+ if (!CALLER_ID_REGEX.test(callerId)) {
31
+ // Defensive: a malformed callerId should never make it to disk.
32
+ // The caller is expected to validate via `resolveCallerId` first.
33
+ throw new Error(`getCallerBindingFile: invalid callerId "${callerId}"`);
34
+ }
35
+ return join(projectRoot, '.peaks', '_runtime', 'callers', `${callerId}.json`);
36
+ }
37
+ /**
38
+ * On-disk location of the per-(peak, caller) active-skill file. D6:
39
+ * one file per (peakSessionId, callerId) pair; two callers bound to
40
+ * the same peak session never clobber each other.
41
+ */
42
+ export function getActiveSkillFileForCaller(projectRoot, peakSessionId, callerId) {
43
+ if (!CALLER_ID_REGEX.test(callerId)) {
44
+ throw new Error(`getActiveSkillFileForCaller: invalid callerId "${callerId}"`);
45
+ }
46
+ return join(getSessionDir(projectRoot, peakSessionId), `active-skill-${callerId}.json`);
47
+ }
48
+ /**
49
+ * Resolve a stable, deterministic callerId for a legacy single-file
50
+ * binding (M2). The hash input priority is:
51
+ *
52
+ * 1. `outerSessionId` (slice 018 stamped this on the per-peak
53
+ * session.json for sessions created after the slice shipped).
54
+ * 2. `claudeSessionId` (legacy field name on pre-018 presence
55
+ * files; honour the read side so v1.2.x data does not lose its
56
+ * binding).
57
+ * 3. `projectRoot` (truly anonymous case: pre-018 sessions that
58
+ * never recorded an outer / claude id).
59
+ *
60
+ * The synthetic id is `legacy-<first 8 hex chars of sha256(input)>`.
61
+ * 32 bits of entropy is enough for typical on-disk state
62
+ * (R1: <100 legacy peak sessions per project; the test asserts
63
+ * uniqueness across 1000 synthetic ids).
64
+ */
65
+ export function synthesiseLegacyCallerId(input) {
66
+ const hash = createHash('sha256').update(input, 'utf8').digest('hex').slice(0, 8);
67
+ return `legacy-${hash}`;
68
+ }
69
+ /**
70
+ * Read a per-caller binding file. Returns `null` if the file does
71
+ * not exist, is malformed, or is for a different project (M1 back-compat
72
+ * read returns the legacy file but only after the per-caller file is
73
+ * absent).
74
+ */
75
+ export function getCallerBinding(projectRoot, callerId) {
76
+ const bindingPath = getCallerBindingFile(projectRoot, callerId);
77
+ if (!existsSync(bindingPath)) {
78
+ return null;
79
+ }
80
+ try {
81
+ const raw = readFileSync(bindingPath, 'utf8');
82
+ const parsed = JSON.parse(raw);
83
+ if (typeof parsed.callerId !== 'string' || parsed.callerId !== callerId) {
84
+ return null;
85
+ }
86
+ if (typeof parsed.peakSessionId !== 'string' || parsed.peakSessionId.length === 0) {
87
+ return null;
88
+ }
89
+ if (typeof parsed.projectRoot !== 'string' || parsed.projectRoot.length === 0) {
90
+ return null;
91
+ }
92
+ return parsed;
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ }
98
+ /**
99
+ * Write or update a per-caller binding file. The caller is responsible
100
+ * for the binding object (callerId must match the file stem, peakSessionId
101
+ * must be a valid session id, projectRoot is canonicalized). Idempotent:
102
+ * re-writing the same callerId overwrites the file.
103
+ */
104
+ export function setCallerBinding(projectRoot, callerId, binding) {
105
+ if (binding.callerId !== callerId) {
106
+ throw new Error(`setCallerBinding: binding.callerId "${binding.callerId}" does not match callerId "${callerId}"`);
107
+ }
108
+ const bindingPath = getCallerBindingFile(projectRoot, callerId);
109
+ const dir = dirname(bindingPath);
110
+ if (!existsSync(dir)) {
111
+ mkdirSync(dir, { recursive: true });
112
+ }
113
+ const payload = {
114
+ ...binding,
115
+ projectRoot: resolve(binding.projectRoot)
116
+ };
117
+ writeFileSync(bindingPath, JSON.stringify(payload, null, 2), 'utf8');
118
+ }
119
+ /**
120
+ * Enumerate the per-caller binding files under
121
+ * `.peaks/_runtime/callers/`. Returns the parsed bindings plus the
122
+ * raw filenames (so callers can list orphan / legacy files without
123
+ * re-reading).
124
+ */
125
+ export function listCallerBindings(projectRoot) {
126
+ const dir = join(projectRoot, '.peaks', '_runtime', 'callers');
127
+ if (!existsSync(dir))
128
+ return [];
129
+ let names;
130
+ try {
131
+ const entries = readdirSync(dir, { withFileTypes: true });
132
+ names = entries.filter((e) => e.isFile() && e.name.endsWith('.json')).map((e) => e.name);
133
+ }
134
+ catch {
135
+ return [];
136
+ }
137
+ const out = [];
138
+ for (const name of names) {
139
+ const callerId = name.replace(/\.json$/, '');
140
+ if (!CALLER_ID_REGEX.test(callerId))
141
+ continue;
142
+ const binding = getCallerBinding(projectRoot, callerId);
143
+ if (binding !== null) {
144
+ out.push(binding);
145
+ }
146
+ }
147
+ return out;
148
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Caller-Id Resolution types (slice 020 — caller-keyed session binding).
3
+ *
4
+ * The single shared `.peaks/_runtime/session.json` and
5
+ * `.peaks/_runtime/active-skill.json` files are replaced with per-caller
6
+ * layouts: `.peaks/_runtime/callers/<callerId>.json` and
7
+ * `.peaks/_runtime/<peakSid>/active-skill-<callerId>.json`. The
8
+ * `callerId` is a generic identifier the calling platform declares
9
+ * itself (Claude Code via `CLAUDE_CODE_SESSION_ID`, future platforms
10
+ * via `PLATFORM_FALLBACKS`).
11
+ *
12
+ * See `.peaks/_runtime/2026-06-09-session-8bfe7d/prd/source/caller-id-contract.md`
13
+ * for the freeze-in contract (D1-D7 + M1-M5).
14
+ */
15
+ export type CallerIdSource = 'flag' | 'env' | 'fallback' | 'none';
16
+ /**
17
+ * On-disk shape of `.peaks/_runtime/callers/<callerId>.json`. One file
18
+ * per caller; two callers may point to the same `peakSessionId` (D6).
19
+ */
20
+ export interface CallerBinding {
21
+ /** Echo of the filename stem; matches D1 regex. */
22
+ callerId: string;
23
+ /** The peak session this caller is bound to. */
24
+ peakSessionId: string;
25
+ /** Absolute path to the project root, canonicalized. */
26
+ projectRoot: string;
27
+ /** ISO 8601 timestamp; stamped at first write. */
28
+ createdAt: string;
29
+ /** ISO 8601 timestamp; bumped on every `peaks <cmd>` that touches the binding. */
30
+ lastActivityAt: string;
31
+ /** Last skill that touched this binding, e.g. "peaks-solo". */
32
+ skill: string;
33
+ /** Last mode, e.g. "full-auto". */
34
+ mode: string;
35
+ /** Last gate, e.g. "startup". */
36
+ gate: string;
37
+ }
38
+ /**
39
+ * Per-(peakSessionId, callerId) presence record at
40
+ * `.peaks/_runtime/<peakSid>/active-skill-<callerId>.json`. Each caller
41
+ * has its own file (D6); two callers bound to the same peak session
42
+ * never clobber each other's presence.
43
+ */
44
+ export interface CallerSkillPresence {
45
+ callerId: string;
46
+ skill: string;
47
+ mode?: string;
48
+ gate?: string;
49
+ setAt: string;
50
+ lastHeartbeat?: string;
51
+ }
52
+ /**
53
+ * D1 callerId regex: ASCII letters, digits, dot, underscore, hyphen;
54
+ * 1-200 chars. Excludes path separators (Windows: `\`, Unix: `/`),
55
+ * NUL, control chars, whitespace, all other Unicode — callerId is
56
+ * embedded in a file path and must be portable across Windows / macOS
57
+ * / Linux.
58
+ */
59
+ export declare const CALLER_ID_REGEX: RegExp;
60
+ /**
61
+ * Thrown by `resolveCallerId` for two cases:
62
+ *
63
+ * - `code: 'EX_USAGE'` (exit 64, D2): no callerId available
64
+ * anywhere (flag/env/fallback all empty).
65
+ * - `code: 'EX_DATAERR'` (exit 65, D5): resolved callerId does not
66
+ * match D1's regex.
67
+ *
68
+ * The `source` field tells the user where the bad id came from
69
+ * (`flag` / `env` / `fallback` / `none`) so the error message points
70
+ * at the right thing to fix.
71
+ */
72
+ export declare class CallerIdError extends Error {
73
+ readonly code: 'EX_USAGE' | 'EX_DATAERR';
74
+ readonly source: CallerIdSource;
75
+ readonly value: string | undefined;
76
+ constructor(code: 'EX_USAGE' | 'EX_DATAERR', source: CallerIdSource, message: string, value?: string);
77
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Caller-Id Resolution types (slice 020 — caller-keyed session binding).
3
+ *
4
+ * The single shared `.peaks/_runtime/session.json` and
5
+ * `.peaks/_runtime/active-skill.json` files are replaced with per-caller
6
+ * layouts: `.peaks/_runtime/callers/<callerId>.json` and
7
+ * `.peaks/_runtime/<peakSid>/active-skill-<callerId>.json`. The
8
+ * `callerId` is a generic identifier the calling platform declares
9
+ * itself (Claude Code via `CLAUDE_CODE_SESSION_ID`, future platforms
10
+ * via `PLATFORM_FALLBACKS`).
11
+ *
12
+ * See `.peaks/_runtime/2026-06-09-session-8bfe7d/prd/source/caller-id-contract.md`
13
+ * for the freeze-in contract (D1-D7 + M1-M5).
14
+ */
15
+ /**
16
+ * D1 callerId regex: ASCII letters, digits, dot, underscore, hyphen;
17
+ * 1-200 chars. Excludes path separators (Windows: `\`, Unix: `/`),
18
+ * NUL, control chars, whitespace, all other Unicode — callerId is
19
+ * embedded in a file path and must be portable across Windows / macOS
20
+ * / Linux.
21
+ */
22
+ export const CALLER_ID_REGEX = /^[a-zA-Z0-9._-]{1,200}$/;
23
+ /**
24
+ * Thrown by `resolveCallerId` for two cases:
25
+ *
26
+ * - `code: 'EX_USAGE'` (exit 64, D2): no callerId available
27
+ * anywhere (flag/env/fallback all empty).
28
+ * - `code: 'EX_DATAERR'` (exit 65, D5): resolved callerId does not
29
+ * match D1's regex.
30
+ *
31
+ * The `source` field tells the user where the bad id came from
32
+ * (`flag` / `env` / `fallback` / `none`) so the error message points
33
+ * at the right thing to fix.
34
+ */
35
+ export class CallerIdError extends Error {
36
+ code;
37
+ source;
38
+ value;
39
+ constructor(code, source, message, value) {
40
+ super(message);
41
+ this.name = 'CallerIdError';
42
+ this.code = code;
43
+ this.source = source;
44
+ this.value = value;
45
+ }
46
+ }
@@ -1,2 +1,6 @@
1
1
  export { ensureSession, getSessionId, getCurrentSessionDir, listSessions, getSessionMeta, setSessionMeta, setSessionTitle, listSessionMetas, getProjectScanPath, hasProjectScan, setCurrentSessionBinding, rotateSessionBinding, type SessionInfo, type SessionMeta } from './session-manager.js';
2
2
  export { getSessionDir } from './getSessionDir.js';
3
+ export { resolveCallerId, type ResolveCallerIdOptions } from './resolve-caller-id.js';
4
+ export { getCallerBindingFile, getActiveSkillFileForCaller, synthesiseLegacyCallerId, getCallerBinding, setCallerBinding, listCallerBindings } from './caller-binding-service.js';
5
+ export { PLATFORM_FALLBACKS, type PlatformFallback } from './platform-fallbacks.js';
6
+ export { CALLER_ID_REGEX, CallerIdError, type CallerBinding, type CallerSkillPresence, type CallerIdSource } from './caller-id-types.js';
@@ -1,2 +1,7 @@
1
1
  export { ensureSession, getSessionId, getCurrentSessionDir, listSessions, getSessionMeta, setSessionMeta, setSessionTitle, listSessionMetas, getProjectScanPath, hasProjectScan, setCurrentSessionBinding, rotateSessionBinding } from './session-manager.js';
2
2
  export { getSessionDir } from './getSessionDir.js';
3
+ // Slice 020 — caller-keyed session binding. The new canonical path.
4
+ export { resolveCallerId } from './resolve-caller-id.js';
5
+ export { getCallerBindingFile, getActiveSkillFileForCaller, synthesiseLegacyCallerId, getCallerBinding, setCallerBinding, listCallerBindings } from './caller-binding-service.js';
6
+ export { PLATFORM_FALLBACKS } from './platform-fallbacks.js';
7
+ export { CALLER_ID_REGEX, CallerIdError } from './caller-id-types.js';
@@ -0,0 +1,31 @@
1
+ /**
2
+ * PLATFORM_FALLBACKS — the Level 3 fallback table for caller-id resolution.
3
+ *
4
+ * Slice 020 (D3): when neither `--caller-id` nor `PEAKS_CALLER_ID` is
5
+ * set, the resolver walks this table top-to-bottom and takes the
6
+ * first non-empty entry. Today there is exactly one entry: Claude
7
+ * Code (`CLAUDE_CODE_SESSION_ID`).
8
+ *
9
+ * To add a new platform (Cursor, Windsurf, peaks-ide, etc.):
10
+ *
11
+ * 1. Add a new entry below.
12
+ * 2. Bump the contract doc's A5 acceptance criterion
13
+ * (`.peaks/_runtime/2026-06-09-session-8bfe7d/prd/source/caller-id-contract.md`).
14
+ * 3. Add a regression test that asserts the new entry resolves
15
+ * correctly under D4 priority.
16
+ *
17
+ * The contract's A5 test (`tests/unit/services/session/caller-id-resolution.test.ts`)
18
+ * asserts `PLATFORM_FALLBACKS.length === 1`; adding a new entry will
19
+ * fail that test, forcing the contract bump.
20
+ *
21
+ * Adding an entry does NOT require code changes to read points
22
+ * (statusline, doctor, sc, session-info) — they all call the same
23
+ * resolver. Each entry is a one-line additive change.
24
+ */
25
+ export interface PlatformFallback {
26
+ readonly envVar: string;
27
+ readonly description: string;
28
+ /** Semver this entry was added in (e.g. "1.3.7"). */
29
+ readonly addedIn: string;
30
+ }
31
+ export declare const PLATFORM_FALLBACKS: ReadonlyArray<PlatformFallback>;