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.
- package/README.md +108 -122
- package/dist/src/cli/commands/core-artifact-commands.js +36 -1
- package/dist/src/cli/commands/perf-commands.d.ts +3 -0
- package/dist/src/cli/commands/perf-commands.js +41 -0
- package/dist/src/cli/commands/progress-close-kill.d.ts +51 -0
- package/dist/src/cli/commands/progress-close-kill.js +152 -0
- package/dist/src/cli/commands/progress-commands.d.ts +3 -0
- package/dist/src/cli/commands/progress-commands.js +348 -0
- package/dist/src/cli/commands/progress-start-spawn.d.ts +59 -0
- package/dist/src/cli/commands/progress-start-spawn.js +114 -0
- package/dist/src/cli/commands/progress-watch-render.d.ts +80 -0
- package/dist/src/cli/commands/progress-watch-render.js +308 -0
- package/dist/src/cli/program.js +4 -0
- package/dist/src/services/config/config-types.d.ts +20 -0
- package/dist/src/services/config/config-types.js +5 -1
- package/dist/src/services/perf/perf-baseline-service.d.ts +70 -0
- package/dist/src/services/perf/perf-baseline-service.js +213 -0
- package/dist/src/services/progress/progress-service.d.ts +179 -0
- package/dist/src/services/progress/progress-service.js +276 -0
- package/dist/src/services/session/index.d.ts +1 -1
- package/dist/src/services/session/index.js +1 -1
- package/dist/src/services/session/session-manager.d.ts +53 -8
- package/dist/src/services/session/session-manager.js +150 -3
- package/dist/src/services/skills/skill-presence-service.d.ts +27 -1
- package/dist/src/services/skills/skill-presence-service.js +112 -9
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +6 -2
- package/skills/peaks-qa/SKILL.md +13 -0
- package/skills/peaks-rd/SKILL.md +76 -0
|
@@ -5,12 +5,54 @@
|
|
|
5
5
|
* Sessions are automatically created when any skill is invoked.
|
|
6
6
|
* Each session gets a unique directory under .peaks/ with incrementing numbered files.
|
|
7
7
|
*/
|
|
8
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
9
|
-
import { join } from 'node:path';
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { join, resolve } from 'node:path';
|
|
10
10
|
import { randomBytes } from 'node:crypto';
|
|
11
11
|
import { initWorkspace } from '../workspace/workspace-service.js';
|
|
12
12
|
const SESSION_FILE = '.session.json';
|
|
13
13
|
const META_FILE = 'session.json';
|
|
14
|
+
/**
|
|
15
|
+
* Canonicalize a project root path. Returns the realpath
|
|
16
|
+
* (resolving all symlinks — important on macOS where `/var`
|
|
17
|
+
* is a symlink to `/private/var`, and on the dev box where
|
|
18
|
+
* `/tmp` is the same `/private/var/folders/...` target as
|
|
19
|
+
* `/var/folders/...`). If the path does not exist (e.g. in
|
|
20
|
+
* tests that write the binding before the dir, or callers
|
|
21
|
+
* that pass a non-existent path), falls back to the
|
|
22
|
+
* `resolve()`d absolute form so the function NEVER throws.
|
|
23
|
+
*/
|
|
24
|
+
function canonicalizeProjectRoot(p) {
|
|
25
|
+
try {
|
|
26
|
+
return realpathSync(p);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return resolve(p);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Resolve a stored `projectRoot` value (from
|
|
34
|
+
* `.peaks/.session.json`) against the caller-passed
|
|
35
|
+
* `projectRoot`, then canonicalize. Handles two forms the
|
|
36
|
+
* legacy strict-equality check missed:
|
|
37
|
+
*
|
|
38
|
+
* 1. **Stored is relative** (e.g. `"."` when the binding
|
|
39
|
+
* was written from inside the project dir). We resolve
|
|
40
|
+
* it against the caller — if the caller's project root
|
|
41
|
+
* is absolute, `path.resolve(caller, ".")` returns the
|
|
42
|
+
* caller's absolute form, which then canonicalizes to
|
|
43
|
+
* the caller's realpath.
|
|
44
|
+
*
|
|
45
|
+
* 2. **Both are absolute but the stored form is not
|
|
46
|
+
* canonical** (e.g. `/var/folders/...` vs
|
|
47
|
+
* `/private/var/folders/...` on macOS). Both canonicalize
|
|
48
|
+
* to the same realpath.
|
|
49
|
+
*
|
|
50
|
+
* The combined check is the canonicalize-on-read contract.
|
|
51
|
+
*/
|
|
52
|
+
function resolveStoredAgainstCaller(stored, caller) {
|
|
53
|
+
const resolved = resolve(caller, stored); // if `stored` is absolute, returns `stored`; else joins with caller
|
|
54
|
+
return canonicalizeProjectRoot(resolved);
|
|
55
|
+
}
|
|
14
56
|
/**
|
|
15
57
|
* Generate a new session ID.
|
|
16
58
|
* Format: YYYY-MM-DD-session-<6位hex>
|
|
@@ -34,6 +76,19 @@ function getSessionFilePath(projectRoot) {
|
|
|
34
76
|
/**
|
|
35
77
|
* Read existing session info from disk.
|
|
36
78
|
* Returns null if no session file exists or if it's invalid.
|
|
79
|
+
*
|
|
80
|
+
* Strict equality on `data.projectRoot === projectRoot` is
|
|
81
|
+
* preserved here on purpose: many other modules (notably
|
|
82
|
+
* `shared/change-id.ts` via `buildArtifactRelativePath`)
|
|
83
|
+
* depend on the strict-equality semantics to test the
|
|
84
|
+
* "no session bound" code path. Changing the read semantics
|
|
85
|
+
* here would cascade into ~30 test failures in those
|
|
86
|
+
* modules — out of scope for the progress rebind fix.
|
|
87
|
+
*
|
|
88
|
+
* The progress subcommands (which are the surface that
|
|
89
|
+
* actually breaks on the rebind bug) use
|
|
90
|
+
* `getSessionIdCanonical` instead, which does the
|
|
91
|
+
* canonicalize-on-read resolution the bug fix needs.
|
|
37
92
|
*/
|
|
38
93
|
function readSessionFile(projectRoot) {
|
|
39
94
|
const sessionFile = getSessionFilePath(projectRoot);
|
|
@@ -50,6 +105,33 @@ function readSessionFile(projectRoot) {
|
|
|
50
105
|
return null;
|
|
51
106
|
}
|
|
52
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Same as `readSessionFile` but canonicalizes BOTH the
|
|
110
|
+
* caller-passed and stored `projectRoot` before comparing.
|
|
111
|
+
* See `resolveStoredAgainstCaller` for the rules.
|
|
112
|
+
*
|
|
113
|
+
* Exported as `readSessionFileCanonical` so the new
|
|
114
|
+
* `getSessionIdCanonical` can use it; not part of the
|
|
115
|
+
* public API otherwise.
|
|
116
|
+
*/
|
|
117
|
+
function readSessionFileCanonical(projectRoot) {
|
|
118
|
+
const sessionFile = getSessionFilePath(projectRoot);
|
|
119
|
+
if (!existsSync(sessionFile))
|
|
120
|
+
return null;
|
|
121
|
+
try {
|
|
122
|
+
const data = JSON.parse(readFileSync(sessionFile, 'utf8'));
|
|
123
|
+
const storedRaw = typeof data.projectRoot === 'string' ? data.projectRoot : null;
|
|
124
|
+
if (data.sessionId &&
|
|
125
|
+
storedRaw !== null &&
|
|
126
|
+
resolveStoredAgainstCaller(storedRaw, projectRoot) === resolveStoredAgainstCaller(projectRoot, projectRoot)) {
|
|
127
|
+
return data;
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
53
135
|
/**
|
|
54
136
|
* Write session info to disk.
|
|
55
137
|
*/
|
|
@@ -61,6 +143,29 @@ function writeSessionFile(projectRoot, info) {
|
|
|
61
143
|
}
|
|
62
144
|
writeFileSync(sessionFile, JSON.stringify(info, null, 2), 'utf8');
|
|
63
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Drop the project-level session binding (`.peaks/.session.json`)
|
|
148
|
+
* so the next `ensureSession()` call auto-generates a fresh
|
|
149
|
+
* session id. The on-disk session directory is left intact —
|
|
150
|
+
* rotating does NOT delete the user's data, it just unbinds the
|
|
151
|
+
* project from that session.
|
|
152
|
+
*
|
|
153
|
+
* Returns the id of the session that was unbound, or `null` if
|
|
154
|
+
* no binding was present. The caller is expected to do something
|
|
155
|
+
* with that — at minimum surface it in the CLI response so the
|
|
156
|
+
* user can find the directory again if they need to.
|
|
157
|
+
*/
|
|
158
|
+
export function rotateSessionBinding(projectRoot) {
|
|
159
|
+
const previous = readSessionFile(projectRoot);
|
|
160
|
+
if (previous === null) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
const sessionFile = getSessionFilePath(projectRoot);
|
|
164
|
+
if (existsSync(sessionFile)) {
|
|
165
|
+
unlinkSync(sessionFile);
|
|
166
|
+
}
|
|
167
|
+
return previous.sessionId;
|
|
168
|
+
}
|
|
64
169
|
/**
|
|
65
170
|
* Bind the project's current session to the given session id by writing
|
|
66
171
|
* `.peaks/.session.json`. The single-session binding is the source of truth
|
|
@@ -168,6 +273,15 @@ export function listSessionMetas(projectRoot) {
|
|
|
168
273
|
* @param projectRoot - Root directory of the project
|
|
169
274
|
* @returns Session ID (e.g., "2026-05-26-session-a3f8b1")
|
|
170
275
|
*/
|
|
276
|
+
function getCurrentOuterSessionId() {
|
|
277
|
+
const peaks = process.env.PEAKS_OUTER_SESSION_ID;
|
|
278
|
+
if (typeof peaks === 'string' && peaks.length > 0)
|
|
279
|
+
return peaks;
|
|
280
|
+
const claude = process.env.CLAUDE_CODE_SESSION_ID;
|
|
281
|
+
if (typeof claude === 'string' && claude.length > 0)
|
|
282
|
+
return claude;
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
171
285
|
export async function ensureSession(projectRoot) {
|
|
172
286
|
const existing = readSessionFile(projectRoot);
|
|
173
287
|
if (existing) {
|
|
@@ -183,10 +297,12 @@ export async function ensureSession(projectRoot) {
|
|
|
183
297
|
writeSessionFile(projectRoot, info);
|
|
184
298
|
await initWorkspace({ projectRoot, sessionId });
|
|
185
299
|
// Initialize session metadata inside the session directory
|
|
300
|
+
const outerSessionId = getCurrentOuterSessionId();
|
|
186
301
|
writeSessionMeta(projectRoot, sessionId, {
|
|
187
302
|
sessionId,
|
|
188
303
|
projectRoot,
|
|
189
|
-
createdAt: now
|
|
304
|
+
createdAt: now,
|
|
305
|
+
...(outerSessionId !== undefined ? { outerSessionId } : {})
|
|
190
306
|
});
|
|
191
307
|
return sessionId;
|
|
192
308
|
}
|
|
@@ -201,6 +317,37 @@ export function getSessionId(projectRoot) {
|
|
|
201
317
|
const info = readSessionFile(projectRoot);
|
|
202
318
|
return info?.sessionId ?? null;
|
|
203
319
|
}
|
|
320
|
+
/**
|
|
321
|
+
* Resolve the current session id with canonicalize-on-read
|
|
322
|
+
* semantics. This is the variant the progress subcommands
|
|
323
|
+
* (step / watch / start / close) use, because the legacy
|
|
324
|
+
* `getSessionId` returns null any time the stored
|
|
325
|
+
* `projectRoot` form differs from the caller-passed form
|
|
326
|
+
* (e.g. stored is "." from inside the project dir; caller
|
|
327
|
+
* is the absolute realpath). When `getSessionId` returns
|
|
328
|
+
* null, callers like `ensureSession` create a brand-new
|
|
329
|
+
* session and overwrite the binding — which is what the
|
|
330
|
+
* user observed as the "mid-dogfood rebind" bug.
|
|
331
|
+
*
|
|
332
|
+
* The fix is to canonicalize both sides of the compare
|
|
333
|
+
* (realpath, then optionally resolve relative stored
|
|
334
|
+
* against the caller's project root). The two forms of
|
|
335
|
+
* the same physical directory now compare equal, and the
|
|
336
|
+
* existing binding is found instead of being overwritten.
|
|
337
|
+
*
|
|
338
|
+
* Use this instead of `getSessionId` only when the
|
|
339
|
+
* caller is operating on a user-supplied `--project` flag
|
|
340
|
+
* and the binding may have been written by a CLI invocation
|
|
341
|
+
* that was running from inside the project dir (the common
|
|
342
|
+
* peaks-solo / peaks-sop scenario). Other modules depend
|
|
343
|
+
* on the strict-equality semantics of `getSessionId` (the
|
|
344
|
+
* "no binding" fallback path is part of their contract),
|
|
345
|
+
* so this variant is opt-in.
|
|
346
|
+
*/
|
|
347
|
+
export function getSessionIdCanonical(projectRoot) {
|
|
348
|
+
const info = readSessionFileCanonical(projectRoot);
|
|
349
|
+
return info?.sessionId ?? null;
|
|
350
|
+
}
|
|
204
351
|
/**
|
|
205
352
|
* Get the absolute path to the current session directory.
|
|
206
353
|
* Creates the session if it doesn't exist.
|
|
@@ -6,7 +6,33 @@ export type SkillPresence = {
|
|
|
6
6
|
mode?: SkillPresenceMode;
|
|
7
7
|
gate?: string;
|
|
8
8
|
sessionId?: string;
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Identifier of the *outer* session — the Claude Code / Cursor /
|
|
11
|
+
* VSCode-plugin / other harness session that is currently driving
|
|
12
|
+
* the LLM. Sourced from the `PEAKS_OUTER_SESSION_ID` environment
|
|
13
|
+
* variable when set, with `CLAUDE_CODE_SESSION_ID` as a fallback for
|
|
14
|
+
* Claude Code. Stamped onto the presence file so the status line
|
|
15
|
+
* can tell whether the recorded skill belongs to the live outer
|
|
16
|
+
* session (show it) or a previous one (render idle), and so
|
|
17
|
+
* `setSkillPresence` can detect a session swap and AskUserQuestion
|
|
18
|
+
* the user about rolling a new peaks session.
|
|
19
|
+
*/
|
|
20
|
+
outerSessionId?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Set by `setSkillPresence` when the outer session id changed
|
|
23
|
+
* between the last presence write and this one AND the bound
|
|
24
|
+
* peaks session has a different (or no) recorded outer session id.
|
|
25
|
+
* The field is informational only — `setSkillPresence` does not
|
|
26
|
+
* roll a new session on its own. peaks-solo's Step 0 reads the
|
|
27
|
+
* field off the presence file and turns it into an
|
|
28
|
+
* AskUserQuestion: "Start a new peaks session / Keep this one".
|
|
29
|
+
*/
|
|
30
|
+
outerSessionMismatch?: {
|
|
31
|
+
previous?: string;
|
|
32
|
+
current: string;
|
|
33
|
+
boundSessionId: string;
|
|
34
|
+
boundOuterSessionId?: string;
|
|
35
|
+
};
|
|
10
36
|
setAt: string;
|
|
11
37
|
lastHeartbeat?: string;
|
|
12
38
|
};
|
|
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from '
|
|
|
2
2
|
import { dirname, resolve } from 'node:path';
|
|
3
3
|
import { findProjectRoot } from '../config/config-safety.js';
|
|
4
4
|
import { ensureMemoryBootstrap } from '../memory/project-memory-service.js';
|
|
5
|
+
import { getSessionMeta } from '../session/session-manager.js';
|
|
5
6
|
export const VALID_SKILL_PRESENCE_MODES = [
|
|
6
7
|
'full-auto',
|
|
7
8
|
'assisted',
|
|
@@ -12,14 +13,23 @@ export function isSkillPresenceMode(value) {
|
|
|
12
13
|
return VALID_SKILL_PRESENCE_MODES.includes(value);
|
|
13
14
|
}
|
|
14
15
|
/**
|
|
15
|
-
* The current
|
|
16
|
-
*
|
|
17
|
-
* file lets the read-only status line tell whether the recorded
|
|
18
|
-
* to the live session (show it) or a previous one
|
|
16
|
+
* The current outer session id, exposed to Bash tool calls via the
|
|
17
|
+
* `PEAKS_OUTER_SESSION_ID` environment variable. Stamping it onto the
|
|
18
|
+
* presence file lets the read-only status line tell whether the recorded
|
|
19
|
+
* skill belongs to the live session (show it) or a previous one
|
|
20
|
+
* (render idle). Falls back to `CLAUDE_CODE_SESSION_ID` for Claude Code
|
|
21
|
+
* so existing Claude Code users get the field populated without any
|
|
22
|
+
* configuration; other harnesses that want a presence stamp can set
|
|
23
|
+
* either variable.
|
|
19
24
|
*/
|
|
20
|
-
function
|
|
21
|
-
const
|
|
22
|
-
|
|
25
|
+
function getCurrentOuterSessionId() {
|
|
26
|
+
const peaks = process.env.PEAKS_OUTER_SESSION_ID;
|
|
27
|
+
if (typeof peaks === 'string' && peaks.length > 0)
|
|
28
|
+
return peaks;
|
|
29
|
+
const claude = process.env.CLAUDE_CODE_SESSION_ID;
|
|
30
|
+
if (typeof claude === 'string' && claude.length > 0)
|
|
31
|
+
return claude;
|
|
32
|
+
return undefined;
|
|
23
33
|
}
|
|
24
34
|
const PRESENCE_FILE = '.peaks/.active-skill.json';
|
|
25
35
|
const SESSION_FILE = '.peaks/.session.json';
|
|
@@ -45,23 +55,116 @@ function getCurrentSessionId(projectRootOverride) {
|
|
|
45
55
|
return null;
|
|
46
56
|
}
|
|
47
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Look up the outer-session-id that was bound to the *current* peaks
|
|
60
|
+
* session, i.e. the one written to the per-session
|
|
61
|
+
* `.peaks/<sid>/session.json` by `ensureSession`/`initWorkspace`. This
|
|
62
|
+
* is the source of truth for "which outer session owns the
|
|
63
|
+
* in-flight peaks session".
|
|
64
|
+
*
|
|
65
|
+
* Returns `null` if no peaks session is bound yet, or if the bound
|
|
66
|
+
* session has no recorded outer session id (legacy sessions predating
|
|
67
|
+
* the outer-session contract).
|
|
68
|
+
*/
|
|
69
|
+
function getBoundOuterSessionId(projectRootOverride) {
|
|
70
|
+
const sessionId = getCurrentSessionId(projectRootOverride);
|
|
71
|
+
if (sessionId === null)
|
|
72
|
+
return undefined;
|
|
73
|
+
const projectRoot = resolveProjectRoot(projectRootOverride);
|
|
74
|
+
const meta = getSessionMeta(projectRoot, sessionId);
|
|
75
|
+
if (meta === null)
|
|
76
|
+
return undefined;
|
|
77
|
+
return typeof meta.outerSessionId === 'string' && meta.outerSessionId.length > 0
|
|
78
|
+
? meta.outerSessionId
|
|
79
|
+
: undefined;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Snapshot of the previous peaks session's outer session id, read
|
|
83
|
+
* straight off `.peaks/.active-skill.json` *before* we overwrite it.
|
|
84
|
+
* Used to detect "the LLM just opened a fresh outer session" — if
|
|
85
|
+
* the previously-recorded outer session id differs from the one we
|
|
86
|
+
* are about to stamp, the user probably closed the previous outer
|
|
87
|
+
* session and is now driving peaks from a new one. We do NOT auto-
|
|
88
|
+
* roll a new peaks session (that is destructive — it would leave
|
|
89
|
+
* the in-flight session with no LLM watching it). Instead we emit
|
|
90
|
+
* a structured `outerSessionMismatch` field on the presence
|
|
91
|
+
* envelope, and peaks-solo's Step 0 turns that into an
|
|
92
|
+
* AskUserQuestion. The user can opt to keep the current session
|
|
93
|
+
* (most common when the swap is a no-op reconnect) or to roll a
|
|
94
|
+
* fresh session (when the new outer session is genuinely a new
|
|
95
|
+
* task).
|
|
96
|
+
*/
|
|
97
|
+
function getPreviousOuterSessionId(projectRootOverride) {
|
|
98
|
+
const presencePath = resolvePresencePath(projectRootOverride);
|
|
99
|
+
if (!existsSync(presencePath))
|
|
100
|
+
return undefined;
|
|
101
|
+
try {
|
|
102
|
+
const raw = readFileSync(presencePath, 'utf8');
|
|
103
|
+
const parsed = JSON.parse(raw);
|
|
104
|
+
if (typeof parsed.outerSessionId === 'string' && parsed.outerSessionId.length > 0) {
|
|
105
|
+
return parsed.outerSessionId;
|
|
106
|
+
}
|
|
107
|
+
// Legacy field name. Honour it on the read side so v1.2.x
|
|
108
|
+
// presence files do not show as a false mismatch.
|
|
109
|
+
if (typeof parsed.claudeSessionId === 'string' && parsed.claudeSessionId.length > 0) {
|
|
110
|
+
return parsed.claudeSessionId;
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
48
118
|
export function exportSkillPresence(projectRootOverride) {
|
|
49
119
|
return resolvePresencePath(projectRootOverride);
|
|
50
120
|
}
|
|
51
121
|
export function setSkillPresence(skill, mode, gate, projectRootOverride) {
|
|
52
122
|
const validatedMode = mode && isSkillPresenceMode(mode) ? mode : undefined;
|
|
53
123
|
const sessionId = getCurrentSessionId(projectRootOverride);
|
|
54
|
-
const
|
|
124
|
+
const outerSessionId = getCurrentOuterSessionId();
|
|
125
|
+
const previousOuterSessionId = getPreviousOuterSessionId(projectRootOverride);
|
|
55
126
|
const now = new Date().toISOString();
|
|
56
127
|
const presence = {
|
|
57
128
|
skill,
|
|
58
129
|
...(validatedMode ? { mode: validatedMode } : {}),
|
|
59
130
|
...(gate ? { gate } : {}),
|
|
60
131
|
...(sessionId ? { sessionId } : {}),
|
|
61
|
-
...(
|
|
132
|
+
...(outerSessionId ? { outerSessionId } : {}),
|
|
62
133
|
setAt: now,
|
|
63
134
|
lastHeartbeat: now
|
|
64
135
|
};
|
|
136
|
+
// Outer-session-mismatch detection. Fires only when:
|
|
137
|
+
// (a) we have a *current* outer session id (i.e. some harness is
|
|
138
|
+
// driving peaks right now — PEAKS_OUTER_SESSION_ID or
|
|
139
|
+
// CLAUDE_CODE_SESSION_ID is set), AND
|
|
140
|
+
// (b) the previous presence write recorded a *different* outer
|
|
141
|
+
// session id (or none), AND
|
|
142
|
+
// (c) the current peaks session is bound to a different outer
|
|
143
|
+
// session id (or no outer session id is bound).
|
|
144
|
+
//
|
|
145
|
+
// The combination of (b) and (c) is what tells us "this is a
|
|
146
|
+
// genuine outer-session swap, not a transient env-var change".
|
|
147
|
+
// When only (b) fires (current bound session was started in this
|
|
148
|
+
// same outer session), no mismatch is reported — that is the
|
|
149
|
+
// common reconnect case.
|
|
150
|
+
if (outerSessionId !== undefined) {
|
|
151
|
+
const boundOuterSessionId = getBoundOuterSessionId(projectRootOverride);
|
|
152
|
+
const outerChanged = previousOuterSessionId !== outerSessionId;
|
|
153
|
+
const boundOuterMatches = boundOuterSessionId === outerSessionId;
|
|
154
|
+
// Suppress the false-positive where neither side ever recorded
|
|
155
|
+
// an outer session id. Two unknowns are not a swap — they are
|
|
156
|
+
// simply "no outer-session signal available yet". Only report
|
|
157
|
+
// a mismatch when at least one side has a recorded outer id.
|
|
158
|
+
const hasOuterSignal = previousOuterSessionId !== undefined || boundOuterSessionId !== undefined;
|
|
159
|
+
if (hasOuterSignal && outerChanged && !boundOuterMatches && sessionId !== null) {
|
|
160
|
+
presence.outerSessionMismatch = {
|
|
161
|
+
...(previousOuterSessionId !== undefined ? { previous: previousOuterSessionId } : {}),
|
|
162
|
+
current: outerSessionId,
|
|
163
|
+
boundSessionId: sessionId,
|
|
164
|
+
...(boundOuterSessionId !== undefined ? { boundOuterSessionId } : {})
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
65
168
|
const presencePath = resolvePresencePath(projectRootOverride);
|
|
66
169
|
const presenceDir = dirname(presencePath);
|
|
67
170
|
if (!existsSync(presenceDir)) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const CLI_VERSION = "1.2.
|
|
1
|
+
export declare const CLI_VERSION = "1.2.8";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "1.2.
|
|
1
|
+
export const CLI_VERSION = "1.2.8";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "peaks-cli",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.8",
|
|
4
4
|
"description": "Peaks CLI and short skill family for Claude Code automation.",
|
|
5
5
|
"author": "SquabbyZ",
|
|
6
6
|
"license": "MIT",
|
|
@@ -43,10 +43,14 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@colbymchenry/codegraph": "0.7.10",
|
|
46
|
+
"chalk": "^5.6.2",
|
|
46
47
|
"commander": "^12.1.0",
|
|
47
|
-
"
|
|
48
|
+
"ora": "^8.2.0",
|
|
49
|
+
"shadcn": "4.7.0",
|
|
50
|
+
"terminal-kit": "^3.1.2"
|
|
48
51
|
},
|
|
49
52
|
"devDependencies": {
|
|
53
|
+
"@types/chalk": "^2.2.4",
|
|
50
54
|
"@types/node": "^22.10.2",
|
|
51
55
|
"@vitest/coverage-v8": "^2.1.8",
|
|
52
56
|
"tsx": "^4.19.2",
|
package/skills/peaks-qa/SKILL.md
CHANGED
|
@@ -178,6 +178,19 @@ peaks openspec validate <change-id> --project <repo> --prefer-external --json
|
|
|
178
178
|
# because code review alone does not catch: hardcoded secrets, XSS vectors,
|
|
179
179
|
# bundle size regressions, render-performance issues, or missing CSP headers.
|
|
180
180
|
# If you skip A3 or A4, Peaks-Cli Gate C will block the verdict.
|
|
181
|
+
#
|
|
182
|
+
# Before running A4, read the RD's perf-baseline at
|
|
183
|
+
# .peaks/<id>/rd/perf-baseline.md (if present) and use the
|
|
184
|
+
# captured thresholds as the comparison baseline. The QA stage
|
|
185
|
+
# is still responsible for running the actual measurement
|
|
186
|
+
# (lighthouse / k6 / autocannon / project-local bench) and
|
|
187
|
+
# for the verdict — the RD-side baseline is the *known-good
|
|
188
|
+
# reference* that lets the QA stage say "X regressed by Y%"
|
|
189
|
+
# instead of "X is bad, but I have no number for what good
|
|
190
|
+
# looks like". If the RD did not produce a perf-baseline
|
|
191
|
+
# (e.g. the slice is docs / chore / has no perf surface),
|
|
192
|
+
# surface that absence in the QA test-report under a
|
|
193
|
+
# `## Performance baseline` section.
|
|
181
194
|
|
|
182
195
|
# 6. write test-report — MANDATORY, write to .peaks/<session-id>/qa/test-reports/<request-id>.md
|
|
183
196
|
# MUST contain actual execution results (pass/fail counts, coverage %, findings).
|
package/skills/peaks-rd/SKILL.md
CHANGED
|
@@ -88,6 +88,21 @@ On the first presence:set in a project, ensure the out-of-band status bar is ins
|
|
|
88
88
|
peaks statusline install --project <repo> # idempotent; skips if already installed
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
+
**Auto-spawn a progress watch terminal once per slice (BLOCKING on the first phase transition).** The user opens a fresh VSCode window per slice, not per Bash call. Without a separate progress terminal the user has no live signal that the sub-agent is alive — the only signal they have is the static statusline. So at the first phase transition of every slice, fire `peaks progress start` ONCE. The CLI auto-spawns a new terminal tab running `peaks progress watch` and the user can close the new tab at any time. Do NOT re-invoke on every phase change — one per slice is the contract. The LLM-side cost of this one invocation is one Bash call plus a small JSON envelope; the watch side is a 1s file poll that does not consume LLM tokens.
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
# At the first phase transition of a slice (after the first
|
|
95
|
+
# peaks progress step), fire the watch:
|
|
96
|
+
peaks progress start --project <repo> --reason "rd-implementing for <rid>"
|
|
97
|
+
|
|
98
|
+
# On every subsequent phase transition, only update the
|
|
99
|
+
# progress file — the watch is already running in another tab:
|
|
100
|
+
peaks progress step --project <repo> --request-id <rid> --role rd \
|
|
101
|
+
--step "running pnpm test" --phase running
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
If `peaks progress start` is unsupported on the current platform (no terminal emulator, headless container, etc.) it returns a recoverable error envelope. Surface that in the RD handoff so the user knows the auto-spawn failed; the sub-agent can still emit `peaks progress step` writes that the user reads from the on-disk file. The auto-spawn is convenience, not a gate.
|
|
105
|
+
|
|
91
106
|
Read persistent project memory via CLI (durable, LLM-authored memories):
|
|
92
107
|
|
|
93
108
|
```bash
|
|
@@ -114,6 +129,26 @@ Use the `<request-id>` PRD assigned. RD companion artifacts (task graph, scan re
|
|
|
114
129
|
|
|
115
130
|
Concrete template and rules: `references/artifact-per-request.md`.
|
|
116
131
|
|
|
132
|
+
## Two RD artifact files — do not confuse them
|
|
133
|
+
|
|
134
|
+
RD has two distinct artifact files, and the most common regression is to write the per-slice content into the per-session file. They serve different readers and live in different places:
|
|
135
|
+
|
|
136
|
+
| File | Scope | Reader | Required content |
|
|
137
|
+
|---|---|---|---|
|
|
138
|
+
| `.peaks/<sid>/rd/tech-doc.md` | per-session — the whole RD plan for the session, all slices | Solo, future LLM, the human scrolling the session | Architecture, slice graph, mock strategy, cross-cutting decisions. **Not** the place for per-slice implementation evidence. |
|
|
139
|
+
| `.peaks/<sid>/rd/requests/<rid>.md` | per-slice — one request, one planning artifact | QA, SC, the lint gate | Red-line scope, in-scope / out-of-scope, unit-test requirements, **Implementation evidence** (file list, `pnpm test` output, git diff excerpts), MCP usage, handoff, status. **This is the file the lint gate checks for placeholders.** |
|
|
140
|
+
| `.peaks/<sid>/rd/code-review.md` | per-session — the engineering review | QA, the human reviewer | Code review findings + fixes. |
|
|
141
|
+
| `.peaks/<sid>/rd/security-review.md` | per-session — the security review | QA | Security review findings + fixes. |
|
|
142
|
+
|
|
143
|
+
**Failure mode the lint gate catches**: the LLM writes the actual implementation content into `rd/tech-doc.md` and leaves `rd/requests/<rid>.md` as the default template (with placeholder sections like "Implementation evidence: 留待 RD 实施阶段补充" and "MCP usage: N/A"). The lint gate then fails the slice with 6+ lint errors on the `<rid>.md` template even though the actual content lives in `tech-doc.md`.
|
|
144
|
+
|
|
145
|
+
**Rule**:
|
|
146
|
+
- **Per-slice content** (red-line scope, in-scope / out-of-scope, the implementation evidence list, the unit-test assertions, the handoff) → **belongs in `rd/requests/<rid>.md`**.
|
|
147
|
+
- **Per-session content** (the architecture overview, the slice roadmap, the cross-cutting concerns, the mock strategy for the whole session) → **belongs in `rd/tech-doc.md`**.
|
|
148
|
+
- When in doubt: copy the per-slice content into the `<rid>.md` artifact's "Implementation evidence" section after writing it to `tech-doc.md`. The two files can carry overlapping context; the gate only enforces that `<rid>.md` is not empty placeholders.
|
|
149
|
+
|
|
150
|
+
Concrete template and rules: `references/artifact-per-request.md`.
|
|
151
|
+
|
|
117
152
|
## Default runbook
|
|
118
153
|
|
|
119
154
|
The default sequence the RD skill should execute for a code-touching request. Skip steps that do not apply to the request type; do not skip the artifact, coverage gate, or red-line scope steps.
|
|
@@ -407,6 +442,47 @@ Before every code or mock change, RD must write and then enforce a red-line scop
|
|
|
407
442
|
- Never add `tailwindcss` to a project that already uses a component library with its own CSS-in-JS solution unless the project-scan explicitly approves it
|
|
408
443
|
- If TailwindCSS is already present, use it consistently with the project's existing utility patterns; do not mix TailwindCSS utility classes with component-library `style` prop overrides on the same element
|
|
409
444
|
|
|
445
|
+
## Mandatory perf-baseline output (RD-side perf gate)
|
|
446
|
+
|
|
447
|
+
**BLOCKING — Do not hand off to QA without a perf-baseline file when the slice has a user-visible performance surface.** The QA stage's Gate A4 (performance check) needs a stable reference to diff against; without an RD-side baseline, the first time Gate A4 runs it has nothing to compare against and any regression it finds is a blind-side surprise. The user-facing pain of leaving perf to QA only has historically been a 3-cycle repair loop ("QA returns for perf", "RD ships a fix", "QA returns for perf again", "RD ships another fix", ...). The RD-side baseline closes that loop.
|
|
448
|
+
|
|
449
|
+
**When this applies:**
|
|
450
|
+
- feature / refactor slices that touch a route, hook, API, or any user-perceivable surface
|
|
451
|
+
- bugfix slices where the bug is performance-shaped (slow render, hot loop, N+1)
|
|
452
|
+
- any slice where the PRD mentions a number (LCP / FCP / TBT / p95 / rps / etc.)
|
|
453
|
+
|
|
454
|
+
**When this does NOT apply:**
|
|
455
|
+
- docs / chore slices
|
|
456
|
+
- pure bugfixes whose fix is "remove the bug" (no perf surface)
|
|
457
|
+
- any slice where the slice is documentation-only or otherwise has no perf surface — in that case write `N/A — no perf surface` in the file's "Notes" section and surface that fact in the RD handoff
|
|
458
|
+
|
|
459
|
+
**How to produce the file:**
|
|
460
|
+
|
|
461
|
+
```bash
|
|
462
|
+
# 1. dry-run preview (default)
|
|
463
|
+
peaks perf baseline --project <repo>
|
|
464
|
+
# → ok: true, data.plannedWrites shows the file path, no files written
|
|
465
|
+
|
|
466
|
+
# 2. apply — scaffolds the file at .peaks/<sid>/rd/perf-baseline.md
|
|
467
|
+
peaks perf baseline --project <repo> --apply --reason "capturing baseline for Gate A4 diff"
|
|
468
|
+
# → ok: true, data.writtenFiles includes the path
|
|
469
|
+
|
|
470
|
+
# 3. fill in the file's Results table
|
|
471
|
+
# (lighthouse / k6 / autocannon / project-local bench — the
|
|
472
|
+
# CLI does not call any of these; that is the RD's job)
|
|
473
|
+
# open .peaks/<sid>/rd/perf-baseline.md and complete the
|
|
474
|
+
# "Path / route | Workload | Tool | Metric | Baseline | Threshold"
|
|
475
|
+
# table
|
|
476
|
+
|
|
477
|
+
# 4. hand off to QA. The QA stage reads the file's Results
|
|
478
|
+
# table as the input to Gate A4 — see peaks-qa SKILL.md
|
|
479
|
+
# Gate A4.
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
**Idempotency:** re-running `peaks perf baseline --apply` on a session where the file already exists is a no-op (the CLI does not overwrite hand-edited content). This is the normal RD retry pattern (re-measurement, threshold adjustment, etc.). If the RD really does want to overwrite, delete the file first and re-run.
|
|
483
|
+
|
|
484
|
+
**The role of the CLI vs. the actual measurement:** the CLI is the *scaffolding*. It writes the file, exposes the path, and keeps the file's structure stable so QA can rely on it. The CLI does NOT call lighthouse / k6 / autocannon — those are project-shape dependent and the right tool is a project-local concern, not a peaks-cli concern. The CLI is justified (4-grounds check): it gates the QA-side decision on a stable artefact, it requires --apply for a destructive write, and it is invokable from a hook on session init. It is *not* a machine-enforced gate that prose cannot enforce — the measurement is the RD's responsibility.
|
|
485
|
+
|
|
410
486
|
## Implementation completion gates
|
|
411
487
|
|
|
412
488
|
RD cannot mark a development slice complete until all of these are true. Each gate below maps to a hard verification gate in the Transition Verification Gates section — run the corresponding command, see the output.
|