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
@@ -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
- claudeSessionId?: string;
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 Claude Code session id, exposed to Bash tool calls via the
16
- * CLAUDE_CODE_SESSION_ID environment variable. Stamping it onto the presence
17
- * file lets the read-only status line tell whether the recorded skill belongs
18
- * to the live session (show it) or a previous one (render idle).
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 getCurrentClaudeSessionId() {
21
- const value = process.env.CLAUDE_CODE_SESSION_ID;
22
- return typeof value === 'string' && value.length > 0 ? value : undefined;
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 claudeSessionId = getCurrentClaudeSessionId();
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
- ...(claudeSessionId ? { claudeSessionId } : {}),
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.6";
1
+ export declare const CLI_VERSION = "1.2.8";
@@ -1 +1 @@
1
- export const CLI_VERSION = "1.2.6";
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.6",
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
- "shadcn": "4.7.0"
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",
@@ -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).
@@ -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.