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
|
@@ -4,6 +4,7 @@ import { dirname, join } from 'node:path';
|
|
|
4
4
|
import { isDirectory, listDirectories } from '../../shared/fs.js';
|
|
5
5
|
import { checkPrerequisites, DEFAULT_REQUEST_TYPE, isRequestType, VALID_REQUEST_TYPES } from './artifact-prerequisites.js';
|
|
6
6
|
import { ensureSession } from '../session/session-manager.js';
|
|
7
|
+
import { getCurrentChangeId, getChangeArtifactRoot } from '../../shared/change-id.js';
|
|
7
8
|
import { getNextNumber, buildNumberedFilename } from '../../shared/incrementing-number.js';
|
|
8
9
|
import { lintRequestArtifact } from './artifact-lint-service.js';
|
|
9
10
|
import { checkTypeSanity } from '../scan/type-sanity-service.js';
|
|
@@ -21,10 +22,11 @@ function dateSlugFromIso(iso) {
|
|
|
21
22
|
function defaultSessionId(iso) {
|
|
22
23
|
return `${dateSlugFromIso(iso)}-session`;
|
|
23
24
|
}
|
|
24
|
-
function renderPrdTemplate(requestId, sessionId, timestamp, requestType) {
|
|
25
|
+
function renderPrdTemplate(requestId, changeId, sessionId, timestamp, requestType) {
|
|
25
26
|
return `# PRD Request ${requestId}
|
|
26
27
|
|
|
27
28
|
- session: ${sessionId}
|
|
29
|
+
- change-id: ${changeId}
|
|
28
30
|
- type: ${requestType}
|
|
29
31
|
- source: <ticket, message URL, or "verbal" with a short sanitized quote>
|
|
30
32
|
- raw input (sanitized): <one-paragraph restatement of what the user actually asked for>
|
|
@@ -57,9 +59,9 @@ function renderPrdTemplate(requestId, sessionId, timestamp, requestType) {
|
|
|
57
59
|
|
|
58
60
|
## Handoff
|
|
59
61
|
|
|
60
|
-
- to peaks-rd: .peaks/${
|
|
61
|
-
- to peaks-qa: .peaks/${
|
|
62
|
-
- to peaks-ui: .peaks/${
|
|
62
|
+
- to peaks-rd: .peaks/${changeId}/rd/requests/${requestId}.md
|
|
63
|
+
- to peaks-qa: .peaks/${changeId}/qa/requests/${requestId}.md
|
|
64
|
+
- to peaks-ui: .peaks/${changeId}/ui/requests/${requestId}.md (when UI involved)
|
|
63
65
|
|
|
64
66
|
## Status
|
|
65
67
|
|
|
@@ -68,11 +70,12 @@ function renderPrdTemplate(requestId, sessionId, timestamp, requestType) {
|
|
|
68
70
|
- state: draft
|
|
69
71
|
`;
|
|
70
72
|
}
|
|
71
|
-
function renderUiTemplate(requestId, sessionId, timestamp, requestType) {
|
|
73
|
+
function renderUiTemplate(requestId, changeId, sessionId, timestamp, requestType) {
|
|
72
74
|
return `# UI Request ${requestId}
|
|
73
75
|
|
|
74
76
|
- session: ${sessionId}
|
|
75
|
-
-
|
|
77
|
+
- change-id: ${changeId}
|
|
78
|
+
- linked-prd: .peaks/${changeId}/prd/requests/${requestId}.md
|
|
76
79
|
- type: ${requestType}
|
|
77
80
|
- scope: full new surface | iteration on existing surface | regression fix | visual refresh
|
|
78
81
|
- design direction: editorial | bento | Swiss | luxury | retro-futurist | glass | product-system | other-explicit-name
|
|
@@ -108,8 +111,8 @@ function renderUiTemplate(requestId, sessionId, timestamp, requestType) {
|
|
|
108
111
|
|
|
109
112
|
## Handoff
|
|
110
113
|
|
|
111
|
-
- to peaks-rd: .peaks/${
|
|
112
|
-
- to peaks-qa: .peaks/${
|
|
114
|
+
- to peaks-rd: .peaks/${changeId}/rd/requests/${requestId}.md
|
|
115
|
+
- to peaks-qa: .peaks/${changeId}/qa/requests/${requestId}.md
|
|
113
116
|
|
|
114
117
|
## Status
|
|
115
118
|
|
|
@@ -118,12 +121,13 @@ function renderUiTemplate(requestId, sessionId, timestamp, requestType) {
|
|
|
118
121
|
- state: draft
|
|
119
122
|
`;
|
|
120
123
|
}
|
|
121
|
-
function renderRdTemplate(requestId, sessionId, timestamp, requestType) {
|
|
124
|
+
function renderRdTemplate(requestId, changeId, sessionId, timestamp, requestType) {
|
|
122
125
|
return `# RD Request ${requestId}
|
|
123
126
|
|
|
124
127
|
- session: ${sessionId}
|
|
125
|
-
-
|
|
126
|
-
- linked-
|
|
128
|
+
- change-id: ${changeId}
|
|
129
|
+
- linked-prd: .peaks/${changeId}/prd/requests/${requestId}.md
|
|
130
|
+
- linked-ui: .peaks/${changeId}/ui/requests/${requestId}.md (when UI involved)
|
|
127
131
|
- type: ${requestType}
|
|
128
132
|
|
|
129
133
|
## Red-line scope
|
|
@@ -165,8 +169,8 @@ function renderRdTemplate(requestId, sessionId, timestamp, requestType) {
|
|
|
165
169
|
|
|
166
170
|
## Handoff
|
|
167
171
|
|
|
168
|
-
- to peaks-qa: .peaks/${
|
|
169
|
-
- to peaks-sc: .peaks/${
|
|
172
|
+
- to peaks-qa: .peaks/${changeId}/qa/requests/${requestId}.md
|
|
173
|
+
- to peaks-sc: .peaks/${changeId}/sc/commit-boundaries/${requestId}.md
|
|
170
174
|
|
|
171
175
|
## Status
|
|
172
176
|
|
|
@@ -175,13 +179,14 @@ function renderRdTemplate(requestId, sessionId, timestamp, requestType) {
|
|
|
175
179
|
- state: draft
|
|
176
180
|
`;
|
|
177
181
|
}
|
|
178
|
-
function renderQaTemplate(requestId, sessionId, timestamp, requestType) {
|
|
182
|
+
function renderQaTemplate(requestId, changeId, sessionId, timestamp, requestType) {
|
|
179
183
|
return `# QA Request ${requestId}
|
|
180
184
|
|
|
181
185
|
- session: ${sessionId}
|
|
182
|
-
-
|
|
183
|
-
- linked-
|
|
184
|
-
- linked-
|
|
186
|
+
- change-id: ${changeId}
|
|
187
|
+
- linked-prd: .peaks/${changeId}/prd/requests/${requestId}.md
|
|
188
|
+
- linked-rd: .peaks/${changeId}/rd/requests/${requestId}.md
|
|
189
|
+
- linked-ui: .peaks/${changeId}/ui/requests/${requestId}.md (when UI involved)
|
|
185
190
|
- type: ${requestType}
|
|
186
191
|
|
|
187
192
|
## Red-line boundary check
|
|
@@ -230,14 +235,15 @@ function renderQaTemplate(requestId, sessionId, timestamp, requestType) {
|
|
|
230
235
|
- state: draft
|
|
231
236
|
`;
|
|
232
237
|
}
|
|
233
|
-
function renderScTemplate(requestId, sessionId, timestamp, requestType) {
|
|
238
|
+
function renderScTemplate(requestId, changeId, sessionId, timestamp, requestType) {
|
|
234
239
|
return `# SC Request ${requestId}
|
|
235
240
|
|
|
236
241
|
- session: ${sessionId}
|
|
237
|
-
-
|
|
238
|
-
- linked-
|
|
239
|
-
- linked-
|
|
240
|
-
- linked-
|
|
242
|
+
- change-id: ${changeId}
|
|
243
|
+
- linked-prd: .peaks/${changeId}/prd/requests/${requestId}.md
|
|
244
|
+
- linked-rd: .peaks/${changeId}/rd/requests/${requestId}.md
|
|
245
|
+
- linked-qa: .peaks/${changeId}/qa/requests/${requestId}.md
|
|
246
|
+
- linked-ui: .peaks/${changeId}/ui/requests/${requestId}.md (when UI involved)
|
|
241
247
|
- type: ${requestType}
|
|
242
248
|
|
|
243
249
|
## Change impact
|
|
@@ -262,7 +268,7 @@ function renderScTemplate(requestId, sessionId, timestamp, requestType) {
|
|
|
262
268
|
|
|
263
269
|
## Sync / authorization
|
|
264
270
|
|
|
265
|
-
- artifact workspace path: .peaks/${
|
|
271
|
+
- artifact workspace path: .peaks/${changeId}/
|
|
266
272
|
- memory sync authorized: yes | no
|
|
267
273
|
- artifact sync authorized: yes | no
|
|
268
274
|
- rationale if not authorized: keep local
|
|
@@ -273,7 +279,7 @@ function renderScTemplate(requestId, sessionId, timestamp, requestType) {
|
|
|
273
279
|
|
|
274
280
|
## Handoff
|
|
275
281
|
|
|
276
|
-
- to peaks-txt: .peaks/${
|
|
282
|
+
- to peaks-txt: .peaks/${changeId}/txt/skill-usage-lessons.md (when reusable lesson exists)
|
|
277
283
|
|
|
278
284
|
## Status
|
|
279
285
|
|
|
@@ -282,18 +288,18 @@ function renderScTemplate(requestId, sessionId, timestamp, requestType) {
|
|
|
282
288
|
- state: draft
|
|
283
289
|
`;
|
|
284
290
|
}
|
|
285
|
-
function renderTemplate(role, requestId, sessionId, timestamp, requestType) {
|
|
291
|
+
function renderTemplate(role, requestId, changeId, sessionId, timestamp, requestType) {
|
|
286
292
|
switch (role) {
|
|
287
293
|
case 'prd':
|
|
288
|
-
return renderPrdTemplate(requestId, sessionId, timestamp, requestType);
|
|
294
|
+
return renderPrdTemplate(requestId, changeId, sessionId, timestamp, requestType);
|
|
289
295
|
case 'ui':
|
|
290
|
-
return renderUiTemplate(requestId, sessionId, timestamp, requestType);
|
|
296
|
+
return renderUiTemplate(requestId, changeId, sessionId, timestamp, requestType);
|
|
291
297
|
case 'rd':
|
|
292
|
-
return renderRdTemplate(requestId, sessionId, timestamp, requestType);
|
|
298
|
+
return renderRdTemplate(requestId, changeId, sessionId, timestamp, requestType);
|
|
293
299
|
case 'qa':
|
|
294
|
-
return renderQaTemplate(requestId, sessionId, timestamp, requestType);
|
|
300
|
+
return renderQaTemplate(requestId, changeId, sessionId, timestamp, requestType);
|
|
295
301
|
case 'sc':
|
|
296
|
-
return renderScTemplate(requestId, sessionId, timestamp, requestType);
|
|
302
|
+
return renderScTemplate(requestId, changeId, sessionId, timestamp, requestType);
|
|
297
303
|
}
|
|
298
304
|
}
|
|
299
305
|
export async function createRequestArtifact(options) {
|
|
@@ -306,10 +312,26 @@ export async function createRequestArtifact(options) {
|
|
|
306
312
|
const requestType = options.requestType ?? DEFAULT_REQUEST_TYPE;
|
|
307
313
|
const clock = options.clock ?? defaultClock;
|
|
308
314
|
const timestamp = clock();
|
|
309
|
-
// Use provided session ID or get/create current session
|
|
315
|
+
// Use provided session ID or get/create current session. The session
|
|
316
|
+
// id is kept as the in-memory binding (so the artifact body can record
|
|
317
|
+
// which session wrote it), but the artifact file is now written
|
|
318
|
+
// under the change-id dir, NOT the session dir.
|
|
319
|
+
//
|
|
320
|
+
// The change-id is the file's durable scope. As of slice
|
|
321
|
+
// 2026-06-05-change-id-as-unit-of-work, the requestId IS the
|
|
322
|
+
// change-id (per the legacy `001-<change-id>.md` filename convention);
|
|
323
|
+
// a request that lives in a session dir under a different change-id
|
|
324
|
+
// is no longer the model. We honor a `current-change` binding if
|
|
325
|
+
// one is set, and otherwise fall back to the requestId itself.
|
|
310
326
|
const sessionId = options.sessionId ?? await ensureSession(options.projectRoot);
|
|
311
|
-
|
|
312
|
-
|
|
327
|
+
const boundChangeId = getCurrentChangeId(options.projectRoot);
|
|
328
|
+
// Resolution order for the change-id (file path key):
|
|
329
|
+
// 1. Explicit `options.changeId` (CLI `--session-id` pre-1.3.0 set this).
|
|
330
|
+
// 2. `current-change` binding (live developer working context).
|
|
331
|
+
// 3. The requestId itself (every request is its own scope by default).
|
|
332
|
+
const changeId = options.changeId ?? boundChangeId ?? options.requestId;
|
|
333
|
+
// Build numbered path under the change-id dir
|
|
334
|
+
const requestsDir = getChangeArtifactRoot(options.projectRoot, changeId) + '/' + options.role + '/requests';
|
|
313
335
|
// Check if a file with this requestId already exists (regardless of number prefix)
|
|
314
336
|
if (await isDirectory(requestsDir)) {
|
|
315
337
|
const existingFiles = await listMarkdownFiles(requestsDir);
|
|
@@ -327,15 +349,18 @@ export async function createRequestArtifact(options) {
|
|
|
327
349
|
const number = getNextNumber(requestsDir);
|
|
328
350
|
const filename = buildNumberedFilename(number, options.requestId);
|
|
329
351
|
const path = join(requestsDir, filename);
|
|
330
|
-
const content = renderTemplate(options.role, options.requestId, sessionId, timestamp, requestType);
|
|
352
|
+
const content = renderTemplate(options.role, options.requestId, changeId, sessionId, timestamp, requestType);
|
|
331
353
|
if (options.apply !== true) {
|
|
332
354
|
return { role: options.role, requestId: options.requestId, sessionId, path, content, applied: false };
|
|
333
355
|
}
|
|
334
356
|
await mkdir(dirname(path), { recursive: true });
|
|
335
357
|
await writeFile(path, content, 'utf8');
|
|
336
|
-
// Create QA initiated marker so rd:qa-handoff gate can verify QA was invoked
|
|
358
|
+
// Create QA initiated marker so rd:qa-handoff gate can verify QA was invoked.
|
|
359
|
+
// As of slice 2026-06-05-change-id-as-unit-of-work, the marker lives under
|
|
360
|
+
// the change-id dir (not the session dir), so the gate's prereq scan
|
|
361
|
+
// (which reads from `.peaks/<change-id>/<role>/...`) finds it.
|
|
337
362
|
if (options.role === 'qa') {
|
|
338
|
-
const qaDir =
|
|
363
|
+
const qaDir = getChangeArtifactRoot(options.projectRoot, changeId) + '/qa';
|
|
339
364
|
const initiatedPath = join(qaDir, '.initiated');
|
|
340
365
|
if (!existsSync(initiatedPath)) {
|
|
341
366
|
await mkdir(qaDir, { recursive: true });
|
|
@@ -348,6 +373,7 @@ function extractMetadata(markdown) {
|
|
|
348
373
|
let state = 'unknown';
|
|
349
374
|
let createdAt;
|
|
350
375
|
let requestType = DEFAULT_REQUEST_TYPE;
|
|
376
|
+
let sessionId;
|
|
351
377
|
for (const rawLine of markdown.split(/\r?\n/)) {
|
|
352
378
|
const line = rawLine.trim();
|
|
353
379
|
const stateMatch = /^-\s*state:\s*(.+?)\s*$/.exec(line);
|
|
@@ -367,21 +393,41 @@ function extractMetadata(markdown) {
|
|
|
367
393
|
requestType = candidate;
|
|
368
394
|
}
|
|
369
395
|
// Placeholder values (e.g. "feature | bug | refactor | clarification") fall back to default.
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
const sessionMatch = /^-\s*session:\s*(.+?)\s*$/.exec(line);
|
|
399
|
+
if (sessionMatch !== null && sessionMatch[1] !== undefined) {
|
|
400
|
+
sessionId = sessionMatch[1];
|
|
401
|
+
continue;
|
|
370
402
|
}
|
|
371
403
|
}
|
|
372
404
|
const base = { state, requestType };
|
|
373
405
|
if (createdAt !== undefined)
|
|
374
406
|
base.createdAt = createdAt;
|
|
407
|
+
if (sessionId !== undefined)
|
|
408
|
+
base.sessionId = sessionId;
|
|
375
409
|
return base;
|
|
376
410
|
}
|
|
377
|
-
async function readSummary(projectRoot,
|
|
378
|
-
const path = join(projectRoot, '.peaks',
|
|
411
|
+
async function readSummary(projectRoot, changeId, role, fileName) {
|
|
412
|
+
const path = join(projectRoot, '.peaks', changeId, role, 'requests', fileName);
|
|
379
413
|
const body = await readFile(path, 'utf8');
|
|
380
|
-
const { state, createdAt, requestType } = extractMetadata(body);
|
|
414
|
+
const { state, createdAt, requestType, sessionId: bodySessionId } = extractMetadata(body);
|
|
381
415
|
// Strip numbered prefix (e.g., "001-requestId.md" -> "requestId")
|
|
382
416
|
// Only strip 3-digit zero-padded prefixes (our incrementing number format)
|
|
383
417
|
const requestId = fileName.replace(/^0\d{2}-/, '').replace(/\.md$/, '');
|
|
384
|
-
|
|
418
|
+
// `changeId` is the durable scope (the directory the file lives in).
|
|
419
|
+
// `sessionId` is metadata (the session that wrote the file, parsed from
|
|
420
|
+
// the `- session:` body line). They may differ when a request is read
|
|
421
|
+
// across sessions (back-compat) or after a session re-bind.
|
|
422
|
+
const summary = {
|
|
423
|
+
role,
|
|
424
|
+
changeId,
|
|
425
|
+
sessionId: bodySessionId ?? changeId,
|
|
426
|
+
requestId,
|
|
427
|
+
path,
|
|
428
|
+
state,
|
|
429
|
+
requestType
|
|
430
|
+
};
|
|
385
431
|
if (createdAt !== undefined) {
|
|
386
432
|
summary.createdAt = createdAt;
|
|
387
433
|
}
|
|
@@ -402,15 +448,53 @@ export async function listRequestArtifacts(options) {
|
|
|
402
448
|
if (!(await isDirectory(peaksRoot))) {
|
|
403
449
|
return [];
|
|
404
450
|
}
|
|
405
|
-
|
|
451
|
+
// As of slice 2026-06-05-change-id-as-unit-of-work, artifact files live
|
|
452
|
+
// in `.peaks/<change-id>/<role>/requests/`. The top-level `.peaks/<dir>/`
|
|
453
|
+
// entries we scan here are change-id dirs (new layout) AND legacy
|
|
454
|
+
// session-id dirs (pre-1.3.0 layout). Both have the same
|
|
455
|
+
// `<role>/requests/<file>.md` shape, so we read them uniformly.
|
|
456
|
+
//
|
|
457
|
+
// Additionally, shipped slices are archived under
|
|
458
|
+
// `.peaks/retrospective/<change-id>/<role>/requests/` and dogfood
|
|
459
|
+
// evidence lives under `.peaks/_dogfood/<change-id>/<role>/requests/`.
|
|
460
|
+
// When `sessionId` is NOT pinned, we scan ALL three umbrella dirs
|
|
461
|
+
// (`<top>`, `retrospective/`, `_dogfood/`) so a `peaks request show
|
|
462
|
+
// <rid>` resolves shipped slices too — which is what the slice check's
|
|
463
|
+
// gate-verify-pipeline stage needs to find evidence for the retrospective
|
|
464
|
+
// slice being verified.
|
|
465
|
+
//
|
|
466
|
+
// Skip well-known non-artifact dirs: `_runtime/` holds ephemeral state
|
|
467
|
+
// (no `requests/` subdirs anyway, but skip explicitly to avoid noise).
|
|
468
|
+
const allDirs = await listDirectories(peaksRoot);
|
|
469
|
+
const candidateDirs = allDirs.filter((dir) => dir !== '_runtime');
|
|
470
|
+
// Expand scopes to include the nested umbrellas that host change-id dirs
|
|
471
|
+
// (retrospective/, _dogfood/). For each, list its sub-dirs and treat
|
|
472
|
+
// them as additional scopes. This makes the lookup span the entire
|
|
473
|
+
// .peaks tree.
|
|
474
|
+
const expandedScopes = [];
|
|
475
|
+
if (options.sessionId !== undefined) {
|
|
476
|
+
expandedScopes.push(options.sessionId);
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
for (const dir of candidateDirs) {
|
|
480
|
+
expandedScopes.push(dir);
|
|
481
|
+
if (dir === 'retrospective' || dir === '_dogfood') {
|
|
482
|
+
const nested = await listDirectories(join(peaksRoot, dir));
|
|
483
|
+
for (const n of nested) {
|
|
484
|
+
expandedScopes.push(join(dir, n));
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const scopes = expandedScopes;
|
|
406
490
|
const roles = options.role !== undefined ? [options.role] : Array.from(VALID_ROLES);
|
|
407
491
|
const summaries = [];
|
|
408
|
-
for (const
|
|
492
|
+
for (const scope of scopes) {
|
|
409
493
|
for (const role of roles) {
|
|
410
|
-
const dir = join(peaksRoot,
|
|
494
|
+
const dir = join(peaksRoot, scope, role, 'requests');
|
|
411
495
|
const fileNames = await listMarkdownFiles(dir);
|
|
412
496
|
for (const fileName of fileNames) {
|
|
413
|
-
summaries.push(await readSummary(options.projectRoot,
|
|
497
|
+
summaries.push(await readSummary(options.projectRoot, scope, role, fileName));
|
|
414
498
|
}
|
|
415
499
|
}
|
|
416
500
|
}
|
|
@@ -438,32 +522,65 @@ export async function showRequestArtifact(options) {
|
|
|
438
522
|
}
|
|
439
523
|
return null;
|
|
440
524
|
};
|
|
525
|
+
// As of slice 2026-06-05-change-id-as-unit-of-work, the dir key is the
|
|
526
|
+
// change-id (not the session-id). When the caller pins `sessionId` we
|
|
527
|
+
// use it as the scope anyway (legacy callers, and tests that pass
|
|
528
|
+
// `STABLE_SESSION` as a stand-in). The directory layout is identical
|
|
529
|
+
// for both old session dirs and new change-id dirs, so a single
|
|
530
|
+
// read path works for both.
|
|
441
531
|
if (options.sessionId !== undefined) {
|
|
442
532
|
const dir = join(options.projectRoot, '.peaks', options.sessionId, options.role, 'requests');
|
|
443
533
|
const found = await findFileInDir(dir);
|
|
444
534
|
if (found === null) {
|
|
445
535
|
return null;
|
|
446
536
|
}
|
|
447
|
-
|
|
448
|
-
const content = await readFile(found.path, 'utf8');
|
|
449
|
-
return { ...summary, content };
|
|
537
|
+
return await readRequestArtifact(options.projectRoot, options.sessionId, options.role, found);
|
|
450
538
|
}
|
|
451
539
|
const peaksRoot = join(options.projectRoot, '.peaks');
|
|
452
540
|
if (!(await isDirectory(peaksRoot))) {
|
|
453
541
|
return null;
|
|
454
542
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
543
|
+
// Scan all top-level dirs in `.peaks/` AND nested change-id dirs
|
|
544
|
+
// under `retrospective/` and `_dogfood/`. The expanded scope list
|
|
545
|
+
// lets us find request artifacts that live one or two levels deep
|
|
546
|
+
// (shipped slices, dogfood evidence). Without this expansion,
|
|
547
|
+
// verify-pipeline can't find the RD/QA request files for any
|
|
548
|
+
// retrospective slice.
|
|
549
|
+
const allDirs = await listDirectories(peaksRoot);
|
|
550
|
+
const scopes = [];
|
|
551
|
+
for (const dir of allDirs) {
|
|
552
|
+
if (dir === '_runtime')
|
|
553
|
+
continue;
|
|
554
|
+
scopes.push(dir);
|
|
555
|
+
if (dir === 'retrospective' || dir === '_dogfood') {
|
|
556
|
+
const nested = await listDirectories(join(peaksRoot, dir));
|
|
557
|
+
for (const n of nested) {
|
|
558
|
+
scopes.push(join(dir, n));
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
for (const scope of scopes) {
|
|
563
|
+
const dir = join(peaksRoot, scope, options.role, 'requests');
|
|
458
564
|
const found = await findFileInDir(dir);
|
|
459
565
|
if (found !== null) {
|
|
460
|
-
|
|
461
|
-
const content = await readFile(found.path, 'utf8');
|
|
462
|
-
return { ...summary, content };
|
|
566
|
+
return await readRequestArtifact(options.projectRoot, scope, options.role, found);
|
|
463
567
|
}
|
|
464
568
|
}
|
|
465
569
|
return null;
|
|
466
570
|
}
|
|
571
|
+
/** Read the summary + content for a found request file; treat a read
|
|
572
|
+
* error on the content as "not found" so the caller can fall through
|
|
573
|
+
* to the next candidate (the on-disk file may be partially written). */
|
|
574
|
+
async function readRequestArtifact(projectRoot, scope, role, found) {
|
|
575
|
+
const summary = await readSummary(projectRoot, scope, role, found.fileName);
|
|
576
|
+
try {
|
|
577
|
+
const content = await readFile(found.path, 'utf8');
|
|
578
|
+
return { ...summary, content };
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
467
584
|
const ALLOWED_STATES_PER_ROLE = {
|
|
468
585
|
prd: ['draft', 'confirmed-by-user', 'handed-off', 'blocked'],
|
|
469
586
|
ui: ['draft', 'direction-locked', 'handed-off', 'blocked'],
|
|
@@ -601,7 +718,7 @@ export async function transitionRequestArtifact(options) {
|
|
|
601
718
|
});
|
|
602
719
|
const prerequisiteResult = await checkPrerequisites({
|
|
603
720
|
projectRoot: options.projectRoot,
|
|
604
|
-
|
|
721
|
+
changeId: existing.changeId,
|
|
605
722
|
role: options.role,
|
|
606
723
|
newState: options.newState,
|
|
607
724
|
requestId: options.requestId,
|
|
@@ -653,6 +770,7 @@ export async function transitionRequestArtifact(options) {
|
|
|
653
770
|
await writeFile(existing.path, updated, 'utf8');
|
|
654
771
|
const result = {
|
|
655
772
|
role: options.role,
|
|
773
|
+
changeId: existing.changeId,
|
|
656
774
|
sessionId: existing.sessionId,
|
|
657
775
|
requestId: options.requestId,
|
|
658
776
|
path: existing.path,
|
|
@@ -30,4 +30,11 @@ export type DoctorOptions = {
|
|
|
30
30
|
/** Platform string (defaults to process.platform); injectable for tests. */
|
|
31
31
|
platform?: NodeJS.Platform;
|
|
32
32
|
};
|
|
33
|
+
/**
|
|
34
|
+
* Pure helper extracted from `defaultWorkspaceInitializedProbe` so tests can
|
|
35
|
+
* drive the filesystem check without monkey-patching `process.cwd()` or
|
|
36
|
+
* `findProjectRoot`. Returns `true` when EITHER the canonical
|
|
37
|
+
* `.peaks/_runtime/session.json` OR the legacy `.peaks/.session.json` exists.
|
|
38
|
+
*/
|
|
39
|
+
export declare function isWorkspaceInitializedAt(projectRoot: string): boolean;
|
|
33
40
|
export declare function runDoctor(options?: DoctorOptions): Promise<DoctorReport>;
|
|
@@ -48,7 +48,25 @@ function defaultWorkspaceInitializedProbe() {
|
|
|
48
48
|
const projectRoot = findProjectRoot(process.cwd());
|
|
49
49
|
if (projectRoot === null)
|
|
50
50
|
return false;
|
|
51
|
-
|
|
51
|
+
// Workspace is "initialized" when EITHER the canonical runtime-layer session
|
|
52
|
+
// binding (`.peaks/_runtime/session.json`, the home since slice
|
|
53
|
+
// 2026-06-05-peaks-runtime-layer) OR the legacy top-level binding
|
|
54
|
+
// (`.peaks/.session.json`, kept as read-only back-compat for one minor
|
|
55
|
+
// release) is present. The legacy check is what catches projects that ran
|
|
56
|
+
// `peaks workspace init` before the runtime-layer migration and have not yet
|
|
57
|
+
// been reconciled; both paths must continue to satisfy the doctor until the
|
|
58
|
+
// legacy location is removed.
|
|
59
|
+
return isWorkspaceInitializedAt(projectRoot);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Pure helper extracted from `defaultWorkspaceInitializedProbe` so tests can
|
|
63
|
+
* drive the filesystem check without monkey-patching `process.cwd()` or
|
|
64
|
+
* `findProjectRoot`. Returns `true` when EITHER the canonical
|
|
65
|
+
* `.peaks/_runtime/session.json` OR the legacy `.peaks/.session.json` exists.
|
|
66
|
+
*/
|
|
67
|
+
export function isWorkspaceInitializedAt(projectRoot) {
|
|
68
|
+
return (existsSync(join(projectRoot, '.peaks', '_runtime', 'session.json')) ||
|
|
69
|
+
existsSync(join(projectRoot, '.peaks', '.session.json')));
|
|
52
70
|
}
|
|
53
71
|
const DESTRUCTIVE_APPLY_PATTERNS = [
|
|
54
72
|
/peaks\s+memory\s+sync[^\n]*--apply/,
|
|
@@ -234,7 +252,7 @@ export async function runDoctor(options = {}) {
|
|
|
234
252
|
checks.push({
|
|
235
253
|
id: 'skill-presence:workspace',
|
|
236
254
|
ok: false,
|
|
237
|
-
message: `Skill ${presence.skill} is active but no workspace session exists (.peaks
|
|
255
|
+
message: `Skill ${presence.skill} is active but no workspace session exists (.peaks/_runtime/session.json missing); run \`peaks workspace init --project <repo>\` — peaks-solo Step 0 must anchor the workspace before any work`
|
|
238
256
|
});
|
|
239
257
|
}
|
|
240
258
|
else {
|
|
@@ -164,6 +164,32 @@ export type ReadSpawnRecordResult = {
|
|
|
164
164
|
reason: 'no-binding' | 'no-spawn-record' | 'invalid-json';
|
|
165
165
|
};
|
|
166
166
|
export declare function readSpawnRecord(projectRoot: string): ReadSpawnRecordResult;
|
|
167
|
+
/**
|
|
168
|
+
* Idempotency check for the `peaks progress start` CLI (and the Task-tool
|
|
169
|
+
* PreToolUse hook that fires it on every Task call). Returns `true` when a
|
|
170
|
+
* recent spawn record exists for this project's session, meaning a watch
|
|
171
|
+
* window was opened within the last `ttlMs` milliseconds. The LLM-side
|
|
172
|
+
* caller treats `true` as "no-op — a watch is already running somewhere".
|
|
173
|
+
*
|
|
174
|
+
* Why TTL instead of pid liveness: the spawned terminal's pid is the
|
|
175
|
+
* osascript/gnome-terminal process, which exits as soon as it spawns the
|
|
176
|
+
* nested shell. Checking pid liveness gives false negatives (the process
|
|
177
|
+
* is gone by design). The spawn record is the canonical "watch was opened
|
|
178
|
+
* for this session" marker. After `ttlMs` the record is treated as stale
|
|
179
|
+
* (e.g. the user closed the window long ago) and a fresh start proceeds.
|
|
180
|
+
*
|
|
181
|
+
* `ttlMs` defaults to 5 minutes — long enough to cover a typical sub-agent
|
|
182
|
+
* slice (RD parallel fan-out + QA verdict), short enough that a user who
|
|
183
|
+
* closed the window gets a fresh one on the next Task call.
|
|
184
|
+
*/
|
|
185
|
+
export type RecentSpawnCheck = {
|
|
186
|
+
recent: boolean;
|
|
187
|
+
reason: 'recent-spawn' | 'no-spawn-record' | 'no-binding' | 'invalid-json' | 'stale-spawn' | 'process-dead';
|
|
188
|
+
/** When reason is 'recent-spawn' or 'stale-spawn', the spawn record. */
|
|
189
|
+
record?: ProgressSpawnRecord;
|
|
190
|
+
ageMs?: number;
|
|
191
|
+
};
|
|
192
|
+
export declare function isRecentSpawn(projectRoot: string, now?: () => number, ttlMs?: number): RecentSpawnCheck;
|
|
167
193
|
export declare function clearSpawnRecord(projectRoot: string): boolean;
|
|
168
194
|
export type PhaseClosingTrigger = 'finished' | 'failed';
|
|
169
195
|
/**
|
|
@@ -245,6 +245,31 @@ export function readSpawnRecord(projectRoot) {
|
|
|
245
245
|
return { ok: false, reason: 'invalid-json' };
|
|
246
246
|
}
|
|
247
247
|
}
|
|
248
|
+
export function isRecentSpawn(projectRoot, now = Date.now, ttlMs = 5 * 60 * 1000) {
|
|
249
|
+
const result = readSpawnRecord(projectRoot);
|
|
250
|
+
if (!result.ok) {
|
|
251
|
+
return { recent: false, reason: result.reason };
|
|
252
|
+
}
|
|
253
|
+
const record = result.data;
|
|
254
|
+
const spawnedMs = Date.parse(record.spawnedAt);
|
|
255
|
+
if (Number.isNaN(spawnedMs)) {
|
|
256
|
+
// Treat unparseable timestamps as a stale record so the next start
|
|
257
|
+
// proceeds normally. This is the conservative choice — the alternative
|
|
258
|
+
// (treating unparseable as "always recent") would block legitimate
|
|
259
|
+
// re-spawns forever.
|
|
260
|
+
return { recent: false, reason: 'stale-spawn', record };
|
|
261
|
+
}
|
|
262
|
+
const ageMs = now() - spawnedMs;
|
|
263
|
+
if (ageMs < 0) {
|
|
264
|
+
// Clock skew or future-dated record: trust the record as "recent" so
|
|
265
|
+
// we do not double-spawn. Same conservative default as a 0-age record.
|
|
266
|
+
return { recent: true, reason: 'recent-spawn', record, ageMs: 0 };
|
|
267
|
+
}
|
|
268
|
+
if (ageMs >= ttlMs) {
|
|
269
|
+
return { recent: false, reason: 'stale-spawn', record, ageMs };
|
|
270
|
+
}
|
|
271
|
+
return { recent: true, reason: 'recent-spawn', record, ageMs };
|
|
272
|
+
}
|
|
248
273
|
export function clearSpawnRecord(projectRoot) {
|
|
249
274
|
const path = spawnRecordPath(projectRoot);
|
|
250
275
|
if (!existsSync(path))
|
|
@@ -51,6 +51,52 @@ export type CommitBoundary = {
|
|
|
51
51
|
syncState: 'synced' | 'pending' | 'failed';
|
|
52
52
|
rollbackPoint: string | null;
|
|
53
53
|
};
|
|
54
|
+
/**
|
|
55
|
+
* Resolution sources for `resolveArtifactSession`, in priority order.
|
|
56
|
+
* - `active-skill`: the orchestrator's active-skill marker
|
|
57
|
+
* (`.peaks/_runtime/active-skill.json`, with a one-minor-release
|
|
58
|
+
* fallback to `.peaks/.active-skill.json`) `sessionId` points to a
|
|
59
|
+
* session dir that owns the slice's marker artifact.
|
|
60
|
+
* - `session-json`: the workspace binding in
|
|
61
|
+
* `.peaks/_runtime/session.json` (with back-compat fallback to
|
|
62
|
+
* `.peaks/.session.json`) points to a session dir that owns the
|
|
63
|
+
* slice's marker artifact (active-skill was checked but did not
|
|
64
|
+
* own it; session-json is the next source).
|
|
65
|
+
* - `find-fallback`: neither binding owned the artifact, but a `find`
|
|
66
|
+
* walk under `.peaks/` located a session dir that does own it.
|
|
67
|
+
*/
|
|
68
|
+
export type ArtifactSessionSource = 'active-skill' | 'session-json' | 'find-fallback';
|
|
69
|
+
export type ResolvedArtifactSession = {
|
|
70
|
+
resolvedSessionId: string | null;
|
|
71
|
+
candidateSources: ArtifactSessionSource[];
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Resolve the session id that owns the slice's artifacts using a 3-tier
|
|
75
|
+
* precedence:
|
|
76
|
+
*
|
|
77
|
+
* 1. `.peaks/_runtime/active-skill.json` `sessionId` (with back-compat
|
|
78
|
+
* fallback to `.peaks/.active-skill.json`) if it points to a real
|
|
79
|
+
* session that owns the slice.
|
|
80
|
+
* 2. `.peaks/_runtime/session.json` `sessionId` (with back-compat
|
|
81
|
+
* fallback to `.peaks/.session.json`) if it points to a real
|
|
82
|
+
* session that owns the slice.
|
|
83
|
+
* 3. `find .peaks/ -name '<marker>'` — the first session dir under
|
|
84
|
+
* `.peaks/` that owns the slice.
|
|
85
|
+
* 4. else `{ resolvedSessionId: null, candidateSources: [] }`.
|
|
86
|
+
*
|
|
87
|
+
* The back-compat fallbacks are tolerated for one minor release so
|
|
88
|
+
* users with pre-migration trees (or running an older CLI version)
|
|
89
|
+
* still get a clean resolution. After the migration (or after v1.3.0
|
|
90
|
+
* is installed and `peaks workspace reconcile` has been run), only
|
|
91
|
+
* the new paths exist and the fallbacks never fire.
|
|
92
|
+
*
|
|
93
|
+
* `candidateSources` reports which sources were checked before the
|
|
94
|
+
* resolver found (or did not find) a winner; the list is in the order
|
|
95
|
+
* the resolver consulted them. This makes the precedence observable in
|
|
96
|
+
* the JSON envelope so a human reviewer can see "active-skill was empty
|
|
97
|
+
* AND session-json was empty, so find-fallback won".
|
|
98
|
+
*/
|
|
99
|
+
export declare function resolveArtifactSession(projectRoot: string, sliceId: string): ResolvedArtifactSession;
|
|
54
100
|
export declare function getChangeTraceabilityStatus(): ChangeTraceabilityStatus;
|
|
55
101
|
export declare function createChangeImpact(options: {
|
|
56
102
|
changeId: string;
|
|
@@ -71,10 +117,15 @@ export declare function recordCommitBoundary(options: {
|
|
|
71
117
|
sliceId: string;
|
|
72
118
|
artifacts?: string[];
|
|
73
119
|
codeFiles?: string[];
|
|
74
|
-
}): CommitBoundary
|
|
120
|
+
}): CommitBoundary & {
|
|
121
|
+
resolvedSessionId: string | null;
|
|
122
|
+
candidateSources: ArtifactSessionSource[];
|
|
123
|
+
};
|
|
75
124
|
export declare function validateArtifactRetention(sliceId: string): {
|
|
76
125
|
valid: boolean;
|
|
77
126
|
missingArtifacts: string[];
|
|
78
127
|
warnings: string[];
|
|
128
|
+
resolvedSessionId: string | null;
|
|
129
|
+
candidateSources: ArtifactSessionSource[];
|
|
79
130
|
};
|
|
80
131
|
export declare function getScHelpText(): string[];
|