peaks-cli 1.2.9 → 1.3.1
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 +62 -46
- package/bin/peaks.js +0 -0
- package/dist/src/cli/commands/hooks-commands.js +24 -9
- package/dist/src/cli/commands/progress-commands.js +26 -2
- package/dist/src/cli/commands/request-commands.js +5 -0
- package/dist/src/cli/commands/slice-commands.d.ts +3 -0
- package/dist/src/cli/commands/slice-commands.js +42 -0
- package/dist/src/cli/commands/workflow-commands.js +3 -3
- package/dist/src/cli/commands/workspace-commands.d.ts +63 -0
- package/dist/src/cli/commands/workspace-commands.js +347 -5
- package/dist/src/cli/program.js +4 -0
- package/dist/src/services/artifacts/artifact-prerequisites.d.ts +17 -1
- package/dist/src/services/artifacts/artifact-prerequisites.js +38 -5
- package/dist/src/services/artifacts/request-artifact-service.d.ts +22 -0
- package/dist/src/services/artifacts/request-artifact-service.js +172 -54
- package/dist/src/services/doctor/doctor-service.d.ts +7 -0
- package/dist/src/services/doctor/doctor-service.js +20 -2
- package/dist/src/services/progress/progress-service.d.ts +26 -0
- package/dist/src/services/progress/progress-service.js +25 -0
- package/dist/src/services/sc/sc-service.d.ts +52 -1
- package/dist/src/services/sc/sc-service.js +324 -17
- package/dist/src/services/scan/acceptance-coverage-service.js +6 -2
- package/dist/src/services/session/session-manager.d.ts +7 -5
- package/dist/src/services/session/session-manager.js +60 -16
- package/dist/src/services/skills/hooks-settings-service.d.ts +25 -3
- package/dist/src/services/skills/hooks-settings-service.js +57 -13
- package/dist/src/services/skills/skill-presence-service.js +102 -68
- package/dist/src/services/skills/skill-runbook-service.js +2 -1
- package/dist/src/services/skills/skill-statusline-service.js +13 -7
- package/dist/src/services/slice/slice-check-service.d.ts +2 -0
- package/dist/src/services/slice/slice-check-service.js +248 -0
- package/dist/src/services/slice/slice-check-types.d.ts +61 -0
- package/dist/src/services/slice/slice-check-types.js +18 -0
- package/dist/src/services/workflow/pipeline-verify-service.d.ts +5 -2
- package/dist/src/services/workflow/pipeline-verify-service.js +35 -35
- package/dist/src/services/workspace/migrate-service.d.ts +2 -0
- package/dist/src/services/workspace/migrate-service.js +484 -0
- package/dist/src/services/workspace/migrate-types.d.ts +84 -0
- package/dist/src/services/workspace/migrate-types.js +21 -0
- package/dist/src/services/workspace/reconcile-service.d.ts +119 -0
- package/dist/src/services/workspace/reconcile-service.js +464 -0
- package/dist/src/services/workspace/reconcile-types.d.ts +93 -0
- package/dist/src/services/workspace/reconcile-types.js +13 -0
- package/dist/src/services/workspace/workspace-service.d.ts +11 -0
- package/dist/src/services/workspace/workspace-service.js +87 -7
- package/dist/src/shared/change-id.d.ts +59 -0
- package/dist/src/shared/change-id.js +194 -16
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +13 -2
- package/skills/peaks-solo/SKILL.md +28 -4
- package/skills/peaks-solo/references/micro-cycle.md +155 -0
- package/skills/peaks-solo/references/runbook.md +2 -0
|
@@ -2,11 +2,33 @@ import { closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readF
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
|
-
/** The hook command written into settings. `${CLAUDE_PROJECT_DIR}` is injected by Claude Code. */
|
|
5
|
+
/** The hook command written into settings for the gate-enforce PreToolUse hook. `${CLAUDE_PROJECT_DIR}` is injected by Claude Code. */
|
|
6
6
|
export const HOOK_ENFORCE_COMMAND = 'peaks gate enforce --project "${CLAUDE_PROJECT_DIR}"';
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Hook command for the sub-agent progress auto-spawn. Fires on every Task
|
|
9
|
+
* tool call (the harness-enforced mechanism for "sub-agent dispatch"). The
|
|
10
|
+
* command itself is non-blocking: `peaks progress start` is idempotent
|
|
11
|
+
* (5-minute TTL on the spawn record) so the LLM does not see a fresh
|
|
12
|
+
* terminal per Task. The `--quiet` flag keeps the LLM context clean — the
|
|
13
|
+
* hook output otherwise adds ~500 tokens per Task call.
|
|
14
|
+
*/
|
|
15
|
+
export const HOOK_PROGRESS_COMMAND = 'peaks progress start --project "${CLAUDE_PROJECT_DIR}" --reason "auto-spawn for sub-agent Task" --quiet';
|
|
16
|
+
/** Substring that identifies a Peaks-managed PreToolUse gate-enforce hook entry. */
|
|
17
|
+
export const HOOK_ENFORCE_SENTINEL = 'peaks gate enforce';
|
|
18
|
+
/** Substring that identifies a Peaks-managed PreToolUse sub-agent-progress hook entry. */
|
|
19
|
+
export const HOOK_PROGRESS_SENTINEL = 'peaks progress start';
|
|
20
|
+
const HOOK_GATE_MATCHER = 'Bash';
|
|
21
|
+
const HOOK_PROGRESS_MATCHER = 'Task';
|
|
22
|
+
/**
|
|
23
|
+
* Substring sentinels that identify a Peaks-managed PreToolUse hook entry.
|
|
24
|
+
* Used to keep `uninstall` and `isInstalled` checks tight: we only touch
|
|
25
|
+
* entries we wrote, never third-party hooks.
|
|
26
|
+
*/
|
|
27
|
+
const PEAKS_HOOK_SENTINELS = [HOOK_ENFORCE_SENTINEL, HOOK_PROGRESS_SENTINEL];
|
|
28
|
+
export const PEAKS_HOOK_ENTRIES = [
|
|
29
|
+
{ sentinel: HOOK_ENFORCE_SENTINEL, matcher: HOOK_GATE_MATCHER, command: HOOK_ENFORCE_COMMAND },
|
|
30
|
+
{ sentinel: HOOK_PROGRESS_SENTINEL, matcher: HOOK_PROGRESS_MATCHER, command: HOOK_PROGRESS_COMMAND }
|
|
31
|
+
];
|
|
10
32
|
function isInsidePath(childPath, parentPath) {
|
|
11
33
|
const rel = relative(parentPath, childPath);
|
|
12
34
|
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
@@ -87,9 +109,17 @@ function readPreToolUse(settings) {
|
|
|
87
109
|
const pre = hooks.PreToolUse;
|
|
88
110
|
return Array.isArray(pre) ? pre : [];
|
|
89
111
|
}
|
|
112
|
+
/** True when every command handler in the entry matches a known peaks sentinel. */
|
|
90
113
|
function entryIsPeaksManaged(entry) {
|
|
91
114
|
const handlers = Array.isArray(entry?.hooks) ? entry.hooks : [];
|
|
92
|
-
|
|
115
|
+
if (handlers.length === 0)
|
|
116
|
+
return false;
|
|
117
|
+
return handlers.every((h) => {
|
|
118
|
+
if (typeof h?.command !== 'string')
|
|
119
|
+
return false;
|
|
120
|
+
const cmd = h.command;
|
|
121
|
+
return PEAKS_HOOK_SENTINELS.some((sentinel) => cmd.includes(sentinel));
|
|
122
|
+
});
|
|
93
123
|
}
|
|
94
124
|
function isInstalled(settings) {
|
|
95
125
|
return readPreToolUse(settings).some(entryIsPeaksManaged);
|
|
@@ -100,18 +130,32 @@ export function planHookInstall(scope, projectRoot) {
|
|
|
100
130
|
assertSafeSettingsPath(scope, root, settingsPath);
|
|
101
131
|
const exists = existsSync(settingsPath);
|
|
102
132
|
const settings = readSettings(settingsPath);
|
|
103
|
-
return {
|
|
133
|
+
return {
|
|
134
|
+
scope,
|
|
135
|
+
settingsPath,
|
|
136
|
+
exists,
|
|
137
|
+
alreadyInstalled: isInstalled(settings),
|
|
138
|
+
desiredCommand: HOOK_ENFORCE_COMMAND,
|
|
139
|
+
sentinel: HOOK_ENFORCE_SENTINEL,
|
|
140
|
+
matcher: HOOK_GATE_MATCHER
|
|
141
|
+
};
|
|
104
142
|
}
|
|
105
|
-
/** Merge
|
|
106
|
-
function
|
|
143
|
+
/** Merge all peaks-managed PreToolUse entries into settings, preserving all other keys and hooks. */
|
|
144
|
+
function withHooksInstalled(settings) {
|
|
107
145
|
const existingHooks = (settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks))
|
|
108
146
|
? settings.hooks
|
|
109
147
|
: {};
|
|
110
148
|
const preToolUse = readPreToolUse(settings);
|
|
111
|
-
|
|
149
|
+
// Drop any existing peaks-managed entries first so re-runs are idempotent
|
|
150
|
+
// even if the command string changed (e.g. a bug fix in the command).
|
|
151
|
+
const nonPeaks = preToolUse.filter((entry) => !entryIsPeaksManaged(entry));
|
|
152
|
+
const ourEntries = PEAKS_HOOK_ENTRIES.map((spec) => ({
|
|
153
|
+
matcher: spec.matcher,
|
|
154
|
+
hooks: [{ type: 'command', command: spec.command }]
|
|
155
|
+
}));
|
|
112
156
|
return {
|
|
113
157
|
...settings,
|
|
114
|
-
hooks: { ...existingHooks, PreToolUse: [...
|
|
158
|
+
hooks: { ...existingHooks, PreToolUse: [...nonPeaks, ...ourEntries] }
|
|
115
159
|
};
|
|
116
160
|
}
|
|
117
161
|
export function applyHookInstall(scope, projectRoot) {
|
|
@@ -121,10 +165,10 @@ export function applyHookInstall(scope, projectRoot) {
|
|
|
121
165
|
const exists = existsSync(settingsPath);
|
|
122
166
|
const settings = readSettings(settingsPath);
|
|
123
167
|
if (isInstalled(settings)) {
|
|
124
|
-
return { scope, settingsPath, exists, alreadyInstalled: true, desiredCommand: HOOK_ENFORCE_COMMAND, applied: false };
|
|
168
|
+
return { scope, settingsPath, exists, alreadyInstalled: true, desiredCommand: HOOK_ENFORCE_COMMAND, applied: false, sentinel: HOOK_ENFORCE_SENTINEL, matcher: HOOK_GATE_MATCHER };
|
|
125
169
|
}
|
|
126
|
-
atomicWriteJson(settingsPath,
|
|
127
|
-
return { scope, settingsPath, exists, alreadyInstalled: false, desiredCommand: HOOK_ENFORCE_COMMAND, applied: true };
|
|
170
|
+
atomicWriteJson(settingsPath, withHooksInstalled(settings));
|
|
171
|
+
return { scope, settingsPath, exists, alreadyInstalled: false, desiredCommand: HOOK_ENFORCE_COMMAND, applied: true, sentinel: HOOK_ENFORCE_SENTINEL, matcher: HOOK_GATE_MATCHER };
|
|
128
172
|
}
|
|
129
173
|
export function removeHookInstall(scope, projectRoot) {
|
|
130
174
|
const root = resolveSettingsRoot(scope, projectRoot);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { dirname, resolve } from 'node:path';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
3
|
import { findProjectRoot } from '../config/config-safety.js';
|
|
4
4
|
import { ensureMemoryBootstrap } from '../memory/project-memory-service.js';
|
|
5
5
|
import { getSessionMeta } from '../session/session-manager.js';
|
|
@@ -31,8 +31,16 @@ function getCurrentOuterSessionId() {
|
|
|
31
31
|
return claude;
|
|
32
32
|
return undefined;
|
|
33
33
|
}
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
// As of slice 2026-06-05-peaks-runtime-layer the orchestrator's
|
|
35
|
+
// active-skill marker lives under `.peaks/_runtime/active-skill.json`.
|
|
36
|
+
// The legacy `.peaks/.active-skill.json` path is preserved as a
|
|
37
|
+
// read-only fallback for one minor release so older CLI versions (or
|
|
38
|
+
// trees that have not been migrated by `peaks workspace reconcile`)
|
|
39
|
+
// keep working without a forced re-init.
|
|
40
|
+
const PRESENCE_FILE = join('.peaks', '_runtime', 'active-skill.json');
|
|
41
|
+
const PRESENCE_FILE_LEGACY = '.peaks/.active-skill.json';
|
|
42
|
+
const SESSION_FILE = join('.peaks', '_runtime', 'session.json');
|
|
43
|
+
const SESSION_FILE_LEGACY = '.peaks/.session.json';
|
|
36
44
|
function resolveProjectRoot(override) {
|
|
37
45
|
if (override)
|
|
38
46
|
return resolve(override);
|
|
@@ -41,12 +49,44 @@ function resolveProjectRoot(override) {
|
|
|
41
49
|
function resolvePresencePath(projectRootOverride) {
|
|
42
50
|
return resolve(resolveProjectRoot(projectRootOverride), PRESENCE_FILE);
|
|
43
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Back-compat read for the active-skill marker. Prefers the new
|
|
54
|
+
* canonical `.peaks/_runtime/active-skill.json`; falls back to the
|
|
55
|
+
* legacy `.peaks/.active-skill.json` for one minor release.
|
|
56
|
+
*
|
|
57
|
+
* Returns the parsed SkillPresence object, or null when neither
|
|
58
|
+
* file is present / valid. The legacy file is never written by
|
|
59
|
+
* current code — only the new path receives writes.
|
|
60
|
+
*/
|
|
61
|
+
function readSkillPresenceBackCompat(projectRootOverride) {
|
|
62
|
+
const presencePath = resolvePresencePath(projectRootOverride);
|
|
63
|
+
const legacyPath = resolve(resolveProjectRoot(projectRootOverride), PRESENCE_FILE_LEGACY);
|
|
64
|
+
const pathToRead = existsSync(presencePath) ? presencePath : legacyPath;
|
|
65
|
+
if (!existsSync(pathToRead))
|
|
66
|
+
return null;
|
|
67
|
+
try {
|
|
68
|
+
const raw = readFileSync(pathToRead, 'utf8');
|
|
69
|
+
const parsed = JSON.parse(raw);
|
|
70
|
+
if (typeof parsed?.skill !== 'string' || parsed.skill.length === 0) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
return { presence: parsed, path: pathToRead };
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
44
79
|
function getCurrentSessionId(projectRootOverride) {
|
|
45
|
-
const
|
|
46
|
-
|
|
80
|
+
const projectRoot = resolveProjectRoot(projectRootOverride);
|
|
81
|
+
const sessionPath = resolve(projectRoot, SESSION_FILE);
|
|
82
|
+
const legacyPath = resolve(projectRoot, SESSION_FILE_LEGACY);
|
|
83
|
+
// Back-compat window: prefer the new canonical path; fall back to the
|
|
84
|
+
// legacy `.peaks/.session.json` for one minor release.
|
|
85
|
+
const pathToRead = existsSync(sessionPath) ? sessionPath : legacyPath;
|
|
86
|
+
if (!existsSync(pathToRead))
|
|
47
87
|
return null;
|
|
48
88
|
try {
|
|
49
|
-
const data = JSON.parse(readFileSync(
|
|
89
|
+
const data = JSON.parse(readFileSync(pathToRead, 'utf8'));
|
|
50
90
|
return typeof data.sessionId === 'string' && data.sessionId.length > 0
|
|
51
91
|
? data.sessionId
|
|
52
92
|
: null;
|
|
@@ -80,7 +120,7 @@ function getBoundOuterSessionId(projectRootOverride) {
|
|
|
80
120
|
}
|
|
81
121
|
/**
|
|
82
122
|
* Snapshot of the previous peaks session's outer session id, read
|
|
83
|
-
* straight off
|
|
123
|
+
* straight off the active-skill marker *before* we overwrite it.
|
|
84
124
|
* Used to detect "the LLM just opened a fresh outer session" — if
|
|
85
125
|
* the previously-recorded outer session id differs from the one we
|
|
86
126
|
* are about to stamp, the user probably closed the previous outer
|
|
@@ -93,27 +133,24 @@ function getBoundOuterSessionId(projectRootOverride) {
|
|
|
93
133
|
* (most common when the swap is a no-op reconnect) or to roll a
|
|
94
134
|
* fresh session (when the new outer session is genuinely a new
|
|
95
135
|
* task).
|
|
136
|
+
*
|
|
137
|
+
* Reads from `.peaks/_runtime/active-skill.json` first; falls back to
|
|
138
|
+
* the legacy `.peaks/.active-skill.json` for one minor release.
|
|
96
139
|
*/
|
|
97
140
|
function getPreviousOuterSessionId(projectRootOverride) {
|
|
98
|
-
const
|
|
99
|
-
if (
|
|
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
|
-
}
|
|
141
|
+
const result = readSkillPresenceBackCompat(projectRootOverride);
|
|
142
|
+
if (result === null)
|
|
112
143
|
return undefined;
|
|
144
|
+
const parsed = result.presence;
|
|
145
|
+
if (typeof parsed.outerSessionId === 'string' && parsed.outerSessionId.length > 0) {
|
|
146
|
+
return parsed.outerSessionId;
|
|
113
147
|
}
|
|
114
|
-
|
|
115
|
-
|
|
148
|
+
// Legacy field name. Honour it on the read side so v1.2.x
|
|
149
|
+
// presence files do not show as a false mismatch.
|
|
150
|
+
if (typeof parsed.claudeSessionId === 'string' && parsed.claudeSessionId.length > 0) {
|
|
151
|
+
return parsed.claudeSessionId;
|
|
116
152
|
}
|
|
153
|
+
return undefined;
|
|
117
154
|
}
|
|
118
155
|
export function exportSkillPresence(projectRootOverride) {
|
|
119
156
|
return resolvePresencePath(projectRootOverride);
|
|
@@ -184,65 +221,62 @@ export function setSkillPresence(skill, mode, gate, projectRootOverride) {
|
|
|
184
221
|
return presence;
|
|
185
222
|
}
|
|
186
223
|
export function getSkillPresence(projectRootOverride) {
|
|
187
|
-
const
|
|
188
|
-
if (
|
|
224
|
+
const result = readSkillPresenceBackCompat(projectRootOverride);
|
|
225
|
+
if (result === null)
|
|
189
226
|
return null;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
return null;
|
|
196
|
-
}
|
|
197
|
-
if (typeof parsed.sessionId === 'string' && parsed.sessionId.length > 0) {
|
|
198
|
-
const currentSessionId = getCurrentSessionId(projectRootOverride);
|
|
199
|
-
if (currentSessionId && parsed.sessionId !== currentSessionId) {
|
|
227
|
+
const { presence, path: presencePath } = result;
|
|
228
|
+
if (typeof presence.sessionId === 'string' && presence.sessionId.length > 0) {
|
|
229
|
+
const currentSessionId = getCurrentSessionId(projectRootOverride);
|
|
230
|
+
if (currentSessionId && presence.sessionId !== currentSessionId) {
|
|
231
|
+
try {
|
|
200
232
|
unlinkSync(presencePath);
|
|
201
|
-
return null;
|
|
202
233
|
}
|
|
234
|
+
catch {
|
|
235
|
+
// best effort
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
203
238
|
}
|
|
204
|
-
return parsed;
|
|
205
|
-
}
|
|
206
|
-
catch {
|
|
207
|
-
return null;
|
|
208
239
|
}
|
|
240
|
+
return presence;
|
|
209
241
|
}
|
|
210
242
|
export function touchSkillHeartbeat(projectRootOverride) {
|
|
211
|
-
const
|
|
212
|
-
if (
|
|
243
|
+
const result = readSkillPresenceBackCompat(projectRootOverride);
|
|
244
|
+
if (result === null)
|
|
213
245
|
return null;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
return null;
|
|
220
|
-
}
|
|
221
|
-
if (typeof parsed.sessionId === 'string' && parsed.sessionId.length > 0) {
|
|
222
|
-
const currentSessionId = getCurrentSessionId(projectRootOverride);
|
|
223
|
-
if (currentSessionId && parsed.sessionId !== currentSessionId) {
|
|
246
|
+
const { presence, path: presencePath } = result;
|
|
247
|
+
if (typeof presence.sessionId === 'string' && presence.sessionId.length > 0) {
|
|
248
|
+
const currentSessionId = getCurrentSessionId(projectRootOverride);
|
|
249
|
+
if (currentSessionId && presence.sessionId !== currentSessionId) {
|
|
250
|
+
try {
|
|
224
251
|
unlinkSync(presencePath);
|
|
225
|
-
return null;
|
|
226
252
|
}
|
|
253
|
+
catch {
|
|
254
|
+
// best effort
|
|
255
|
+
}
|
|
256
|
+
return null;
|
|
227
257
|
}
|
|
228
|
-
parsed.lastHeartbeat = new Date().toISOString();
|
|
229
|
-
writeFileSync(presencePath, JSON.stringify(parsed, null, 2), 'utf8');
|
|
230
|
-
return parsed;
|
|
231
|
-
}
|
|
232
|
-
catch {
|
|
233
|
-
return null;
|
|
234
258
|
}
|
|
259
|
+
presence.lastHeartbeat = new Date().toISOString();
|
|
260
|
+
writeFileSync(presencePath, JSON.stringify(presence, null, 2), 'utf8');
|
|
261
|
+
return presence;
|
|
235
262
|
}
|
|
236
263
|
export function clearSkillPresence(projectRootOverride) {
|
|
264
|
+
// Clear both the new canonical path and the legacy path, so a stale
|
|
265
|
+
// presence marker from a prior CLI version cannot resurrect after
|
|
266
|
+
// a fresh `clear`.
|
|
237
267
|
const presencePath = resolvePresencePath(projectRootOverride);
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
268
|
+
const legacyPath = resolve(resolveProjectRoot(projectRootOverride), PRESENCE_FILE_LEGACY);
|
|
269
|
+
let cleared = false;
|
|
270
|
+
for (const p of [presencePath, legacyPath]) {
|
|
271
|
+
if (!existsSync(p))
|
|
272
|
+
continue;
|
|
273
|
+
try {
|
|
274
|
+
unlinkSync(p);
|
|
275
|
+
cleared = true;
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
// best effort
|
|
279
|
+
}
|
|
247
280
|
}
|
|
281
|
+
return cleared;
|
|
248
282
|
}
|
|
@@ -6,7 +6,8 @@ const DESTRUCTIVE_APPLY_PATTERNS = [
|
|
|
6
6
|
/peaks\s+memory\s+extract[^\n]*--apply/,
|
|
7
7
|
/peaks\s+artifacts\s+sync[^\n]*--apply/,
|
|
8
8
|
/peaks\s+openspec\s+archive[^\n]*--apply/,
|
|
9
|
-
/peaks\s+standards\s+(?:init|update)[^\n]*--apply
|
|
9
|
+
/peaks\s+standards\s+(?:init|update)[^\n]*--apply/,
|
|
10
|
+
/peaks\s+workspace\s+reconcile[^\n]*--apply/
|
|
10
11
|
];
|
|
11
12
|
const AUTHORIZATION_KEYWORDS_PATTERN = /authoriz|explicit|--dry-run|approv|only after|only when/i;
|
|
12
13
|
const PEAKS_COMMAND_LINE_PATTERN = /^\s*peaks\s+\w/;
|
|
@@ -6,16 +6,19 @@ import { findProjectRoot } from '../config/config-safety.js';
|
|
|
6
6
|
*
|
|
7
7
|
* Claude Code invokes the configured statusLine command on every turn and pipes
|
|
8
8
|
* a JSON session payload on stdin. This renderer reads the durable presence file
|
|
9
|
-
* (
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* (`.peaks/_runtime/active-skill.json`, with a one-minor-release back-compat
|
|
10
|
+
* fallback to `.peaks/.active-skill.json`) and prints a single line that
|
|
11
|
+
* Claude Code paints at the bottom of the terminal. Because it is rendered
|
|
12
|
+
* by the harness — not emitted as LLM tokens — the signal cannot be forgotten
|
|
13
|
+
* by the model, cannot be confused with normal output, and survives context
|
|
14
|
+
* compaction.
|
|
13
15
|
*
|
|
14
16
|
* This module is intentionally READ-ONLY. Unlike getSkillPresence in
|
|
15
17
|
* skill-presence-service.ts, it never deletes or rewrites the presence file:
|
|
16
18
|
* the statusLine runs on every turn and must have zero side effects.
|
|
17
19
|
*/
|
|
18
|
-
const PRESENCE_FILE = '.peaks
|
|
20
|
+
const PRESENCE_FILE = '.peaks/_runtime/active-skill.json';
|
|
21
|
+
const PRESENCE_FILE_LEGACY = '.peaks/.active-skill.json';
|
|
19
22
|
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
|
|
20
23
|
function resolveCwdFromStdin(stdin) {
|
|
21
24
|
const fromWorkspace = stdin?.workspace?.current_dir ?? stdin?.workspace?.project_dir;
|
|
@@ -48,11 +51,14 @@ export function parseStatusLineStdin(raw) {
|
|
|
48
51
|
*/
|
|
49
52
|
function readPresenceReadOnly(projectRoot) {
|
|
50
53
|
const presencePath = resolve(projectRoot, PRESENCE_FILE);
|
|
51
|
-
|
|
54
|
+
// Back-compat: prefer the new canonical path; fall back to the legacy
|
|
55
|
+
// `.peaks/.active-skill.json` for one minor release.
|
|
56
|
+
const pathToRead = existsSync(presencePath) ? presencePath : resolve(projectRoot, PRESENCE_FILE_LEGACY);
|
|
57
|
+
if (!existsSync(pathToRead)) {
|
|
52
58
|
return { presence: null, invalid: false };
|
|
53
59
|
}
|
|
54
60
|
try {
|
|
55
|
-
const parsed = JSON.parse(readFileSync(
|
|
61
|
+
const parsed = JSON.parse(readFileSync(pathToRead, 'utf8'));
|
|
56
62
|
if (!parsed || typeof parsed !== 'object') {
|
|
57
63
|
return { presence: null, invalid: true };
|
|
58
64
|
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, statSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { isDirectory } from '../../shared/fs.js';
|
|
5
|
+
import { getCurrentChangeId } from '../../shared/change-id.js';
|
|
6
|
+
import { verifyPipeline } from '../workflow/pipeline-verify-service.js';
|
|
7
|
+
function runCommand(command, args, cwd, timeoutMs) {
|
|
8
|
+
const start = Date.now();
|
|
9
|
+
try {
|
|
10
|
+
const stdout = execFileSync(command, args, {
|
|
11
|
+
cwd,
|
|
12
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
13
|
+
timeout: timeoutMs,
|
|
14
|
+
maxBuffer: 32 * 1024 * 1024
|
|
15
|
+
}).toString('utf8');
|
|
16
|
+
return {
|
|
17
|
+
status: 'pass',
|
|
18
|
+
stdout,
|
|
19
|
+
stderr: '',
|
|
20
|
+
exitCode: 0,
|
|
21
|
+
durationMs: Date.now() - start
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
const stdout = (error?.stdout ?? '').toString('utf8');
|
|
26
|
+
const stderr = (error?.stderr ?? '').toString('utf8');
|
|
27
|
+
return {
|
|
28
|
+
status: 'fail',
|
|
29
|
+
stdout,
|
|
30
|
+
stderr,
|
|
31
|
+
exitCode: typeof error?.status === 'number' ? error.status : 1,
|
|
32
|
+
durationMs: Date.now() - start
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function tailLines(text, max) {
|
|
37
|
+
const lines = text.split('\n').filter((l) => l.trim().length > 0);
|
|
38
|
+
if (lines.length <= max)
|
|
39
|
+
return lines.join('\n');
|
|
40
|
+
return [...lines.slice(0, 3), `... (${lines.length - max} more lines) ...`, ...lines.slice(-max + 3)].join('\n');
|
|
41
|
+
}
|
|
42
|
+
async function runTypecheck(projectRoot) {
|
|
43
|
+
const start = Date.now();
|
|
44
|
+
const result = runCommand('npx', ['tsc', '--noEmit'], projectRoot, 180_000);
|
|
45
|
+
const testFiles = result.stdout.match(/(tests?\/.*\.test\.ts)/g) ?? [];
|
|
46
|
+
return {
|
|
47
|
+
name: 'typecheck',
|
|
48
|
+
description: 'npx tsc --noEmit (no JS emit, type-only check)',
|
|
49
|
+
status: result.status,
|
|
50
|
+
durationMs: result.durationMs,
|
|
51
|
+
detail: result.status === 'pass'
|
|
52
|
+
? `Typecheck passed in ${result.durationMs}ms.`
|
|
53
|
+
: tailLines(result.stdout + result.stderr, 10) || `tsc exited with code ${result.exitCode}.`,
|
|
54
|
+
data: { exitCode: result.exitCode }
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function parseVitestSummary(stdout, fallbackDuration) {
|
|
58
|
+
// Vitest 2.x prints e.g. "Test Files 1 passed (1)" and "Tests 1 passed (1)"
|
|
59
|
+
// and "Duration 0.50s" or "Duration 1.23s". Be lenient with regex.
|
|
60
|
+
const testsMatch = /Tests?\s+(\d+)\s+(?:passed|run)/.exec(stdout);
|
|
61
|
+
const failedMatch = /Tests?\s+(\d+)\s+failed/.exec(stdout);
|
|
62
|
+
const skippedMatch = /Tests?\s+(\d+)\s+skipped/.exec(stdout);
|
|
63
|
+
const durationMatch = /Duration[^\d]*(\d+(?:\.\d+)?)\s*s/.exec(stdout);
|
|
64
|
+
return {
|
|
65
|
+
tests: testsMatch ? parseInt(testsMatch[1], 10) : 0,
|
|
66
|
+
passed: 0,
|
|
67
|
+
failed: failedMatch ? parseInt(failedMatch[1], 10) : 0,
|
|
68
|
+
skipped: skippedMatch ? parseInt(skippedMatch[1], 10) : 0,
|
|
69
|
+
durationMs: durationMatch ? Math.round(parseFloat(durationMatch[1]) * 1000) : fallbackDuration
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
async function runUnitTests(projectRoot) {
|
|
73
|
+
const start = Date.now();
|
|
74
|
+
const result = runCommand('npx', ['vitest', 'run', '--reporter=default', '--coverage=false'], projectRoot, 600_000);
|
|
75
|
+
const summary = parseVitestSummary(result.stdout, result.durationMs);
|
|
76
|
+
// Vitest doesn't always print the per-bucket counts cleanly; infer "passed"
|
|
77
|
+
// as total - failed - skipped when failed/skipped buckets are present.
|
|
78
|
+
const passed = Math.max(summary.tests - summary.failed - summary.skipped, 0);
|
|
79
|
+
return {
|
|
80
|
+
name: 'unit-tests',
|
|
81
|
+
description: 'npx vitest run (full test suite, coverage off)',
|
|
82
|
+
status: result.status,
|
|
83
|
+
durationMs: result.durationMs,
|
|
84
|
+
detail: result.status === 'pass'
|
|
85
|
+
? `All tests passed in ${result.durationMs}ms.`
|
|
86
|
+
: tailLines(result.stdout + result.stderr, 12) || `vitest exited with code ${result.exitCode}.`,
|
|
87
|
+
data: {
|
|
88
|
+
tests: summary.tests,
|
|
89
|
+
passed,
|
|
90
|
+
failed: summary.failed,
|
|
91
|
+
skipped: summary.skipped,
|
|
92
|
+
exitCode: result.exitCode
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const REVIEW_FILES = [
|
|
97
|
+
{ name: 'code-review', path: 'rd/code-review.md', label: 'code-review' },
|
|
98
|
+
{ name: 'security-review', path: 'rd/security-review.md', label: 'security-review' },
|
|
99
|
+
{ name: 'perf-baseline', path: 'rd/perf-baseline.md', label: 'perf-baseline' }
|
|
100
|
+
];
|
|
101
|
+
async function runReviewFanout(projectRoot, rid, refresh) {
|
|
102
|
+
const start = Date.now();
|
|
103
|
+
if (refresh) {
|
|
104
|
+
// `peaks-rd` does the 3-way fan-out when the slice is in `spec-locked` or
|
|
105
|
+
// `implemented` state. The actual fan-out is invoked via the `peaks-rd`
|
|
106
|
+
// skill body, not via a CLI subcommand (each sub-agent is invoked with
|
|
107
|
+
// its own prompt). When `--refresh-fanout` is set, we emit a
|
|
108
|
+
// nextAction that tells the caller to invoke `Skill(skill="peaks-rd")`
|
|
109
|
+
// (the role skill owns the 3 review artifact writes).
|
|
110
|
+
return {
|
|
111
|
+
name: 'review-fanout',
|
|
112
|
+
description: '3-way review fan-out (code-review + security-review + perf baseline)',
|
|
113
|
+
status: 'skipped',
|
|
114
|
+
durationMs: Date.now() - start,
|
|
115
|
+
detail: '3-way fan-out is dispatched via Skill(skill="peaks-rd"); invoke it to regenerate the review artifacts.',
|
|
116
|
+
data: { refresh: true, rid }
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
// Default: verify all 3 review files exist with non-empty content. The
|
|
120
|
+
// files can live under EITHER `.peaks/<rid>/rd/` (active change-id) or
|
|
121
|
+
// `.peaks/retrospective/<rid>/rd/` (shipped). The boundary check
|
|
122
|
+
// accepts either — the LLM may be at a slice that's still active
|
|
123
|
+
// (not yet archived) or one that just shipped.
|
|
124
|
+
const scopes = [rid, `retrospective/${rid}`];
|
|
125
|
+
const missing = [];
|
|
126
|
+
const found = [];
|
|
127
|
+
for (const review of REVIEW_FILES) {
|
|
128
|
+
let hit = null;
|
|
129
|
+
for (const scope of scopes) {
|
|
130
|
+
const abs = join(projectRoot, '.peaks', scope, review.path);
|
|
131
|
+
if (existsSync(abs)) {
|
|
132
|
+
const bytes = statSync(abs).size;
|
|
133
|
+
if (bytes >= 20) {
|
|
134
|
+
hit = { abs, scope, bytes };
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (hit === null) {
|
|
140
|
+
missing.push(review.label);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
found.push({ name: review.name, path: hit.abs, bytes: hit.bytes, scope: hit.scope });
|
|
144
|
+
}
|
|
145
|
+
const status = missing.length === 0 ? 'pass' : 'fail';
|
|
146
|
+
return {
|
|
147
|
+
name: 'review-fanout',
|
|
148
|
+
description: '3-way review fan-out (code-review + security-review + perf baseline)',
|
|
149
|
+
status,
|
|
150
|
+
durationMs: Date.now() - start,
|
|
151
|
+
detail: status === 'pass'
|
|
152
|
+
? `All 3 review artifacts present (${found.map((f) => f.name).join(', ')}; scope: ${found[0]?.scope}).`
|
|
153
|
+
: `Missing or empty: ${missing.join(', ')}. Re-run with --refresh-fanout or invoke Skill(skill="peaks-rd") to regenerate.`,
|
|
154
|
+
data: { found, missing }
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
async function runGateVerifyPipeline(projectRoot, rid, changeId) {
|
|
158
|
+
const start = Date.now();
|
|
159
|
+
try {
|
|
160
|
+
const result = await verifyPipeline({ projectRoot, rid, changeId });
|
|
161
|
+
const duration = Date.now() - start;
|
|
162
|
+
return {
|
|
163
|
+
name: 'gate-verify-pipeline',
|
|
164
|
+
description: 'peaks workflow verify-pipeline (RD/QA gate checks against .peaks/<changeId>/)',
|
|
165
|
+
status: result.complete ? 'pass' : 'fail',
|
|
166
|
+
durationMs: duration,
|
|
167
|
+
detail: result.complete
|
|
168
|
+
? `All gates passed in ${duration}ms.`
|
|
169
|
+
: `${result.violations.length} violation(s): ${result.violations.join('; ')}`,
|
|
170
|
+
data: {
|
|
171
|
+
rdGates: result.rdPhase.gates.length,
|
|
172
|
+
qaGates: result.qaPhase.gates.length,
|
|
173
|
+
rdState: result.rdPhase.state,
|
|
174
|
+
qaState: result.qaPhase.state,
|
|
175
|
+
violations: result.violations,
|
|
176
|
+
nextActions: result.nextActions
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
return {
|
|
182
|
+
name: 'gate-verify-pipeline',
|
|
183
|
+
description: 'peaks workflow verify-pipeline (RD/QA gate checks against .peaks/<changeId>/)',
|
|
184
|
+
status: 'fail',
|
|
185
|
+
durationMs: Date.now() - start,
|
|
186
|
+
detail: error?.message ?? 'verify-pipeline threw',
|
|
187
|
+
data: {}
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
export async function sliceCheck(options) {
|
|
192
|
+
const peaksRoot = join(options.projectRoot, '.peaks');
|
|
193
|
+
if (!(await isDirectory(peaksRoot))) {
|
|
194
|
+
throw new Error(`.peaks/ not found at ${options.projectRoot}. Run peaks workspace init first.`);
|
|
195
|
+
}
|
|
196
|
+
// Resolve rid: explicit > current-change binding > null
|
|
197
|
+
let rid = options.rid;
|
|
198
|
+
if (rid === undefined) {
|
|
199
|
+
const bound = getCurrentChangeId(options.projectRoot);
|
|
200
|
+
if (bound !== null) {
|
|
201
|
+
rid = bound;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (rid === undefined) {
|
|
205
|
+
throw new Error('No --rid and no current-change binding. Pass --rid <id> or run peaks workspace init --change-id <id> first.');
|
|
206
|
+
}
|
|
207
|
+
const totalStart = Date.now();
|
|
208
|
+
const stages = [];
|
|
209
|
+
// Stage 1: typecheck
|
|
210
|
+
stages.push(await runTypecheck(options.projectRoot));
|
|
211
|
+
// Stage 2: full vitest
|
|
212
|
+
if (!options.skipTests) {
|
|
213
|
+
stages.push(await runUnitTests(options.projectRoot));
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
stages.push({
|
|
217
|
+
name: 'unit-tests',
|
|
218
|
+
description: 'npx vitest run (skipped per --skip-tests)',
|
|
219
|
+
status: 'skipped',
|
|
220
|
+
durationMs: 0,
|
|
221
|
+
detail: 'Skipped: --skip-tests was set.'
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
// Stage 3: 3-way review fanout check
|
|
225
|
+
stages.push(await runReviewFanout(options.projectRoot, rid, options.refreshFanout));
|
|
226
|
+
// Stage 4: gate verify-pipeline
|
|
227
|
+
stages.push(await runGateVerifyPipeline(options.projectRoot, rid, rid));
|
|
228
|
+
const boundaryReady = stages.every((s) => s.status === 'pass' || s.status === 'skipped');
|
|
229
|
+
const nextActions = [];
|
|
230
|
+
if (!boundaryReady) {
|
|
231
|
+
const failed = stages.filter((s) => s.status === 'fail');
|
|
232
|
+
for (const f of failed) {
|
|
233
|
+
nextActions.push(`Fix ${f.name}: ${f.detail.split('\n')[0]}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
nextActions.push(`peaks request transition ${rid} --role rd --state qa-handoff --confirm --project <path>`);
|
|
238
|
+
nextActions.push(`peaks request transition ${rid} --role qa --state verdict-issued --confirm --project <path>`);
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
projectRoot: options.projectRoot,
|
|
242
|
+
rid,
|
|
243
|
+
stages,
|
|
244
|
+
boundaryReady,
|
|
245
|
+
totalDurationMs: Date.now() - totalStart,
|
|
246
|
+
nextActions
|
|
247
|
+
};
|
|
248
|
+
}
|