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.
- package/README.md +12 -0
- 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/commands/project-commands.js +1 -1
- package/dist/src/cli/commands/scan-commands.js +22 -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/memory/project-memory-service.d.ts +1 -1
- package/dist/src/services/memory/project-memory-service.js +52 -23
- 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/scan/libraries-service.d.ts +24 -0
- package/dist/src/services/scan/libraries-service.js +419 -0
- package/dist/src/services/scan/libraries-types.d.ts +59 -0
- package/dist/src/services/scan/libraries-types.js +9 -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/services/skills/skill-runbook-service.js +34 -1
- package/dist/src/services/workflow/autonomous-resume-writer.js +7 -7
- package/dist/src/shared/change-id.d.ts +30 -0
- package/dist/src/shared/change-id.js +40 -6
- package/dist/src/shared/paths.d.ts +1 -1
- package/dist/src/shared/paths.js +2 -1
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +6 -2
- package/schemas/library-breaking-changes.data.json +141 -0
- package/schemas/library-breaking-changes.meta.json +6 -0
- package/schemas/library-breaking-changes.schema.json +50 -0
- package/skills/peaks-qa/SKILL.md +25 -0
- package/skills/peaks-rd/SKILL.md +221 -2
- package/skills/peaks-solo/SKILL.md +76 -316
- package/skills/peaks-solo/references/runbook.md +166 -0
- package/skills/peaks-solo/references/workflow-gates-and-types.md +177 -0
- package/skills/peaks-solo-resume/SKILL.md +81 -0
- package/skills/peaks-solo-status/SKILL.md +120 -0
- package/skills/peaks-solo-test/SKILL.md +84 -0
- 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
|
-
|
|
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,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 =
|
|
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 {
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
87
|
+
export function buildArtifactRelativePathInRoot(projectRoot, changeId, ...segments) {
|
|
78
88
|
validateChangeIdOrThrow(changeId);
|
|
79
|
-
const
|
|
80
|
-
|
|
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(
|
|
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"];
|
package/dist/src/shared/paths.js
CHANGED
|
@@ -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.
|
|
1
|
+
export declare const CLI_VERSION = "1.2.9";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "1.2.
|
|
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.
|
|
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
|
-
"
|
|
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",
|