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.
Files changed (53) hide show
  1. package/README.md +62 -46
  2. package/bin/peaks.js +0 -0
  3. package/dist/src/cli/commands/hooks-commands.js +24 -9
  4. package/dist/src/cli/commands/progress-commands.js +26 -2
  5. package/dist/src/cli/commands/request-commands.js +5 -0
  6. package/dist/src/cli/commands/slice-commands.d.ts +3 -0
  7. package/dist/src/cli/commands/slice-commands.js +42 -0
  8. package/dist/src/cli/commands/workflow-commands.js +3 -3
  9. package/dist/src/cli/commands/workspace-commands.d.ts +63 -0
  10. package/dist/src/cli/commands/workspace-commands.js +347 -5
  11. package/dist/src/cli/program.js +4 -0
  12. package/dist/src/services/artifacts/artifact-prerequisites.d.ts +17 -1
  13. package/dist/src/services/artifacts/artifact-prerequisites.js +38 -5
  14. package/dist/src/services/artifacts/request-artifact-service.d.ts +22 -0
  15. package/dist/src/services/artifacts/request-artifact-service.js +172 -54
  16. package/dist/src/services/doctor/doctor-service.d.ts +7 -0
  17. package/dist/src/services/doctor/doctor-service.js +20 -2
  18. package/dist/src/services/progress/progress-service.d.ts +26 -0
  19. package/dist/src/services/progress/progress-service.js +25 -0
  20. package/dist/src/services/sc/sc-service.d.ts +52 -1
  21. package/dist/src/services/sc/sc-service.js +324 -17
  22. package/dist/src/services/scan/acceptance-coverage-service.js +6 -2
  23. package/dist/src/services/session/session-manager.d.ts +7 -5
  24. package/dist/src/services/session/session-manager.js +60 -16
  25. package/dist/src/services/skills/hooks-settings-service.d.ts +25 -3
  26. package/dist/src/services/skills/hooks-settings-service.js +57 -13
  27. package/dist/src/services/skills/skill-presence-service.js +102 -68
  28. package/dist/src/services/skills/skill-runbook-service.js +2 -1
  29. package/dist/src/services/skills/skill-statusline-service.js +13 -7
  30. package/dist/src/services/slice/slice-check-service.d.ts +2 -0
  31. package/dist/src/services/slice/slice-check-service.js +248 -0
  32. package/dist/src/services/slice/slice-check-types.d.ts +61 -0
  33. package/dist/src/services/slice/slice-check-types.js +18 -0
  34. package/dist/src/services/workflow/pipeline-verify-service.d.ts +5 -2
  35. package/dist/src/services/workflow/pipeline-verify-service.js +35 -35
  36. package/dist/src/services/workspace/migrate-service.d.ts +2 -0
  37. package/dist/src/services/workspace/migrate-service.js +484 -0
  38. package/dist/src/services/workspace/migrate-types.d.ts +84 -0
  39. package/dist/src/services/workspace/migrate-types.js +21 -0
  40. package/dist/src/services/workspace/reconcile-service.d.ts +119 -0
  41. package/dist/src/services/workspace/reconcile-service.js +464 -0
  42. package/dist/src/services/workspace/reconcile-types.d.ts +93 -0
  43. package/dist/src/services/workspace/reconcile-types.js +13 -0
  44. package/dist/src/services/workspace/workspace-service.d.ts +11 -0
  45. package/dist/src/services/workspace/workspace-service.js +87 -7
  46. package/dist/src/shared/change-id.d.ts +59 -0
  47. package/dist/src/shared/change-id.js +194 -16
  48. package/dist/src/shared/version.d.ts +1 -1
  49. package/dist/src/shared/version.js +1 -1
  50. package/package.json +13 -2
  51. package/skills/peaks-solo/SKILL.md +28 -4
  52. package/skills/peaks-solo/references/micro-cycle.md +155 -0
  53. 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
- /** Substring that identifies a Peaks-managed PreToolUse hook entry. */
8
- export const HOOK_SENTINEL = 'peaks gate enforce';
9
- const HOOK_MATCHER = 'Bash';
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
- return handlers.length > 0 && handlers.every((h) => typeof h?.command === 'string' && h.command.includes(HOOK_SENTINEL));
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 { scope, settingsPath, exists, alreadyInstalled: isInstalled(settings), desiredCommand: HOOK_ENFORCE_COMMAND };
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 our PreToolUse entry into settings, preserving all other keys and hooks. */
106
- function withHookInstalled(settings) {
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
- const ourEntry = { matcher: HOOK_MATCHER, hooks: [{ type: 'command', command: HOOK_ENFORCE_COMMAND }] };
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: [...preToolUse, ourEntry] }
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, withHookInstalled(settings));
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
- const PRESENCE_FILE = '.peaks/.active-skill.json';
35
- const SESSION_FILE = '.peaks/.session.json';
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 sessionPath = resolve(resolveProjectRoot(projectRootOverride), SESSION_FILE);
46
- if (!existsSync(sessionPath))
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(sessionPath, 'utf8'));
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 `.peaks/.active-skill.json` *before* we overwrite it.
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 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
- }
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
- catch {
115
- return undefined;
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 presencePath = resolvePresencePath(projectRootOverride);
188
- if (!existsSync(presencePath)) {
224
+ const result = readSkillPresenceBackCompat(projectRootOverride);
225
+ if (result === null)
189
226
  return null;
190
- }
191
- try {
192
- const raw = readFileSync(presencePath, 'utf8');
193
- const parsed = JSON.parse(raw);
194
- if (typeof parsed?.skill !== 'string' || parsed.skill.length === 0) {
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 presencePath = resolvePresencePath(projectRootOverride);
212
- if (!existsSync(presencePath)) {
243
+ const result = readSkillPresenceBackCompat(projectRootOverride);
244
+ if (result === null)
213
245
  return null;
214
- }
215
- try {
216
- const raw = readFileSync(presencePath, 'utf8');
217
- const parsed = JSON.parse(raw);
218
- if (typeof parsed?.skill !== 'string' || parsed.skill.length === 0) {
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
- if (!existsSync(presencePath)) {
239
- return false;
240
- }
241
- try {
242
- unlinkSync(presencePath);
243
- return true;
244
- }
245
- catch {
246
- return false;
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
- * (.peaks/.active-skill.json) and prints a single line that Claude Code paints at
10
- * the bottom of the terminal. Because it is rendered by the harness — not emitted
11
- * as LLM tokens the signal cannot be forgotten by the model, cannot be confused
12
- * with normal output, and survives context compaction.
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/.active-skill.json';
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
- if (!existsSync(presencePath)) {
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(presencePath, 'utf8'));
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,2 @@
1
+ import type { SliceCheckOptions, SliceCheckResult } from './slice-check-types.js';
2
+ export declare function sliceCheck(options: SliceCheckOptions): Promise<SliceCheckResult>;
@@ -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
+ }