peaks-cli 1.2.6 → 1.2.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.
Files changed (30) hide show
  1. package/README.md +108 -122
  2. package/dist/src/cli/commands/core-artifact-commands.js +36 -1
  3. package/dist/src/cli/commands/perf-commands.d.ts +3 -0
  4. package/dist/src/cli/commands/perf-commands.js +41 -0
  5. package/dist/src/cli/commands/progress-close-kill.d.ts +51 -0
  6. package/dist/src/cli/commands/progress-close-kill.js +152 -0
  7. package/dist/src/cli/commands/progress-commands.d.ts +3 -0
  8. package/dist/src/cli/commands/progress-commands.js +348 -0
  9. package/dist/src/cli/commands/progress-start-spawn.d.ts +59 -0
  10. package/dist/src/cli/commands/progress-start-spawn.js +114 -0
  11. package/dist/src/cli/commands/progress-watch-render.d.ts +80 -0
  12. package/dist/src/cli/commands/progress-watch-render.js +308 -0
  13. package/dist/src/cli/program.js +4 -0
  14. package/dist/src/services/config/config-types.d.ts +20 -0
  15. package/dist/src/services/config/config-types.js +5 -1
  16. package/dist/src/services/perf/perf-baseline-service.d.ts +70 -0
  17. package/dist/src/services/perf/perf-baseline-service.js +213 -0
  18. package/dist/src/services/progress/progress-service.d.ts +179 -0
  19. package/dist/src/services/progress/progress-service.js +276 -0
  20. package/dist/src/services/session/index.d.ts +1 -1
  21. package/dist/src/services/session/index.js +1 -1
  22. package/dist/src/services/session/session-manager.d.ts +53 -8
  23. package/dist/src/services/session/session-manager.js +150 -3
  24. package/dist/src/services/skills/skill-presence-service.d.ts +27 -1
  25. package/dist/src/services/skills/skill-presence-service.js +112 -9
  26. package/dist/src/shared/version.d.ts +1 -1
  27. package/dist/src/shared/version.js +1 -1
  28. package/package.json +6 -2
  29. package/skills/peaks-qa/SKILL.md +13 -0
  30. package/skills/peaks-rd/SKILL.md +76 -0
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Sub-agent progress surfacing for the RD/QA sub-agents in
3
+ * `peaks-solo`'s Swarm phase. A sub-agent (or the LLM via the
4
+ * `peaks progress step` CLI) writes a stable JSON file at
5
+ * `.peaks/<sid>/system/subagent-progress.json`. The user-side
6
+ * `peaks progress watch` CLI polls this file in a separate
7
+ * terminal tab and renders elapsed / spinner / sub-step. The
8
+ * `peaks progress start` CLI auto-spawns the watch in a new
9
+ * terminal window so the user does not have to remember to do
10
+ * it.
11
+ *
12
+ * Token cost design (the binding constraint of this feature):
13
+ * - The LLM side (step CLI) writes the file at most once per
14
+ * phase transition. That is approximately one Bash call per
15
+ * RD/QA sub-step. In a typical 5-step sub-agent slice the
16
+ * cost is < 10 output tokens.
17
+ * - The watch side polls a local file, not the LLM. Zero token
18
+ * cost.
19
+ * - The auto-spawn side (start CLI) is invoked once per
20
+ * session by the LLM at the first phase transition. One
21
+ * Bash call. The user closes the new terminal at any time;
22
+ * no further side effects.
23
+ *
24
+ * Net: the LLM pays a one-time < 10 token cost per slice to
25
+ * give the user real-time progress visibility. The user pays
26
+ * zero manual setup.
27
+ *
28
+ * This module is pure filesystem. It does NOT import the LLM
29
+ * harness, does NOT spawn terminals, and does NOT talk to any
30
+ * IPC. Those are concerns of the CLI layer (../cli/commands/
31
+ * progress-commands.ts and hooks-settings-service.ts).
32
+ */
33
+ export type SubAgentProgressPhase = 'starting' | 'running' | 'verifying' | 'completing' | 'finished' | 'failed' | 'idle';
34
+ export type SubAgentProgressStep = {
35
+ /** ISO-8601 timestamp at which the sub-agent started. */
36
+ startedAt: string;
37
+ /** ISO-8601 timestamp at which the sub-agent finished (or now, if still running). */
38
+ updatedAt: string;
39
+ /** Free-form human-readable sub-step label, e.g. "running test/ut". */
40
+ step: string;
41
+ /** Current phase bucket. `idle` is the pre-start sentinel. */
42
+ phase: SubAgentProgressPhase;
43
+ /** When set, the sub-agent is finished and reports the verdict here. */
44
+ verdict?: 'pass' | 'return-to-rd' | 'blocked';
45
+ /** Optional count of in-scope files touched, assertions run, etc. */
46
+ counts?: {
47
+ filesTouched?: number;
48
+ testsRun?: number;
49
+ };
50
+ };
51
+ export type SubAgentProgress = {
52
+ version: 1;
53
+ sessionId: string;
54
+ outerSessionId?: string;
55
+ /** Outer-agent role that owns the slice (e.g. "rd", "qa"). */
56
+ role: string;
57
+ /** Per-slice identifier. */
58
+ requestId: string;
59
+ /** When the sub-agent entered the first non-idle state. */
60
+ startedAt: string;
61
+ /** When the sub-agent last touched the file. */
62
+ updatedAt: string;
63
+ /** Current step. */
64
+ current: SubAgentProgressStep;
65
+ /**
66
+ * History of completed steps. Length is unbounded; the watch
67
+ * tool renders the most recent N. Kept for after-the-fact
68
+ * forensics ("how long did step 3 take?").
69
+ */
70
+ history: SubAgentProgressStep[];
71
+ };
72
+ export type ReadProgressOptions = {
73
+ projectRoot: string;
74
+ };
75
+ export type ReadProgressResult = {
76
+ ok: true;
77
+ data: SubAgentProgress;
78
+ path: string;
79
+ } | {
80
+ ok: false;
81
+ reason: 'no-binding' | 'no-progress-file' | 'invalid-json';
82
+ };
83
+ export type WriteProgressOptions = {
84
+ projectRoot: string;
85
+ requestId: string;
86
+ role: string;
87
+ step: string;
88
+ phase: SubAgentProgressPhase;
89
+ verdict?: 'pass' | 'return-to-rd' | 'blocked';
90
+ counts?: SubAgentProgressStep['counts'];
91
+ outerSessionId?: string;
92
+ };
93
+ /**
94
+ * Read the current progress file. Returns a tagged result so
95
+ * the CLI can map each failure mode to a distinct nextActions
96
+ * hint (no-binding → run peaks workspace init; no-progress-file
97
+ * → sub-agent has not started yet; invalid-json → the LLM wrote
98
+ * garbage; recover by writing a fresh file).
99
+ */
100
+ export declare function readSubAgentProgress(options: ReadProgressOptions): ReadProgressResult;
101
+ /**
102
+ * Append-or-replace the current step. Idempotent on identical
103
+ * (step, phase) — the file is rewritten with the same payload
104
+ * (no new history entry) so heartbeats from the same phase do
105
+ * not pollute the history. Phase transitions append a new step
106
+ * to the history and replace `current`.
107
+ */
108
+ export declare function writeSubAgentProgress(options: WriteProgressOptions): SubAgentProgress;
109
+ /**
110
+ * Resolve the project root for a CLI invocation: --project
111
+ * override wins, otherwise the canonical git-root promotion
112
+ * (so the sub-agent's writes land in the same `.peaks/<sid>/`
113
+ * the user's manual CLI would use). Re-exports the same
114
+ * helper peaks workspace init / session rotate already use
115
+ * for symmetry.
116
+ */
117
+ export declare function resolveProgressProjectRoot(override: string | undefined, cwd: string): string;
118
+ /**
119
+ * Compute the absolute path to the progress file for a given
120
+ * project root, for callers that need to display / fs.watch it
121
+ * (the watch banner, the auto-spawn helper, the close command).
122
+ * This MUST agree with `progressPath` — the read/write helpers
123
+ * resolve through `progressPath` and use the session sub-directory,
124
+ * so the displayed path does too. Without this agreement the
125
+ * watch banner would point at a path the file is never written
126
+ * to, and the user would `cat` an empty file.
127
+ */
128
+ export declare function subAgentProgressPath(projectRoot: string): string;
129
+ /**
130
+ * Compute the absolute path to the spawn record for a given
131
+ * project root. Exported so `peaks progress close` (and the
132
+ * start command's success payload) can advertise the on-disk
133
+ * location without re-deriving the session sub-directory.
134
+ */
135
+ export declare function subAgentSpawnPath(projectRoot: string): string;
136
+ export type ProgressSpawnRecord = {
137
+ version: 1;
138
+ sessionId: string;
139
+ pid: number;
140
+ platform: NodeJS.Platform;
141
+ command: string;
142
+ args: string[];
143
+ spawnedAt: string;
144
+ reason?: string;
145
+ /** The title we asked the terminal emulator to set. */
146
+ windowTitle: string;
147
+ };
148
+ export type WriteSpawnRecordOptions = {
149
+ projectRoot: string;
150
+ pid: number;
151
+ platform: NodeJS.Platform;
152
+ command: string;
153
+ args: string[];
154
+ reason?: string;
155
+ windowTitle: string;
156
+ };
157
+ export declare function writeSpawnRecord(options: WriteSpawnRecordOptions): ProgressSpawnRecord | null;
158
+ export type ReadSpawnRecordResult = {
159
+ ok: true;
160
+ data: ProgressSpawnRecord;
161
+ path: string;
162
+ } | {
163
+ ok: false;
164
+ reason: 'no-binding' | 'no-spawn-record' | 'invalid-json';
165
+ };
166
+ export declare function readSpawnRecord(projectRoot: string): ReadSpawnRecordResult;
167
+ export declare function clearSpawnRecord(projectRoot: string): boolean;
168
+ export type PhaseClosingTrigger = 'finished' | 'failed';
169
+ /**
170
+ * True if a transition into the given phase should auto-close
171
+ * the spawned watch window. `finished` and `failed` both
172
+ * indicate the sub-agent is done; a `blocked` verdict on a
173
+ * `finished` step is intentionally NOT a close trigger
174
+ * because a blocked slice usually means the user needs to
175
+ * read the watch output before deciding what to do. The CLI
176
+ * layer reads `data.current.phase`, not the verdict, so this
177
+ * helper is the only close-decision source of truth.
178
+ */
179
+ export declare function phaseAutoClosesSpawn(phase: SubAgentProgressPhase): boolean;
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Sub-agent progress surfacing for the RD/QA sub-agents in
3
+ * `peaks-solo`'s Swarm phase. A sub-agent (or the LLM via the
4
+ * `peaks progress step` CLI) writes a stable JSON file at
5
+ * `.peaks/<sid>/system/subagent-progress.json`. The user-side
6
+ * `peaks progress watch` CLI polls this file in a separate
7
+ * terminal tab and renders elapsed / spinner / sub-step. The
8
+ * `peaks progress start` CLI auto-spawns the watch in a new
9
+ * terminal window so the user does not have to remember to do
10
+ * it.
11
+ *
12
+ * Token cost design (the binding constraint of this feature):
13
+ * - The LLM side (step CLI) writes the file at most once per
14
+ * phase transition. That is approximately one Bash call per
15
+ * RD/QA sub-step. In a typical 5-step sub-agent slice the
16
+ * cost is < 10 output tokens.
17
+ * - The watch side polls a local file, not the LLM. Zero token
18
+ * cost.
19
+ * - The auto-spawn side (start CLI) is invoked once per
20
+ * session by the LLM at the first phase transition. One
21
+ * Bash call. The user closes the new terminal at any time;
22
+ * no further side effects.
23
+ *
24
+ * Net: the LLM pays a one-time < 10 token cost per slice to
25
+ * give the user real-time progress visibility. The user pays
26
+ * zero manual setup.
27
+ *
28
+ * This module is pure filesystem. It does NOT import the LLM
29
+ * harness, does NOT spawn terminals, and does NOT talk to any
30
+ * IPC. Those are concerns of the CLI layer (../cli/commands/
31
+ * progress-commands.ts and hooks-settings-service.ts).
32
+ */
33
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
34
+ import { dirname, join, resolve } from 'node:path';
35
+ import { getSessionIdCanonical } from '../session/session-manager.js';
36
+ import { findProjectRoot } from '../config/config-safety.js';
37
+ const PROGRESS_REL_PATH = 'system/subagent-progress.json';
38
+ const SPAWN_REL_PATH = 'system/progress-spawn.json';
39
+ function progressPath(projectRoot) {
40
+ // The progress file lives under the *session* directory, not
41
+ // directly under .peaks/. Every other per-slice artefact
42
+ // (rd/tech-doc.md, qa/test-cases/<rid>.md, prd/requests/<rid>.md,
43
+ // memory/, openspec/) lives under .peaks/<sid>/, so progress
44
+ // should too. Without the session prefix, a session rotation
45
+ // would orphan the file in the project root, and switching
46
+ // sessions would have the watch reading the wrong slice's
47
+ // progress.
48
+ const sessionId = getSessionIdCanonical(projectRoot);
49
+ const subDir = sessionId ?? 'unbound';
50
+ return join(projectRoot, '.peaks', subDir, PROGRESS_REL_PATH);
51
+ }
52
+ function ensureParentDir(path) {
53
+ const dir = dirname(path);
54
+ if (!existsSync(dir)) {
55
+ mkdirSync(dir, { recursive: true });
56
+ }
57
+ }
58
+ function nowIso() {
59
+ return new Date().toISOString();
60
+ }
61
+ /**
62
+ * Read the current progress file. Returns a tagged result so
63
+ * the CLI can map each failure mode to a distinct nextActions
64
+ * hint (no-binding → run peaks workspace init; no-progress-file
65
+ * → sub-agent has not started yet; invalid-json → the LLM wrote
66
+ * garbage; recover by writing a fresh file).
67
+ */
68
+ export function readSubAgentProgress(options) {
69
+ const sessionId = getSessionIdCanonical(options.projectRoot);
70
+ if (sessionId === null) {
71
+ return { ok: false, reason: 'no-binding' };
72
+ }
73
+ const path = progressPath(options.projectRoot);
74
+ if (!existsSync(path)) {
75
+ return { ok: false, reason: 'no-progress-file' };
76
+ }
77
+ try {
78
+ const data = JSON.parse(readFileSync(path, 'utf8'));
79
+ if (data.version !== 1 || typeof data.sessionId !== 'string') {
80
+ return { ok: false, reason: 'invalid-json' };
81
+ }
82
+ return { ok: true, data, path };
83
+ }
84
+ catch {
85
+ return { ok: false, reason: 'invalid-json' };
86
+ }
87
+ }
88
+ /**
89
+ * Append-or-replace the current step. Idempotent on identical
90
+ * (step, phase) — the file is rewritten with the same payload
91
+ * (no new history entry) so heartbeats from the same phase do
92
+ * not pollute the history. Phase transitions append a new step
93
+ * to the history and replace `current`.
94
+ */
95
+ export function writeSubAgentProgress(options) {
96
+ const existing = readSubAgentProgress({ projectRoot: options.projectRoot });
97
+ const now = nowIso();
98
+ const path = progressPath(options.projectRoot);
99
+ if (existing.ok) {
100
+ const prev = existing.data;
101
+ // Heartbeat on the same current step: just bump updatedAt, do
102
+ // NOT add a history entry. The shape of `current` is preserved.
103
+ if (prev.current.step === options.step && prev.current.phase === options.phase) {
104
+ const next = {
105
+ ...prev,
106
+ updatedAt: now,
107
+ current: {
108
+ ...prev.current,
109
+ updatedAt: now,
110
+ ...(options.verdict !== undefined ? { verdict: options.verdict } : {}),
111
+ ...(options.counts !== undefined ? { counts: options.counts } : {})
112
+ }
113
+ };
114
+ ensureParentDir(path);
115
+ writeFileSync(path, JSON.stringify(next, null, 2) + '\n', 'utf8');
116
+ return next;
117
+ }
118
+ // Real phase / step transition: archive the prior current
119
+ // into history, install the new current.
120
+ const archived = {
121
+ ...prev.current,
122
+ updatedAt: now
123
+ };
124
+ const next = {
125
+ ...prev,
126
+ updatedAt: now,
127
+ current: {
128
+ startedAt: now,
129
+ updatedAt: now,
130
+ step: options.step,
131
+ phase: options.phase,
132
+ ...(options.verdict !== undefined ? { verdict: options.verdict } : {}),
133
+ ...(options.counts !== undefined ? { counts: options.counts } : {})
134
+ },
135
+ history: [...prev.history, archived]
136
+ };
137
+ ensureParentDir(path);
138
+ writeFileSync(path, JSON.stringify(next, null, 2) + '\n', 'utf8');
139
+ return next;
140
+ }
141
+ // No prior file: this is the first write. Bootstrap a fresh
142
+ // progress doc. The sessionId is whatever the binding points
143
+ // at, so cross-session confusion is impossible.
144
+ const sessionId = getSessionIdCanonical(options.projectRoot) ?? 'unbound';
145
+ const fresh = {
146
+ version: 1,
147
+ sessionId,
148
+ ...(options.outerSessionId !== undefined ? { outerSessionId: options.outerSessionId } : {}),
149
+ role: options.role,
150
+ requestId: options.requestId,
151
+ startedAt: now,
152
+ updatedAt: now,
153
+ current: {
154
+ startedAt: now,
155
+ updatedAt: now,
156
+ step: options.step,
157
+ phase: options.phase,
158
+ ...(options.verdict !== undefined ? { verdict: options.verdict } : {}),
159
+ ...(options.counts !== undefined ? { counts: options.counts } : {})
160
+ },
161
+ history: []
162
+ };
163
+ ensureParentDir(path);
164
+ writeFileSync(path, JSON.stringify(fresh, null, 2) + '\n', 'utf8');
165
+ return fresh;
166
+ }
167
+ /**
168
+ * Resolve the project root for a CLI invocation: --project
169
+ * override wins, otherwise the canonical git-root promotion
170
+ * (so the sub-agent's writes land in the same `.peaks/<sid>/`
171
+ * the user's manual CLI would use). Re-exports the same
172
+ * helper peaks workspace init / session rotate already use
173
+ * for symmetry.
174
+ */
175
+ export function resolveProgressProjectRoot(override, cwd) {
176
+ if (override !== undefined)
177
+ return override;
178
+ return findProjectRoot(cwd) ?? cwd;
179
+ }
180
+ /**
181
+ * Compute the absolute path to the progress file for a given
182
+ * project root, for callers that need to display / fs.watch it
183
+ * (the watch banner, the auto-spawn helper, the close command).
184
+ * This MUST agree with `progressPath` — the read/write helpers
185
+ * resolve through `progressPath` and use the session sub-directory,
186
+ * so the displayed path does too. Without this agreement the
187
+ * watch banner would point at a path the file is never written
188
+ * to, and the user would `cat` an empty file.
189
+ */
190
+ export function subAgentProgressPath(projectRoot) {
191
+ return progressPath(resolve(projectRoot));
192
+ }
193
+ /**
194
+ * Compute the absolute path to the spawn record for a given
195
+ * project root. Exported so `peaks progress close` (and the
196
+ * start command's success payload) can advertise the on-disk
197
+ * location without re-deriving the session sub-directory.
198
+ */
199
+ export function subAgentSpawnPath(projectRoot) {
200
+ const sessionId = getSessionIdCanonical(projectRoot);
201
+ const subDir = sessionId ?? 'unbound';
202
+ return join(projectRoot, '.peaks', subDir, SPAWN_REL_PATH);
203
+ }
204
+ function spawnRecordPath(projectRoot) {
205
+ const sessionId = getSessionIdCanonical(projectRoot);
206
+ const subDir = sessionId ?? 'unbound';
207
+ return join(projectRoot, '.peaks', subDir, SPAWN_REL_PATH);
208
+ }
209
+ export function writeSpawnRecord(options) {
210
+ const sessionId = getSessionIdCanonical(options.projectRoot);
211
+ if (sessionId === null)
212
+ return null;
213
+ const now = nowIso();
214
+ const record = {
215
+ version: 1,
216
+ sessionId,
217
+ pid: options.pid,
218
+ platform: options.platform,
219
+ command: options.command,
220
+ args: options.args,
221
+ spawnedAt: now,
222
+ ...(options.reason !== undefined ? { reason: options.reason } : {}),
223
+ windowTitle: options.windowTitle
224
+ };
225
+ const path = spawnRecordPath(options.projectRoot);
226
+ ensureParentDir(path);
227
+ writeFileSync(path, JSON.stringify(record, null, 2) + '\n', 'utf8');
228
+ return record;
229
+ }
230
+ export function readSpawnRecord(projectRoot) {
231
+ const sessionId = getSessionIdCanonical(projectRoot);
232
+ if (sessionId === null)
233
+ return { ok: false, reason: 'no-binding' };
234
+ const path = spawnRecordPath(projectRoot);
235
+ if (!existsSync(path))
236
+ return { ok: false, reason: 'no-spawn-record' };
237
+ try {
238
+ const data = JSON.parse(readFileSync(path, 'utf8'));
239
+ if (data.version !== 1 || typeof data.pid !== 'number') {
240
+ return { ok: false, reason: 'invalid-json' };
241
+ }
242
+ return { ok: true, data, path };
243
+ }
244
+ catch {
245
+ return { ok: false, reason: 'invalid-json' };
246
+ }
247
+ }
248
+ export function clearSpawnRecord(projectRoot) {
249
+ const path = spawnRecordPath(projectRoot);
250
+ if (!existsSync(path))
251
+ return false;
252
+ try {
253
+ unlinkSync(path);
254
+ return true;
255
+ }
256
+ catch {
257
+ return false;
258
+ }
259
+ }
260
+ const PHASES_THAT_AUTO_CLOSE = new Set([
261
+ 'finished',
262
+ 'failed'
263
+ ]);
264
+ /**
265
+ * True if a transition into the given phase should auto-close
266
+ * the spawned watch window. `finished` and `failed` both
267
+ * indicate the sub-agent is done; a `blocked` verdict on a
268
+ * `finished` step is intentionally NOT a close trigger
269
+ * because a blocked slice usually means the user needs to
270
+ * read the watch output before deciding what to do. The CLI
271
+ * layer reads `data.current.phase`, not the verdict, so this
272
+ * helper is the only close-decision source of truth.
273
+ */
274
+ export function phaseAutoClosesSpawn(phase) {
275
+ return PHASES_THAT_AUTO_CLOSE.has(phase);
276
+ }
@@ -1 +1 @@
1
- export { ensureSession, getSessionId, getCurrentSessionDir, listSessions, getSessionMeta, setSessionMeta, setSessionTitle, listSessionMetas, getProjectScanPath, hasProjectScan, setCurrentSessionBinding, type SessionInfo, type SessionMeta } from './session-manager.js';
1
+ export { ensureSession, getSessionId, getCurrentSessionDir, listSessions, getSessionMeta, setSessionMeta, setSessionTitle, listSessionMetas, getProjectScanPath, hasProjectScan, setCurrentSessionBinding, rotateSessionBinding, type SessionInfo, type SessionMeta } from './session-manager.js';
@@ -1 +1 @@
1
- export { ensureSession, getSessionId, getCurrentSessionDir, listSessions, getSessionMeta, setSessionMeta, setSessionTitle, listSessionMetas, getProjectScanPath, hasProjectScan, setCurrentSessionBinding } from './session-manager.js';
1
+ export { ensureSession, getSessionId, getCurrentSessionDir, listSessions, getSessionMeta, setSessionMeta, setSessionTitle, listSessionMetas, getProjectScanPath, hasProjectScan, setCurrentSessionBinding, rotateSessionBinding } from './session-manager.js';
@@ -19,7 +19,32 @@ export type SessionMeta = {
19
19
  createdAt: string;
20
20
  lastActivity?: string;
21
21
  projectRoot: string;
22
+ /**
23
+ * The outer (harness / IDE / plugin) session id that
24
+ * `ensureSession` was called from. Sourced from
25
+ * `PEAKS_OUTER_SESSION_ID` env var, with `CLAUDE_CODE_SESSION_ID`
26
+ * as a Claude-Code fallback. Stamped once at session creation;
27
+ * later presence writes can compare against this to detect an
28
+ * outer-session swap and AskUserQuestion the user about rolling
29
+ * a new peaks session. Sessions predating the field simply
30
+ * have it undefined; presence-mismatch detection skips those
31
+ * (no false positives on legacy data).
32
+ */
33
+ outerSessionId?: string;
22
34
  };
35
+ /**
36
+ * Drop the project-level session binding (`.peaks/.session.json`)
37
+ * so the next `ensureSession()` call auto-generates a fresh
38
+ * session id. The on-disk session directory is left intact —
39
+ * rotating does NOT delete the user's data, it just unbinds the
40
+ * project from that session.
41
+ *
42
+ * Returns the id of the session that was unbound, or `null` if
43
+ * no binding was present. The caller is expected to do something
44
+ * with that — at minimum surface it in the CLI response so the
45
+ * user can find the directory again if they need to.
46
+ */
47
+ export declare function rotateSessionBinding(projectRoot: string): string | null;
23
48
  /**
24
49
  * Bind the project's current session to the given session id by writing
25
50
  * `.peaks/.session.json`. The single-session binding is the source of truth
@@ -50,14 +75,6 @@ export declare function setSessionTitle(projectRoot: string, sessionId: string,
50
75
  * Returns sessions sorted by sessionId descending (most recent first).
51
76
  */
52
77
  export declare function listSessionMetas(projectRoot: string): SessionMeta[];
53
- /**
54
- * Get or create the current session for a project.
55
- * If a valid session already exists, returns it.
56
- * Otherwise, creates a new session with auto-generated ID.
57
- *
58
- * @param projectRoot - Root directory of the project
59
- * @returns Session ID (e.g., "2026-05-26-session-a3f8b1")
60
- */
61
78
  export declare function ensureSession(projectRoot: string): Promise<string>;
62
79
  /**
63
80
  * Get the current session ID without creating a new one.
@@ -67,6 +84,34 @@ export declare function ensureSession(projectRoot: string): Promise<string>;
67
84
  * @returns Session ID or null
68
85
  */
69
86
  export declare function getSessionId(projectRoot: string): string | null;
87
+ /**
88
+ * Resolve the current session id with canonicalize-on-read
89
+ * semantics. This is the variant the progress subcommands
90
+ * (step / watch / start / close) use, because the legacy
91
+ * `getSessionId` returns null any time the stored
92
+ * `projectRoot` form differs from the caller-passed form
93
+ * (e.g. stored is "." from inside the project dir; caller
94
+ * is the absolute realpath). When `getSessionId` returns
95
+ * null, callers like `ensureSession` create a brand-new
96
+ * session and overwrite the binding — which is what the
97
+ * user observed as the "mid-dogfood rebind" bug.
98
+ *
99
+ * The fix is to canonicalize both sides of the compare
100
+ * (realpath, then optionally resolve relative stored
101
+ * against the caller's project root). The two forms of
102
+ * the same physical directory now compare equal, and the
103
+ * existing binding is found instead of being overwritten.
104
+ *
105
+ * Use this instead of `getSessionId` only when the
106
+ * caller is operating on a user-supplied `--project` flag
107
+ * and the binding may have been written by a CLI invocation
108
+ * that was running from inside the project dir (the common
109
+ * peaks-solo / peaks-sop scenario). Other modules depend
110
+ * on the strict-equality semantics of `getSessionId` (the
111
+ * "no binding" fallback path is part of their contract),
112
+ * so this variant is opt-in.
113
+ */
114
+ export declare function getSessionIdCanonical(projectRoot: string): string | null;
70
115
  /**
71
116
  * Get the absolute path to the current session directory.
72
117
  * Creates the session if it doesn't exist.