peaks-cli 1.3.6 → 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.
- package/dist/src/cli/commands/core-artifact-commands.js +119 -14
- package/dist/src/cli/commands/request-commands.js +31 -1
- package/dist/src/cli/commands/slice-commands.js +9 -5
- package/dist/src/cli/commands/workspace-commands.js +46 -2
- package/dist/src/services/artifacts/request-artifact-service.d.ts +16 -0
- package/dist/src/services/artifacts/request-artifact-service.js +18 -2
- package/dist/src/services/session/caller-binding-service.d.ts +70 -0
- package/dist/src/services/session/caller-binding-service.js +148 -0
- package/dist/src/services/session/caller-id-types.d.ts +77 -0
- package/dist/src/services/session/caller-id-types.js +46 -0
- package/dist/src/services/session/index.d.ts +4 -0
- package/dist/src/services/session/index.js +5 -0
- package/dist/src/services/session/platform-fallbacks.d.ts +31 -0
- package/dist/src/services/session/platform-fallbacks.js +35 -0
- package/dist/src/services/session/resolve-caller-id.d.ts +57 -0
- package/dist/src/services/session/resolve-caller-id.js +88 -0
- package/dist/src/services/session/session-manager.d.ts +55 -0
- package/dist/src/services/session/session-manager.js +68 -0
- package/dist/src/services/skills/skill-presence-service.d.ts +21 -4
- package/dist/src/services/skills/skill-presence-service.js +75 -11
- package/dist/src/services/slice/slice-check-service.js +36 -18
- package/dist/src/services/slice/slice-check-types.d.ts +40 -6
- package/dist/src/services/slice/slice-check-types.js +11 -1
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +1 -1
- package/skills/peaks-qa/SKILL.md +5 -2
- package/skills/peaks-rd/SKILL.md +18 -133
- package/skills/peaks-rd/references/rd-transition-gates.md +148 -0
- package/skills/peaks-solo/SKILL.md +18 -209
- package/skills/peaks-solo/references/frontend-only-mode.md +73 -0
- package/skills/peaks-solo/references/micro-cycle.md +4 -2
- package/skills/peaks-solo/references/project-scan-checklist.md +136 -0
|
@@ -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>;
|
|
@@ -0,0 +1,35 @@
|
|
|
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 const PLATFORM_FALLBACKS = [
|
|
26
|
+
{
|
|
27
|
+
envVar: 'CLAUDE_CODE_SESSION_ID',
|
|
28
|
+
description: 'Claude Code session id',
|
|
29
|
+
addedIn: '1.3.7'
|
|
30
|
+
}
|
|
31
|
+
// Future entries (do NOT add without bumping the contract's A5):
|
|
32
|
+
// { envVar: 'CURSOR_SESSION_ID', description: 'Cursor session id', addedIn: 'TBD' },
|
|
33
|
+
// { envVar: 'WINDSURF_SESSION_ID', description: 'Windsurf session id', addedIn: 'TBD' },
|
|
34
|
+
// { envVar: 'PEAKS_IDE_SESSION_ID', description: 'peaks-ide session id', addedIn: 'TBD' },
|
|
35
|
+
];
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caller-Id Resolution (slice 020 — caller-keyed session binding).
|
|
3
|
+
*
|
|
4
|
+
* `resolveCallerId` is the single source of truth for "who is calling
|
|
5
|
+
* the CLI". The resolver applies D4 priority (flag > env > platform
|
|
6
|
+
* fallback > reject) and validates the winner against D1's regex;
|
|
7
|
+
* failures throw `CallerIdError` (D2 → exit 64, D5 → exit 65).
|
|
8
|
+
*
|
|
9
|
+
* The function is synchronous and pure. It does NOT touch the
|
|
10
|
+
* filesystem, does NOT read any caller binding file, and does NOT
|
|
11
|
+
* mutate state. The caller (a CLI command, a service, a test) decides
|
|
12
|
+
* what to do with the resolved id.
|
|
13
|
+
*
|
|
14
|
+
* See `.peaks/_runtime/2026-06-09-session-8bfe7d/prd/source/caller-id-contract.md`
|
|
15
|
+
* for the freeze-in contract (D1-D7).
|
|
16
|
+
*/
|
|
17
|
+
import { CallerIdError } from './caller-id-types.js';
|
|
18
|
+
export { CallerIdError };
|
|
19
|
+
export interface ResolveCallerIdOptions {
|
|
20
|
+
/**
|
|
21
|
+
* The `--caller-id <id>` flag value (per-invocation override).
|
|
22
|
+
* D4 priority level 1: flag wins.
|
|
23
|
+
*/
|
|
24
|
+
flagValue?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Override for the `PEAKS_CALLER_ID` environment variable. D4
|
|
27
|
+
* priority level 2: env wins. Defaults to `process.env.PEAKS_CALLER_ID`.
|
|
28
|
+
* The override exists so tests can run without mutating process.env.
|
|
29
|
+
*/
|
|
30
|
+
envOverride?: string;
|
|
31
|
+
/**
|
|
32
|
+
* The env object to read. Defaults to `process.env`. Exists so
|
|
33
|
+
* tests can drive Level 3 (platform fallback) without mutating
|
|
34
|
+
* process.env.
|
|
35
|
+
*/
|
|
36
|
+
env?: NodeJS.ProcessEnv;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the calling process's callerId per D1-D5.
|
|
40
|
+
*
|
|
41
|
+
* D4 priority (strict, no merge):
|
|
42
|
+
* 1. `opts.flagValue` (per-invocation `--caller-id <id>` override)
|
|
43
|
+
* 2. `opts.envOverride ?? process.env.PEAKS_CALLER_ID` (per-process declaration)
|
|
44
|
+
* 3. First non-empty entry in `PLATFORM_FALLBACKS` (platform default)
|
|
45
|
+
* 4. → **D2 fires**: throw `CallerIdError` (EX_USAGE, exit 64)
|
|
46
|
+
*
|
|
47
|
+
* On success: returns the resolved id (matches D1's regex, validated).
|
|
48
|
+
* On D2 (nothing set): throws `CallerIdError` (EX_USAGE, exit 64).
|
|
49
|
+
* On D5 (regex fail): throws `CallerIdError` (EX_DATAERR, exit 65).
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* resolveCallerId({ flagValue: 'foo-bar' }) // → 'foo-bar'
|
|
53
|
+
* resolveCallerId({ envOverride: 'baz' }) // → 'baz'
|
|
54
|
+
* resolveCallerId({ env: { CLAUDE_CODE_SESSION_ID: 'sid-123' } }) // → 'sid-123'
|
|
55
|
+
* resolveCallerId() // → throws CallerIdError (EX_USAGE)
|
|
56
|
+
*/
|
|
57
|
+
export declare function resolveCallerId(opts?: ResolveCallerIdOptions): string;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caller-Id Resolution (slice 020 — caller-keyed session binding).
|
|
3
|
+
*
|
|
4
|
+
* `resolveCallerId` is the single source of truth for "who is calling
|
|
5
|
+
* the CLI". The resolver applies D4 priority (flag > env > platform
|
|
6
|
+
* fallback > reject) and validates the winner against D1's regex;
|
|
7
|
+
* failures throw `CallerIdError` (D2 → exit 64, D5 → exit 65).
|
|
8
|
+
*
|
|
9
|
+
* The function is synchronous and pure. It does NOT touch the
|
|
10
|
+
* filesystem, does NOT read any caller binding file, and does NOT
|
|
11
|
+
* mutate state. The caller (a CLI command, a service, a test) decides
|
|
12
|
+
* what to do with the resolved id.
|
|
13
|
+
*
|
|
14
|
+
* See `.peaks/_runtime/2026-06-09-session-8bfe7d/prd/source/caller-id-contract.md`
|
|
15
|
+
* for the freeze-in contract (D1-D7).
|
|
16
|
+
*/
|
|
17
|
+
import { CALLER_ID_REGEX, CallerIdError } from './caller-id-types.js';
|
|
18
|
+
import { PLATFORM_FALLBACKS } from './platform-fallbacks.js';
|
|
19
|
+
// Re-export for CLI consumers (avoids a second import line).
|
|
20
|
+
export { CallerIdError };
|
|
21
|
+
/**
|
|
22
|
+
* Check whether `value` looks like a callerId (non-empty, matches D1).
|
|
23
|
+
* Returns the trimmed value if so, undefined otherwise. Does not throw.
|
|
24
|
+
*/
|
|
25
|
+
function isNonEmpty(value) {
|
|
26
|
+
return typeof value === 'string' && value.length > 0;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Read a single platform-fallback env var from `env`. Returns the
|
|
30
|
+
* non-empty trimmed value or `undefined`. Logs nothing.
|
|
31
|
+
*/
|
|
32
|
+
function readPlatformFallback(env) {
|
|
33
|
+
for (let i = 0; i < PLATFORM_FALLBACKS.length; i++) {
|
|
34
|
+
const candidate = env[PLATFORM_FALLBACKS[i].envVar];
|
|
35
|
+
if (isNonEmpty(candidate)) {
|
|
36
|
+
return { value: candidate, index: i };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Validate `value` against D1's regex. Returns the value on success,
|
|
43
|
+
* throws `CallerIdError` (EX_DATAERR, exit 65) on failure.
|
|
44
|
+
*/
|
|
45
|
+
function validateCallerId(value, source) {
|
|
46
|
+
if (!CALLER_ID_REGEX.test(value)) {
|
|
47
|
+
throw new CallerIdError('EX_DATAERR', source, `Invalid caller id "${value}" (source: ${source}). callerId must match ^[a-zA-Z0-9._-]{1,200}$.`, value);
|
|
48
|
+
}
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the calling process's callerId per D1-D5.
|
|
53
|
+
*
|
|
54
|
+
* D4 priority (strict, no merge):
|
|
55
|
+
* 1. `opts.flagValue` (per-invocation `--caller-id <id>` override)
|
|
56
|
+
* 2. `opts.envOverride ?? process.env.PEAKS_CALLER_ID` (per-process declaration)
|
|
57
|
+
* 3. First non-empty entry in `PLATFORM_FALLBACKS` (platform default)
|
|
58
|
+
* 4. → **D2 fires**: throw `CallerIdError` (EX_USAGE, exit 64)
|
|
59
|
+
*
|
|
60
|
+
* On success: returns the resolved id (matches D1's regex, validated).
|
|
61
|
+
* On D2 (nothing set): throws `CallerIdError` (EX_USAGE, exit 64).
|
|
62
|
+
* On D5 (regex fail): throws `CallerIdError` (EX_DATAERR, exit 65).
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* resolveCallerId({ flagValue: 'foo-bar' }) // → 'foo-bar'
|
|
66
|
+
* resolveCallerId({ envOverride: 'baz' }) // → 'baz'
|
|
67
|
+
* resolveCallerId({ env: { CLAUDE_CODE_SESSION_ID: 'sid-123' } }) // → 'sid-123'
|
|
68
|
+
* resolveCallerId() // → throws CallerIdError (EX_USAGE)
|
|
69
|
+
*/
|
|
70
|
+
export function resolveCallerId(opts = {}) {
|
|
71
|
+
const env = opts.env ?? process.env;
|
|
72
|
+
// D4 level 1: flag value
|
|
73
|
+
if (isNonEmpty(opts.flagValue)) {
|
|
74
|
+
return validateCallerId(opts.flagValue, 'flag');
|
|
75
|
+
}
|
|
76
|
+
// D4 level 2: env var
|
|
77
|
+
const envValue = isNonEmpty(opts.envOverride) ? opts.envOverride : env.PEAKS_CALLER_ID;
|
|
78
|
+
if (isNonEmpty(envValue)) {
|
|
79
|
+
return validateCallerId(envValue, 'env');
|
|
80
|
+
}
|
|
81
|
+
// D4 level 3: PLATFORM_FALLBACKS table (top-to-bottom)
|
|
82
|
+
const fallback = readPlatformFallback(env);
|
|
83
|
+
if (fallback !== undefined) {
|
|
84
|
+
return validateCallerId(fallback.value, 'fallback');
|
|
85
|
+
}
|
|
86
|
+
// D4 level 4: D2 fires — no callerId available
|
|
87
|
+
throw new CallerIdError('EX_USAGE', 'none', 'No caller id available. Set PEAKS_CALLER_ID or pass --caller-id.');
|
|
88
|
+
}
|
|
@@ -89,7 +89,62 @@ export declare function setSessionTitle(projectRoot: string, sessionId: string,
|
|
|
89
89
|
* release) but is not authoritative.
|
|
90
90
|
*/
|
|
91
91
|
export declare function listSessionMetas(projectRoot: string): SessionMeta[];
|
|
92
|
+
export type EnsureSessionOptions = {
|
|
93
|
+
/**
|
|
94
|
+
* When `true`, suppress the outer-session-mismatch auto-rotation.
|
|
95
|
+
* The caller wants today's "stamp the field, do not rotate" behaviour
|
|
96
|
+
* even when the outer session id has changed. Used by
|
|
97
|
+
* `peaks workspace init --no-rotate-on-outer-mismatch`.
|
|
98
|
+
*/
|
|
99
|
+
skipRotateOnOuterMismatch?: boolean;
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* Result of `ensureSessionWithRotation`. When the bound session was
|
|
103
|
+
* rotated because the outer session id had changed, `previousSessionId`
|
|
104
|
+
* is the id of the unbound session and `rotationReason` is the structured
|
|
105
|
+
* reason code the CLI surfaces in its JSON envelope.
|
|
106
|
+
*/
|
|
107
|
+
export type EnsureSessionResult = {
|
|
108
|
+
sessionId: string;
|
|
109
|
+
previousSessionId: string | null;
|
|
110
|
+
rotationReason: 'outer-session-mismatch' | null;
|
|
111
|
+
};
|
|
92
112
|
export declare function ensureSession(projectRoot: string): Promise<string>;
|
|
113
|
+
/**
|
|
114
|
+
* Outer-session-aware wrapper around `ensureSession`.
|
|
115
|
+
*
|
|
116
|
+
* Slice 018 (auto-roll on outer-mismatch). When the current outer
|
|
117
|
+
* session id (sourced from `PEAKS_OUTER_SESSION_ID` with
|
|
118
|
+
* `CLAUDE_CODE_SESSION_ID` as the Claude-Code fallback) differs from
|
|
119
|
+
* the outer session id recorded on the *bound* peaks session's
|
|
120
|
+
* `.peaks/_runtime/<sid>/session.json`, the project-level session
|
|
121
|
+
* binding is rotated before `ensureSession` is called. The old
|
|
122
|
+
* session dir is preserved on disk (data is never wiped) — only the
|
|
123
|
+
* binding changes — and the rotation is surfaced in the return value
|
|
124
|
+
* so the CLI can include it in the JSON envelope.
|
|
125
|
+
*
|
|
126
|
+
* Rotation is suppressed in three cases (all false-positive guards):
|
|
127
|
+
*
|
|
128
|
+
* 1. The current outer session id is undefined (no env var set) —
|
|
129
|
+
* there is no signal to compare against, defaulting to "do not
|
|
130
|
+
* rotate" avoids orphaning the session.
|
|
131
|
+
* 2. The bound session has no recorded `outerSessionId` (legacy
|
|
132
|
+
* session predating the outer-session contract) — there is no
|
|
133
|
+
* signal on the other side either.
|
|
134
|
+
* 3. The bound session's recorded outer session id matches the
|
|
135
|
+
* current one (reconnect within the same Claude session) — this
|
|
136
|
+
* is the common case, not a swap.
|
|
137
|
+
*
|
|
138
|
+
* When `options.skipRotateOnOuterMismatch === true`, the rotation
|
|
139
|
+
* check is short-circuited and the binding is preserved (opt-out for
|
|
140
|
+
* `peaks workspace init --no-rotate-on-outer-mismatch`). The wrapper
|
|
141
|
+
* still delegates to `ensureSession` so the caller gets the existing
|
|
142
|
+
* binding on a reconnect and a fresh id on a first run.
|
|
143
|
+
*
|
|
144
|
+
* Existing public surface is preserved: `ensureSession` is unchanged.
|
|
145
|
+
* This wrapper is the new entry point the CLI uses.
|
|
146
|
+
*/
|
|
147
|
+
export declare function ensureSessionWithRotation(projectRoot: string, options?: EnsureSessionOptions): Promise<EnsureSessionResult>;
|
|
93
148
|
/**
|
|
94
149
|
* Get the current session ID without creating a new one.
|
|
95
150
|
* Returns null if no session exists.
|
|
@@ -421,6 +421,74 @@ export async function ensureSession(projectRoot) {
|
|
|
421
421
|
});
|
|
422
422
|
return sessionId;
|
|
423
423
|
}
|
|
424
|
+
/**
|
|
425
|
+
* Outer-session-aware wrapper around `ensureSession`.
|
|
426
|
+
*
|
|
427
|
+
* Slice 018 (auto-roll on outer-mismatch). When the current outer
|
|
428
|
+
* session id (sourced from `PEAKS_OUTER_SESSION_ID` with
|
|
429
|
+
* `CLAUDE_CODE_SESSION_ID` as the Claude-Code fallback) differs from
|
|
430
|
+
* the outer session id recorded on the *bound* peaks session's
|
|
431
|
+
* `.peaks/_runtime/<sid>/session.json`, the project-level session
|
|
432
|
+
* binding is rotated before `ensureSession` is called. The old
|
|
433
|
+
* session dir is preserved on disk (data is never wiped) — only the
|
|
434
|
+
* binding changes — and the rotation is surfaced in the return value
|
|
435
|
+
* so the CLI can include it in the JSON envelope.
|
|
436
|
+
*
|
|
437
|
+
* Rotation is suppressed in three cases (all false-positive guards):
|
|
438
|
+
*
|
|
439
|
+
* 1. The current outer session id is undefined (no env var set) —
|
|
440
|
+
* there is no signal to compare against, defaulting to "do not
|
|
441
|
+
* rotate" avoids orphaning the session.
|
|
442
|
+
* 2. The bound session has no recorded `outerSessionId` (legacy
|
|
443
|
+
* session predating the outer-session contract) — there is no
|
|
444
|
+
* signal on the other side either.
|
|
445
|
+
* 3. The bound session's recorded outer session id matches the
|
|
446
|
+
* current one (reconnect within the same Claude session) — this
|
|
447
|
+
* is the common case, not a swap.
|
|
448
|
+
*
|
|
449
|
+
* When `options.skipRotateOnOuterMismatch === true`, the rotation
|
|
450
|
+
* check is short-circuited and the binding is preserved (opt-out for
|
|
451
|
+
* `peaks workspace init --no-rotate-on-outer-mismatch`). The wrapper
|
|
452
|
+
* still delegates to `ensureSession` so the caller gets the existing
|
|
453
|
+
* binding on a reconnect and a fresh id on a first run.
|
|
454
|
+
*
|
|
455
|
+
* Existing public surface is preserved: `ensureSession` is unchanged.
|
|
456
|
+
* This wrapper is the new entry point the CLI uses.
|
|
457
|
+
*/
|
|
458
|
+
export async function ensureSessionWithRotation(projectRoot, options) {
|
|
459
|
+
const skipRotate = options?.skipRotateOnOuterMismatch === true;
|
|
460
|
+
const currentOuterSessionId = getCurrentOuterSessionId();
|
|
461
|
+
// Compute the rotation decision up front. We only rotate when ALL
|
|
462
|
+
// three pre-conditions hold: (a) the current outer session id is
|
|
463
|
+
// defined, (b) the bound session has a recorded outer session id,
|
|
464
|
+
// and (c) the two differ. The bound session id is the *first*
|
|
465
|
+
// read so we can use it both for the comparison and for the
|
|
466
|
+
// rotation result.
|
|
467
|
+
const boundSessionId = getSessionId(projectRoot);
|
|
468
|
+
let rotated = null;
|
|
469
|
+
let rotationReason = null;
|
|
470
|
+
if (boundSessionId !== null && currentOuterSessionId !== undefined) {
|
|
471
|
+
const boundMeta = getSessionMeta(projectRoot, boundSessionId);
|
|
472
|
+
const boundOuter = boundMeta?.outerSessionId;
|
|
473
|
+
if (typeof boundOuter === 'string' &&
|
|
474
|
+
boundOuter.length > 0 &&
|
|
475
|
+
boundOuter !== currentOuterSessionId &&
|
|
476
|
+
!skipRotate) {
|
|
477
|
+
rotated = rotateSessionBinding(projectRoot);
|
|
478
|
+
rotationReason = 'outer-session-mismatch';
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
// After the rotation, `ensureSession` will either reuse the
|
|
482
|
+
// canonical-fallback binding (when one still exists, e.g. a sibling
|
|
483
|
+
// projectRoot form) or auto-generate a fresh id. We pass through.
|
|
484
|
+
void rotated; // rotated is the *previous* session id; preserved for the caller via the return value
|
|
485
|
+
const sessionId = await ensureSession(projectRoot);
|
|
486
|
+
return {
|
|
487
|
+
sessionId,
|
|
488
|
+
previousSessionId: rotated,
|
|
489
|
+
rotationReason
|
|
490
|
+
};
|
|
491
|
+
}
|
|
424
492
|
/**
|
|
425
493
|
* Get the current session ID without creating a new one.
|
|
426
494
|
* Returns null if no session exists.
|
|
@@ -22,10 +22,16 @@ export type SkillPresence = {
|
|
|
22
22
|
* Set by `setSkillPresence` when the outer session id changed
|
|
23
23
|
* between the last presence write and this one AND the bound
|
|
24
24
|
* peaks session has a different (or no) recorded outer session id.
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
25
|
+
*
|
|
26
|
+
* As of slice 018 (auto-roll on outer-mismatch), the field is
|
|
27
|
+
* informational only — it tells the statusline and any log /
|
|
28
|
+
* observability consumer that an outer-session swap was observed
|
|
29
|
+
* on the previous heartbeat. The actual binding rotation is
|
|
30
|
+
* performed by `ensureSessionWithRotation` (slice 018), not by
|
|
31
|
+
* `setSkillPresence`. `peaks-solo`'s Step 0 used to read this
|
|
32
|
+
* field and turn it into an AskUserQuestion; that ask is no
|
|
33
|
+
* longer needed because the rotation already happened by the time
|
|
34
|
+
* the skill is invoked.
|
|
29
35
|
*/
|
|
30
36
|
outerSessionMismatch?: {
|
|
31
37
|
previous?: string;
|
|
@@ -37,6 +43,17 @@ export type SkillPresence = {
|
|
|
37
43
|
lastHeartbeat?: string;
|
|
38
44
|
};
|
|
39
45
|
export declare function exportSkillPresence(projectRootOverride?: string): string;
|
|
46
|
+
/**
|
|
47
|
+
* Write the per-caller active-skill marker to
|
|
48
|
+
* `.peaks/_runtime/<peakSid>/active-skill-<callerId>.json` (D6). Returns
|
|
49
|
+
* the written presence with the `callerId` field set.
|
|
50
|
+
*
|
|
51
|
+
* The caller is responsible for resolving the `callerId` (via
|
|
52
|
+
* `resolveCallerId` from `src/services/session/resolve-caller-id.ts`)
|
|
53
|
+
* and the `peakSessionId` (via `getCallerBinding` then reading
|
|
54
|
+
* `peakSessionId`, OR via `ensureSession` for the first-time case).
|
|
55
|
+
*/
|
|
56
|
+
export declare function setSkillPresenceForCaller(projectRootOverride: string, callerId: string, peakSessionId: string, skill: string, mode?: string, gate?: string): SkillPresence;
|
|
40
57
|
export declare function setSkillPresence(skill: string, mode?: string, gate?: string, projectRootOverride?: string): SkillPresence;
|
|
41
58
|
export declare function getSkillPresence(projectRootOverride?: string): SkillPresence | null;
|
|
42
59
|
export declare function touchSkillHeartbeat(projectRootOverride?: string): SkillPresence | null;
|
|
@@ -124,18 +124,23 @@ function getBoundOuterSessionId(projectRootOverride) {
|
|
|
124
124
|
* Used to detect "the LLM just opened a fresh outer session" — if
|
|
125
125
|
* the previously-recorded outer session id differs from the one we
|
|
126
126
|
* are about to stamp, the user probably closed the previous outer
|
|
127
|
-
* session and is now driving peaks from a new one.
|
|
128
|
-
* roll a new peaks session (that is destructive — it would leave
|
|
129
|
-
* the in-flight session with no LLM watching it). Instead we emit
|
|
130
|
-
* a structured `outerSessionMismatch` field on the presence
|
|
131
|
-
* envelope, and peaks-solo's Step 0 turns that into an
|
|
132
|
-
* AskUserQuestion. The user can opt to keep the current session
|
|
133
|
-
* (most common when the swap is a no-op reconnect) or to roll a
|
|
134
|
-
* fresh session (when the new outer session is genuinely a new
|
|
135
|
-
* task).
|
|
127
|
+
* session and is now driving peaks from a new one.
|
|
136
128
|
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
129
|
+
* As of slice 018 (auto-roll on outer-mismatch), the actual rotation
|
|
130
|
+
* is `ensureSessionWithRotation`'s job, not this one. The presence
|
|
131
|
+
* service still emits the structured `outerSessionMismatch` field on
|
|
132
|
+
* the presence envelope (useful for the statusline to render a stale
|
|
133
|
+
* marker and for the QA / log consumers to know an outer-session swap
|
|
134
|
+
* happened), but it no longer carries the implicit "ask the user"
|
|
135
|
+
* promise — `peaks-solo`'s Step 0 no longer needs to surface an
|
|
136
|
+
* AskUserQuestion, because the rotation already fired by the time the
|
|
137
|
+
* skill is invoked.
|
|
138
|
+
*
|
|
139
|
+
* `getPreviousOuterSessionId` keeps its read-side role: it powers the
|
|
140
|
+
* informational `outerSessionMismatch` field below and the legacy
|
|
141
|
+
* `claudeSessionId` back-compat. Reads from
|
|
142
|
+
* `.peaks/_runtime/active-skill.json` first; falls back to the
|
|
143
|
+
* legacy `.peaks/.active-skill.json` for one minor release.
|
|
139
144
|
*/
|
|
140
145
|
function getPreviousOuterSessionId(projectRootOverride) {
|
|
141
146
|
const result = readSkillPresenceBackCompat(projectRootOverride);
|
|
@@ -155,6 +160,65 @@ function getPreviousOuterSessionId(projectRootOverride) {
|
|
|
155
160
|
export function exportSkillPresence(projectRootOverride) {
|
|
156
161
|
return resolvePresencePath(projectRootOverride);
|
|
157
162
|
}
|
|
163
|
+
// ============================================================================
|
|
164
|
+
// Slice 020 — caller-keyed active-skill marker (D6).
|
|
165
|
+
// ============================================================================
|
|
166
|
+
//
|
|
167
|
+
// Today's per-project active-skill marker (`.peaks/_runtime/active-skill.json`)
|
|
168
|
+
// races when multiple Claude Code windows (or different platforms) drive the
|
|
169
|
+
// same project concurrently. Slice 020 introduces a per-caller file at
|
|
170
|
+
// `.peaks/_runtime/<peakSid>/active-skill-<callerId>.json` (D6). Two callers
|
|
171
|
+
// bound to the same peak session never clobber each other.
|
|
172
|
+
//
|
|
173
|
+
// The single-file marker is RETAINED for one minor release as read-only
|
|
174
|
+
// back-compat (M1, M4). The new write path is `setSkillPresenceForCaller`;
|
|
175
|
+
// the legacy `setSkillPresence` is now a thin wrapper that synthesises a
|
|
176
|
+
// legacy callerId from `process.env.CLAUDE_CODE_SESSION_ID` (or
|
|
177
|
+
// `projectRoot` for the truly-anonymous case) and delegates.
|
|
178
|
+
/**
|
|
179
|
+
* Write the per-caller active-skill marker to
|
|
180
|
+
* `.peaks/_runtime/<peakSid>/active-skill-<callerId>.json` (D6). Returns
|
|
181
|
+
* the written presence with the `callerId` field set.
|
|
182
|
+
*
|
|
183
|
+
* The caller is responsible for resolving the `callerId` (via
|
|
184
|
+
* `resolveCallerId` from `src/services/session/resolve-caller-id.ts`)
|
|
185
|
+
* and the `peakSessionId` (via `getCallerBinding` then reading
|
|
186
|
+
* `peakSessionId`, OR via `ensureSession` for the first-time case).
|
|
187
|
+
*/
|
|
188
|
+
export function setSkillPresenceForCaller(projectRootOverride, callerId, peakSessionId, skill, mode, gate) {
|
|
189
|
+
const validatedMode = mode && isSkillPresenceMode(mode) ? mode : undefined;
|
|
190
|
+
const now = new Date().toISOString();
|
|
191
|
+
const presence = {
|
|
192
|
+
skill,
|
|
193
|
+
...(validatedMode ? { mode: validatedMode } : {}),
|
|
194
|
+
...(gate ? { gate } : {}),
|
|
195
|
+
...(peakSessionId ? { sessionId: peakSessionId } : {}),
|
|
196
|
+
...(callerId ? { outerSessionId: callerId } : {}),
|
|
197
|
+
setAt: now,
|
|
198
|
+
lastHeartbeat: now
|
|
199
|
+
};
|
|
200
|
+
const presencePath = getActiveSkillFileForCallerPath(resolveProjectRoot(projectRootOverride), peakSessionId, callerId);
|
|
201
|
+
const presenceDir = dirname(presencePath);
|
|
202
|
+
if (!existsSync(presenceDir)) {
|
|
203
|
+
mkdirSync(presenceDir, { recursive: true });
|
|
204
|
+
}
|
|
205
|
+
writeFileSync(presencePath, JSON.stringify(presence, null, 2), 'utf8');
|
|
206
|
+
// Skill-activation side effect: bring the memory store into existence for
|
|
207
|
+
// fresh projects. Same fail-open contract as the legacy path.
|
|
208
|
+
ensureMemoryBootstrap(resolveProjectRoot(projectRootOverride));
|
|
209
|
+
return presence;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Compute the per-caller active-skill file path. Re-exported for test
|
|
213
|
+
* ergonomics; canonical path lives in
|
|
214
|
+
* `src/services/session/caller-binding-service.ts` but inlined here to
|
|
215
|
+
* avoid a circular import (`caller-binding-service` reads
|
|
216
|
+
* `skill-presence-service` for the `setCallerBinding` integration in
|
|
217
|
+
* future slices; the inverse import would deadlock).
|
|
218
|
+
*/
|
|
219
|
+
function getActiveSkillFileForCallerPath(projectRoot, peakSessionId, callerId) {
|
|
220
|
+
return resolve(projectRoot, '.peaks', '_runtime', peakSessionId, `active-skill-${callerId}.json`);
|
|
221
|
+
}
|
|
158
222
|
export function setSkillPresence(skill, mode, gate, projectRootOverride) {
|
|
159
223
|
const validatedMode = mode && isSkillPresenceMode(mode) ? mode : undefined;
|
|
160
224
|
const sessionId = getCurrentSessionId(projectRootOverride);
|