peaks-cli 1.2.7 → 1.2.9

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 (54) hide show
  1. package/README.md +12 -0
  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/commands/project-commands.js +1 -1
  14. package/dist/src/cli/commands/scan-commands.js +22 -0
  15. package/dist/src/cli/program.js +4 -0
  16. package/dist/src/services/config/config-types.d.ts +20 -0
  17. package/dist/src/services/config/config-types.js +5 -1
  18. package/dist/src/services/memory/project-memory-service.d.ts +1 -1
  19. package/dist/src/services/memory/project-memory-service.js +52 -23
  20. package/dist/src/services/perf/perf-baseline-service.d.ts +70 -0
  21. package/dist/src/services/perf/perf-baseline-service.js +213 -0
  22. package/dist/src/services/progress/progress-service.d.ts +179 -0
  23. package/dist/src/services/progress/progress-service.js +276 -0
  24. package/dist/src/services/scan/libraries-service.d.ts +24 -0
  25. package/dist/src/services/scan/libraries-service.js +419 -0
  26. package/dist/src/services/scan/libraries-types.d.ts +59 -0
  27. package/dist/src/services/scan/libraries-types.js +9 -0
  28. package/dist/src/services/session/index.d.ts +1 -1
  29. package/dist/src/services/session/index.js +1 -1
  30. package/dist/src/services/session/session-manager.d.ts +53 -8
  31. package/dist/src/services/session/session-manager.js +150 -3
  32. package/dist/src/services/skills/skill-presence-service.d.ts +27 -1
  33. package/dist/src/services/skills/skill-presence-service.js +112 -9
  34. package/dist/src/services/skills/skill-runbook-service.js +34 -1
  35. package/dist/src/services/workflow/autonomous-resume-writer.js +7 -7
  36. package/dist/src/shared/change-id.d.ts +30 -0
  37. package/dist/src/shared/change-id.js +40 -6
  38. package/dist/src/shared/paths.d.ts +1 -1
  39. package/dist/src/shared/paths.js +2 -1
  40. package/dist/src/shared/version.d.ts +1 -1
  41. package/dist/src/shared/version.js +1 -1
  42. package/package.json +6 -2
  43. package/schemas/library-breaking-changes.data.json +141 -0
  44. package/schemas/library-breaking-changes.meta.json +6 -0
  45. package/schemas/library-breaking-changes.schema.json +50 -0
  46. package/skills/peaks-qa/SKILL.md +25 -0
  47. package/skills/peaks-rd/SKILL.md +221 -2
  48. package/skills/peaks-solo/SKILL.md +76 -316
  49. package/skills/peaks-solo/references/runbook.md +166 -0
  50. package/skills/peaks-solo/references/workflow-gates-and-types.md +177 -0
  51. package/skills/peaks-solo-resume/SKILL.md +81 -0
  52. package/skills/peaks-solo-status/SKILL.md +120 -0
  53. package/skills/peaks-solo-test/SKILL.md +84 -0
  54. package/skills/peaks-txt/SKILL.md +8 -5
@@ -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,3 +1,4 @@
1
+ import { dirname, join } from 'node:path';
1
2
  import { readText } from '../../shared/fs.js';
2
3
  import { loadSkillRegistry } from './skill-registry.js';
3
4
  const DESTRUCTIVE_APPLY_PATTERNS = [
@@ -13,6 +14,38 @@ function extractRunbookSection(body) {
13
14
  const match = /## Default runbook\n+([\s\S]*?)(?=\n## |$)/.exec(body);
14
15
  return match === null ? null : match[1];
15
16
  }
17
+ /**
18
+ * Load the runbook section, falling back to `references/runbook.md` if the
19
+ * SKILL.md only has a pointer section. This supports skills (notably
20
+ * `peaks-solo`) that extracted their 150-line bash runbook to a sibling
21
+ * reference to keep SKILL.md under the 800-line cap. The CLI
22
+ * `peaks skill runbook` command uses the same fallback so a human
23
+ * reviewer sees the full runbook regardless of where it lives.
24
+ *
25
+ * Strategy: prefer the LONGER of the two sections. A short pointer section
26
+ * in SKILL.md (~ 1-2 lines) is treated as a "this runbook is in the
27
+ * reference" marker; a long inline section (>= the reference length) is
28
+ * treated as the canonical runbook. This avoids the false positive where
29
+ * the pointer section's regex match returns a non-null but content-poor
30
+ * string.
31
+ */
32
+ async function loadRunbookSection(skillPath, body) {
33
+ const inline = extractRunbookSection(body);
34
+ const refPath = join(dirname(skillPath), 'references', 'runbook.md');
35
+ let refSection = null;
36
+ try {
37
+ const refBody = await readText(refPath);
38
+ refSection = extractRunbookSection(refBody);
39
+ }
40
+ catch {
41
+ // reference file does not exist or is not readable
42
+ }
43
+ if (inline === null)
44
+ return refSection;
45
+ if (refSection === null)
46
+ return inline;
47
+ return inline.length >= refSection.length ? inline : refSection;
48
+ }
16
49
  function findDestructiveApplyLines(section) {
17
50
  const lines = section.split(/\r?\n/);
18
51
  return lines.filter((line) => DESTRUCTIVE_APPLY_PATTERNS.some((pattern) => pattern.test(line)));
@@ -30,7 +63,7 @@ export async function inspectSkillRunbook(name, baseDir) {
30
63
  throw new Error(`Skill "${name}" not found under skills directory`);
31
64
  }
32
65
  const body = await readText(skill.skillPath);
33
- const section = extractRunbookSection(body);
66
+ const section = await loadRunbookSection(skill.skillPath, body);
34
67
  if (section === null) {
35
68
  return {
36
69
  name: skill.name,
@@ -1,6 +1,6 @@
1
1
  import { mkdir, writeFile } from 'node:fs/promises';
2
2
  import { dirname, join } from 'node:path';
3
- import { buildArtifactRelativePath, validateChangeIdOrThrow } from '../../shared/change-id.js';
3
+ import { buildArtifactRelativePathInRoot, validateChangeIdOrThrow } from '../../shared/change-id.js';
4
4
  import { pathExists } from '../../shared/fs.js';
5
5
  function defaultClock() {
6
6
  return new Date().toISOString();
@@ -109,27 +109,27 @@ Next actions:
109
109
  function buildFiles(changeId, goal, createdAt, artifactWorkspacePath) {
110
110
  return [
111
111
  {
112
- path: join(artifactWorkspacePath, buildArtifactRelativePath(changeId, 'prd', 'autonomous-goal-package.json')),
112
+ path: join(artifactWorkspacePath, buildArtifactRelativePathInRoot(artifactWorkspacePath, changeId, 'prd', 'autonomous-goal-package.json')),
113
113
  content: renderGoalPackage(changeId, goal)
114
114
  },
115
115
  {
116
- path: join(artifactWorkspacePath, buildArtifactRelativePath(changeId, 'rd', 'swarm', 'autonomous-rd-plan.json')),
116
+ path: join(artifactWorkspacePath, buildArtifactRelativePathInRoot(artifactWorkspacePath, changeId, 'rd', 'swarm', 'autonomous-rd-plan.json')),
117
117
  content: renderRdPlan(changeId)
118
118
  },
119
119
  {
120
- path: join(artifactWorkspacePath, buildArtifactRelativePath(changeId, 'rd', 'swarm', 'checkpoints', 'checkpoint-1.json')),
120
+ path: join(artifactWorkspacePath, buildArtifactRelativePathInRoot(artifactWorkspacePath, changeId, 'rd', 'swarm', 'checkpoints', 'checkpoint-1.json')),
121
121
  content: renderCheckpoint(changeId, createdAt)
122
122
  },
123
123
  {
124
- path: join(artifactWorkspacePath, buildArtifactRelativePath(changeId, 'rd', 'swarm', 'evidence', 'unit-tests.md')),
124
+ path: join(artifactWorkspacePath, buildArtifactRelativePathInRoot(artifactWorkspacePath, changeId, 'rd', 'swarm', 'evidence', 'unit-tests.md')),
125
125
  content: renderUnitTestsEvidence(changeId)
126
126
  },
127
127
  {
128
- path: join(artifactWorkspacePath, buildArtifactRelativePath(changeId, 'rd', 'swarm', 'evidence', 'validation-report.md')),
128
+ path: join(artifactWorkspacePath, buildArtifactRelativePathInRoot(artifactWorkspacePath, changeId, 'rd', 'swarm', 'evidence', 'validation-report.md')),
129
129
  content: renderValidationReport(changeId)
130
130
  },
131
131
  {
132
- path: join(artifactWorkspacePath, buildArtifactRelativePath(changeId, 'rd', 'swarm', 'resume-instructions.md')),
132
+ path: join(artifactWorkspacePath, buildArtifactRelativePathInRoot(artifactWorkspacePath, changeId, 'rd', 'swarm', 'resume-instructions.md')),
133
133
  content: renderResumeInstructions(changeId)
134
134
  }
135
135
  ];
@@ -11,6 +11,30 @@ export declare class ChangeIdValidationError extends Error {
11
11
  constructor(changeId: string);
12
12
  }
13
13
  export declare function isUnsafeArtifactPath(path: string): boolean;
14
+ /**
15
+ * Build an artifact-relative path using a caller-supplied project root, so
16
+ * the helper does not need to walk `process.cwd()` to find a session.
17
+ *
18
+ * If a session exists for `projectRoot`, files are stored in:
19
+ * .peaks/<sessionId>/<role>/<number>-<changeId>.md
20
+ *
21
+ * If no session exists, falls back to legacy behavior:
22
+ * .peaks/<changeId>/<segments>
23
+ *
24
+ * Use this from callers that have a workspace or `artifactWorkspacePath` in
25
+ * hand (e.g. CLI subcommands that received `--project`, or test fixtures
26
+ * that created a tmpdir workspace). Legacy callers without an explicit
27
+ * `projectRoot` should continue to use `buildArtifactRelativePath`.
28
+ *
29
+ * @param projectRoot - The project root to use for session lookup and dirPath
30
+ * computation. Must be an absolute path. Falls back to `process.cwd()` if
31
+ * the empty string is passed (defensive only; should not happen via the
32
+ * public API).
33
+ * @param changeId - Used as file description/slug (e.g., "auth-system", "add-user-auth")
34
+ * @param segments - Optional path segments (first segment is typically the role: 'prd', 'rd', 'qa', etc.)
35
+ * @returns Relative path to the artifact file
36
+ */
37
+ export declare function buildArtifactRelativePathInRoot(projectRoot: string, changeId: string, ...segments: string[]): string;
14
38
  /**
15
39
  * Build an artifact-relative path using session-based storage.
16
40
  *
@@ -20,6 +44,12 @@ export declare function isUnsafeArtifactPath(path: string): boolean;
20
44
  * If no session exists, falls back to legacy behavior:
21
45
  * .peaks/<changeId>/<segments>
22
46
  *
47
+ * This function walks `process.cwd()` to find the project root and reads
48
+ * `.peaks/.session.json` from it. Callers that already have an explicit
49
+ * `projectRoot` (workspace handle, test fixture, or CLI `--project` flag)
50
+ * should prefer `buildArtifactRelativePathInRoot(projectRoot, ...)` to
51
+ * avoid being polluted by the host environment's session binding.
52
+ *
23
53
  * @param changeId - Used as file description/slug (e.g., "auth-system", "add-user-auth")
24
54
  * @param segments - Optional path segments (first segment is typically the role: 'prd', 'rd', 'qa', etc.)
25
55
  * @returns Relative path to the artifact file
@@ -62,25 +62,37 @@ export function isUnsafeArtifactPath(path) {
62
62
  return isUnsafePathInput(path);
63
63
  }
64
64
  /**
65
- * Build an artifact-relative path using session-based storage.
65
+ * Build an artifact-relative path using a caller-supplied project root, so
66
+ * the helper does not need to walk `process.cwd()` to find a session.
66
67
  *
67
- * If a session exists, files are stored in:
68
+ * If a session exists for `projectRoot`, files are stored in:
68
69
  * .peaks/<sessionId>/<role>/<number>-<changeId>.md
69
70
  *
70
71
  * If no session exists, falls back to legacy behavior:
71
72
  * .peaks/<changeId>/<segments>
72
73
  *
74
+ * Use this from callers that have a workspace or `artifactWorkspacePath` in
75
+ * hand (e.g. CLI subcommands that received `--project`, or test fixtures
76
+ * that created a tmpdir workspace). Legacy callers without an explicit
77
+ * `projectRoot` should continue to use `buildArtifactRelativePath`.
78
+ *
79
+ * @param projectRoot - The project root to use for session lookup and dirPath
80
+ * computation. Must be an absolute path. Falls back to `process.cwd()` if
81
+ * the empty string is passed (defensive only; should not happen via the
82
+ * public API).
73
83
  * @param changeId - Used as file description/slug (e.g., "auth-system", "add-user-auth")
74
84
  * @param segments - Optional path segments (first segment is typically the role: 'prd', 'rd', 'qa', etc.)
75
85
  * @returns Relative path to the artifact file
76
86
  */
77
- export function buildArtifactRelativePath(changeId, ...segments) {
87
+ export function buildArtifactRelativePathInRoot(projectRoot, changeId, ...segments) {
78
88
  validateChangeIdOrThrow(changeId);
79
- const projectRoot = findProjectRoot(process.cwd()) ?? process.cwd();
80
- const sessionId = getSessionId(projectRoot);
89
+ const resolvedProjectRoot = projectRoot && projectRoot.length > 0
90
+ ? projectRoot
91
+ : (findProjectRoot(process.cwd()) ?? process.cwd());
92
+ const sessionId = getSessionId(resolvedProjectRoot);
81
93
  if (sessionId && segments.length > 0 && segments[0]) {
82
94
  const role = normalizeForwardSlashes(segments[0]);
83
- const dirPath = join(projectRoot, '.peaks', sessionId, role);
95
+ const dirPath = join(resolvedProjectRoot, '.peaks', sessionId, role);
84
96
  if (isUnsafeArtifactPath(role) || isUnsafeArtifactPath(sessionId)) {
85
97
  throw new ChangeIdValidationError(changeId);
86
98
  }
@@ -97,6 +109,28 @@ export function buildArtifactRelativePath(changeId, ...segments) {
97
109
  }
98
110
  return normalizeArtifactPath(candidatePath);
99
111
  }
112
+ /**
113
+ * Build an artifact-relative path using session-based storage.
114
+ *
115
+ * If a session exists, files are stored in:
116
+ * .peaks/<sessionId>/<role>/<number>-<changeId>.md
117
+ *
118
+ * If no session exists, falls back to legacy behavior:
119
+ * .peaks/<changeId>/<segments>
120
+ *
121
+ * This function walks `process.cwd()` to find the project root and reads
122
+ * `.peaks/.session.json` from it. Callers that already have an explicit
123
+ * `projectRoot` (workspace handle, test fixture, or CLI `--project` flag)
124
+ * should prefer `buildArtifactRelativePathInRoot(projectRoot, ...)` to
125
+ * avoid being polluted by the host environment's session binding.
126
+ *
127
+ * @param changeId - Used as file description/slug (e.g., "auth-system", "add-user-auth")
128
+ * @param segments - Optional path segments (first segment is typically the role: 'prd', 'rd', 'qa', etc.)
129
+ * @returns Relative path to the artifact file
130
+ */
131
+ export function buildArtifactRelativePath(changeId, ...segments) {
132
+ return buildArtifactRelativePathInRoot(findProjectRoot(process.cwd()) ?? process.cwd(), changeId, ...segments);
133
+ }
100
134
  export function isPathInsideArtifactRoot(path, artifactRoot) {
101
135
  if (!path || !artifactRoot)
102
136
  return false;
@@ -3,4 +3,4 @@ export declare const skillsDir: string;
3
3
  export declare const schemasDir: string;
4
4
  export declare const templatesDir: string;
5
5
  export declare const requiredSkillNames: readonly ["peaks-solo", "peaks-prd", "peaks-ui", "peaks-rd", "peaks-qa", "peaks-sc", "peaks-txt", "peaks-sop"];
6
- export declare const requiredSchemaFiles: readonly ["artifact-manifest.schema.json", "context-capsule.schema.json", "approval-record.schema.json", "change-impact.schema.json", "refactor-slice-spec.schema.json", "artifact-retention-report.schema.json", "capability-source.schema.json", "capability-item.schema.json", "capability-availability.schema.json", "recommendation-plan.schema.json", "artifact-workspace.schema.json", "mcp-server.schema.json", "mcp-install-spec.schema.json", "mcp-install-plan.schema.json", "mcp-apply-result.schema.json", "openspec-change-summary.schema.json", "openspec-render-request.schema.json", "openspec-validation-result.schema.json", "doctor-report.schema.json"];
6
+ export declare const requiredSchemaFiles: readonly ["artifact-manifest.schema.json", "context-capsule.schema.json", "approval-record.schema.json", "change-impact.schema.json", "refactor-slice-spec.schema.json", "artifact-retention-report.schema.json", "capability-source.schema.json", "capability-item.schema.json", "capability-availability.schema.json", "recommendation-plan.schema.json", "artifact-workspace.schema.json", "mcp-server.schema.json", "mcp-install-spec.schema.json", "mcp-install-plan.schema.json", "mcp-apply-result.schema.json", "openspec-change-summary.schema.json", "openspec-render-request.schema.json", "openspec-validation-result.schema.json", "doctor-report.schema.json", "library-breaking-changes.schema.json"];
@@ -45,5 +45,6 @@ export const requiredSchemaFiles = [
45
45
  'openspec-change-summary.schema.json',
46
46
  'openspec-render-request.schema.json',
47
47
  'openspec-validation-result.schema.json',
48
- 'doctor-report.schema.json'
48
+ 'doctor-report.schema.json',
49
+ 'library-breaking-changes.schema.json'
49
50
  ];
@@ -1 +1 @@
1
- export declare const CLI_VERSION = "1.2.7";
1
+ export declare const CLI_VERSION = "1.2.9";
@@ -1 +1 @@
1
- export const CLI_VERSION = "1.2.7";
1
+ export const CLI_VERSION = "1.2.9";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-cli",
3
- "version": "1.2.7",
3
+ "version": "1.2.9",
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",