peaks-cli 1.3.0 → 1.3.2
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/core-artifact-commands.js +49 -11
- 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 +44 -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 +349 -12
- package/dist/src/cli/program.js +4 -0
- package/dist/src/services/artifacts/artifact-prerequisites.d.ts +29 -1
- package/dist/src/services/artifacts/artifact-prerequisites.js +69 -5
- package/dist/src/services/artifacts/request-artifact-service.d.ts +22 -0
- package/dist/src/services/artifacts/request-artifact-service.js +214 -56
- package/dist/src/services/doctor/doctor-service.d.ts +69 -0
- package/dist/src/services/doctor/doctor-service.js +296 -3
- 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.js +71 -13
- package/dist/src/services/scan/acceptance-coverage-service.js +6 -2
- package/dist/src/services/session/session-manager.d.ts +22 -1
- package/dist/src/services/session/session-manager.js +149 -30
- 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/slice/slice-check-service.d.ts +2 -0
- package/dist/src/services/slice/slice-check-service.js +267 -0
- package/dist/src/services/slice/slice-check-types.d.ts +70 -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 +606 -0
- package/dist/src/services/workspace/migrate-types.d.ts +127 -0
- package/dist/src/services/workspace/migrate-types.js +21 -0
- package/dist/src/services/workspace/reconcile-service.d.ts +33 -0
- package/dist/src/services/workspace/reconcile-service.js +160 -42
- package/dist/src/services/workspace/reconcile-types.d.ts +25 -0
- package/dist/src/services/workspace/workspace-service.d.ts +11 -0
- package/dist/src/services/workspace/workspace-service.js +71 -24
- 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 +10 -2
- package/schemas/doctor-report.schema.json +2 -2
- package/skills/peaks-qa/SKILL.md +1 -0
- package/skills/peaks-rd/SKILL.md +2 -1
- package/skills/peaks-solo/SKILL.md +17 -1
- package/skills/peaks-solo/references/micro-cycle.md +155 -0
- package/skills/peaks-txt/SKILL.md +2 -0
- package/skills/peaks-ui/SKILL.md +1 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
|
-
import { existsSync } from 'node:fs';
|
|
3
|
+
import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs';
|
|
4
4
|
import { createRequire } from 'node:module';
|
|
5
5
|
import { dirname, resolve as resolvePath } from 'node:path';
|
|
6
6
|
import { readText } from '../../shared/fs.js';
|
|
@@ -48,7 +48,211 @@ 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')));
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Pure helper that compares the published dist `CLI_VERSION` against the
|
|
73
|
+
* source-of-truth `package.json#version`. Default readers fail-soft to `null`
|
|
74
|
+
* on missing/unreadable/malformed input. Exported so tests can drive the
|
|
75
|
+
* filesystem reads without monkey-patching `process.cwd()`.
|
|
76
|
+
*/
|
|
77
|
+
export function compareDistVersion(opts) {
|
|
78
|
+
const distReader = opts.distVersionReader ?? defaultDistVersionReader;
|
|
79
|
+
const sourceReader = opts.sourceVersionReader ?? defaultSourceVersionReader;
|
|
80
|
+
const dist = safeRead(() => distReader(opts.projectRoot));
|
|
81
|
+
const source = safeRead(() => sourceReader(opts.projectRoot)) ?? 'unknown';
|
|
82
|
+
const distReadable = dist !== null;
|
|
83
|
+
return {
|
|
84
|
+
dist,
|
|
85
|
+
source,
|
|
86
|
+
match: distReadable && dist === source,
|
|
87
|
+
distReadable
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function safeRead(reader) {
|
|
91
|
+
try {
|
|
92
|
+
return reader();
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function defaultDistVersionReader(projectRoot) {
|
|
99
|
+
// Synchronous read is fine: the dist version.js is small and on the
|
|
100
|
+
// local build pipeline's hot path. readFileSync + regex is cheaper
|
|
101
|
+
// than pulling in fs/promises for a single short file.
|
|
102
|
+
const distPath = join(projectRoot, 'dist', 'src', 'shared', 'version.js');
|
|
103
|
+
if (!existsSync(distPath)) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
const body = readFileSync(distPath, 'utf8');
|
|
107
|
+
const match = /export\s+const\s+CLI_VERSION\s*=\s*["']([^"']+)["']/.exec(body);
|
|
108
|
+
return match?.[1] ?? null;
|
|
109
|
+
}
|
|
110
|
+
function defaultSourceVersionReader(projectRoot) {
|
|
111
|
+
const pkgPath = join(projectRoot, 'package.json');
|
|
112
|
+
if (!existsSync(pkgPath)) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const body = readFileSync(pkgPath, 'utf8');
|
|
116
|
+
const parsed = JSON.parse(body);
|
|
117
|
+
return typeof parsed.version === 'string' ? parsed.version : null;
|
|
118
|
+
}
|
|
119
|
+
function defaultDistVersionProbe() {
|
|
120
|
+
const projectRoot = findProjectRoot(process.cwd());
|
|
121
|
+
if (projectRoot === null) {
|
|
122
|
+
return { dist: null, source: 'unknown', match: false, distReadable: false };
|
|
123
|
+
}
|
|
124
|
+
return compareDistVersion({ projectRoot });
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Pure helper that inspects the on-disk workspace layout for
|
|
128
|
+
* post-F3-canonical violations. The post-F3 canonical layout puts
|
|
129
|
+
* session dirs under `.peaks/_runtime/<sid>/` and the runtime
|
|
130
|
+
* binding at `.peaks/_runtime/session.json`; the legacy paths
|
|
131
|
+
* (top-level `<YYYY-MM-DD-session-<hex>>/` dirs and the legacy
|
|
132
|
+
* top-level `.peaks/.session.json` / `.peaks/.active-skill.json`
|
|
133
|
+
* dotfiles) must be absent. This helper is exported so tests can
|
|
134
|
+
* drive the filesystem walk without monkey-patching `process.cwd()`
|
|
135
|
+
* or `findProjectRoot`.
|
|
136
|
+
*
|
|
137
|
+
* Both scanners fail-soft (return `[]` on read errors) so a flaky
|
|
138
|
+
* filesystem read on a non-fatal probe path never escalates into a
|
|
139
|
+
* doctor failure.
|
|
140
|
+
*/
|
|
141
|
+
export function inspectWorkspaceLayout(opts) {
|
|
142
|
+
const topLevel = opts.topLevelScanner ?? defaultTopLevelSessionDirScanner;
|
|
143
|
+
const dotfiles = opts.dotfileScanner ?? defaultLegacyDotfileScanner;
|
|
144
|
+
const perChangeId = opts.perChangeIdScanner ?? defaultPerChangeIdDirScanner;
|
|
145
|
+
return {
|
|
146
|
+
topLevelSessionDirs: safeList(() => topLevel(opts.projectRoot)),
|
|
147
|
+
legacyDotfiles: safeList(() => dotfiles(opts.projectRoot)),
|
|
148
|
+
perChangeIdDirs: safeList(() => perChangeId(opts.projectRoot))
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function safeList(reader) {
|
|
152
|
+
try {
|
|
153
|
+
const out = reader();
|
|
154
|
+
return Array.isArray(out) ? out : [];
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const SESSION_DIR_PATTERN = /^\d{4}-\d{2}-\d{2}-session-[a-f0-9]+$/;
|
|
161
|
+
function defaultTopLevelSessionDirScanner(projectRoot) {
|
|
162
|
+
const peaksRoot = join(projectRoot, '.peaks');
|
|
163
|
+
if (!existsSync(peaksRoot))
|
|
164
|
+
return [];
|
|
165
|
+
let names;
|
|
166
|
+
try {
|
|
167
|
+
names = readdirSync(peaksRoot);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
const offenders = [];
|
|
173
|
+
for (const name of names) {
|
|
174
|
+
if (!SESSION_DIR_PATTERN.test(name))
|
|
175
|
+
continue;
|
|
176
|
+
const full = join(peaksRoot, name);
|
|
177
|
+
try {
|
|
178
|
+
const stat = existsSync(full) ? lstatSync(full) : null;
|
|
179
|
+
if (stat === null)
|
|
180
|
+
continue;
|
|
181
|
+
// Directories only — the regex should never match a dotfile or
|
|
182
|
+
// regular file, but be defensive against weird filesystem state
|
|
183
|
+
// (e.g. someone manually created a file whose name happens to
|
|
184
|
+
// match the session-id pattern).
|
|
185
|
+
if (stat.isDirectory()) {
|
|
186
|
+
offenders.push(join('.peaks', name) + '/');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return offenders;
|
|
194
|
+
}
|
|
195
|
+
const LEGACY_DOTFILES = ['.session.json', '.active-skill.json'];
|
|
196
|
+
function defaultLegacyDotfileScanner(projectRoot) {
|
|
197
|
+
const peaksRoot = join(projectRoot, '.peaks');
|
|
198
|
+
if (!existsSync(peaksRoot))
|
|
199
|
+
return [];
|
|
200
|
+
const offenders = [];
|
|
201
|
+
for (const name of LEGACY_DOTFILES) {
|
|
202
|
+
if (existsSync(join(peaksRoot, name))) {
|
|
203
|
+
offenders.push(join('.peaks', name));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return offenders;
|
|
207
|
+
}
|
|
208
|
+
function defaultWorkspaceLayoutProbe() {
|
|
209
|
+
const projectRoot = findProjectRoot(process.cwd());
|
|
210
|
+
if (projectRoot === null) {
|
|
211
|
+
return { topLevelSessionDirs: [], legacyDotfiles: [], perChangeIdDirs: [] };
|
|
212
|
+
}
|
|
213
|
+
return inspectWorkspaceLayout({ projectRoot });
|
|
214
|
+
}
|
|
215
|
+
// Slice 007 — per-change-id top-level dir pattern. Matches the
|
|
216
|
+
// F3-canonical (pre-canonicalization) layout the 5 already-shipped
|
|
217
|
+
// slices left behind, e.g. `.peaks/001-2026-06-06-doctor-dist-version-check/`.
|
|
218
|
+
// The pattern is intentionally narrow so it does NOT match the
|
|
219
|
+
// post-F3 system dirs (`_runtime/`, `_dogfood/`, `retrospective/`,
|
|
220
|
+
// `issues/`, `memory/`, `perf-baseline/`, `project-scan/`, `sops/`,
|
|
221
|
+
// `0NN-session-...`, `YYYY-MM-DD-session-...`).
|
|
222
|
+
const PER_CHANGE_ID_PATTERN = /^\d{3}-\d{4}-\d{2}-\d{2}-[a-z][a-z0-9-]*[a-z0-9]$/;
|
|
223
|
+
function defaultPerChangeIdDirScanner(projectRoot) {
|
|
224
|
+
const peaksRoot = join(projectRoot, '.peaks');
|
|
225
|
+
if (!existsSync(peaksRoot))
|
|
226
|
+
return [];
|
|
227
|
+
let names;
|
|
228
|
+
try {
|
|
229
|
+
names = readdirSync(peaksRoot);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return [];
|
|
233
|
+
}
|
|
234
|
+
const offenders = [];
|
|
235
|
+
for (const name of names) {
|
|
236
|
+
if (!PER_CHANGE_ID_PATTERN.test(name))
|
|
237
|
+
continue;
|
|
238
|
+
const full = join(peaksRoot, name);
|
|
239
|
+
try {
|
|
240
|
+
const stat = existsSync(full) ? lstatSync(full) : null;
|
|
241
|
+
if (stat === null)
|
|
242
|
+
continue;
|
|
243
|
+
// Directories only — the regex should never match a dotfile
|
|
244
|
+
// or regular file, but be defensive against weird filesystem
|
|
245
|
+
// state (e.g. someone manually created a file whose name
|
|
246
|
+
// happens to match the per-change-id pattern).
|
|
247
|
+
if (stat.isDirectory()) {
|
|
248
|
+
offenders.push(join('.peaks', name) + '/');
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return offenders;
|
|
52
256
|
}
|
|
53
257
|
const DESTRUCTIVE_APPLY_PATTERNS = [
|
|
54
258
|
/peaks\s+memory\s+sync[^\n]*--apply/,
|
|
@@ -234,7 +438,7 @@ export async function runDoctor(options = {}) {
|
|
|
234
438
|
checks.push({
|
|
235
439
|
id: 'skill-presence:workspace',
|
|
236
440
|
ok: false,
|
|
237
|
-
message: `Skill ${presence.skill} is active but no workspace session exists (.peaks
|
|
441
|
+
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
442
|
});
|
|
239
443
|
}
|
|
240
444
|
else {
|
|
@@ -326,6 +530,95 @@ export async function runDoctor(options = {}) {
|
|
|
326
530
|
message: `@colbymchenry/codegraph not resolvable: ${getErrorMessage(error)}`
|
|
327
531
|
});
|
|
328
532
|
}
|
|
533
|
+
// Build-hygiene check: the published `dist/` ships a different CLI_VERSION
|
|
534
|
+
// than the source-of-truth `src/shared/version.ts` / `package.json#version`
|
|
535
|
+
// whenever the user runs `npx peaks` or `node bin/peaks.js` after `pnpm
|
|
536
|
+
// install` but before `pnpm build`. This is the silent-stale-CLI failure
|
|
537
|
+
// mode reported in `.peaks/2026-06-05-session-fecddb/txt/dogfood-2026-06-04-05.md`
|
|
538
|
+
// (F1). A missing dist/ is treated as informational (fresh clone, not broken)
|
|
539
|
+
// so the check does not flip the summary to red on a clean checkout.
|
|
540
|
+
const distProbe = options.distVersionProbe ?? defaultDistVersionProbe;
|
|
541
|
+
try {
|
|
542
|
+
const result = distProbe();
|
|
543
|
+
if (!result.distReadable) {
|
|
544
|
+
checks.push({
|
|
545
|
+
id: 'build:dist-version-matches-source',
|
|
546
|
+
ok: true,
|
|
547
|
+
message: `dist/ is not present; run \`pnpm build\` to populate dist/src/shared/version.js (source version ${result.source})`
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
else if (result.match) {
|
|
551
|
+
checks.push({
|
|
552
|
+
id: 'build:dist-version-matches-source',
|
|
553
|
+
ok: true,
|
|
554
|
+
message: `dist/src/shared/version.js ships CLI_VERSION ${result.dist} matching source ${result.source}`
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
checks.push({
|
|
559
|
+
id: 'build:dist-version-matches-source',
|
|
560
|
+
ok: false,
|
|
561
|
+
message: `dist/src/shared/version.js ships CLI_VERSION ${result.dist} but source ${result.source} is in src/shared/version.ts; run \`pnpm build\` to refresh dist/`
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
catch (error) {
|
|
566
|
+
checks.push({
|
|
567
|
+
id: 'build:dist-version-matches-source',
|
|
568
|
+
ok: false,
|
|
569
|
+
message: `dist version check failed: ${getErrorMessage(error)}`
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
// Build-hygiene check: a non-canonical post-F3 workspace layout is
|
|
573
|
+
// the silent-regression failure mode that slice 003 explicitly chose
|
|
574
|
+
// to allow (the current session binding was kept at top-level as a
|
|
575
|
+
// safety measure). This check surfaces any leftover top-level
|
|
576
|
+
// session dirs, the legacy runtime dotfiles (`.peaks/.session.json`,
|
|
577
|
+
// `.peaks/.active-skill.json`), OR per-change-id top-level dirs
|
|
578
|
+
// (`.peaks/NNN-YYYY-MM-DD-<slug>/`) so a future contributor who
|
|
579
|
+
// manually recreates one of them is warned. The check is read-only;
|
|
580
|
+
// the fix path is `peaks workspace migrate --to-runtime --project
|
|
581
|
+
// <repo> --apply`.
|
|
582
|
+
//
|
|
583
|
+
// Slice 007 broadened this check to flag per-change-id top-level
|
|
584
|
+
// dirs (the 5 already-shipped slices left them behind). Until
|
|
585
|
+
// slice 008's migration consolidates them, the check reports
|
|
586
|
+
// `ok: false` for any repo that still has the legacy per-change-id
|
|
587
|
+
// layout.
|
|
588
|
+
const layoutProbe = options.workspaceLayoutProbe ?? defaultWorkspaceLayoutProbe;
|
|
589
|
+
try {
|
|
590
|
+
const layout = layoutProbe();
|
|
591
|
+
// Back-compat: probes injected by older tests (pre-slice-007)
|
|
592
|
+
// return a 2-field shape (no perChangeIdDirs). Treat missing
|
|
593
|
+
// field as empty.
|
|
594
|
+
const perChangeIdDirs = layout.perChangeIdDirs ?? [];
|
|
595
|
+
if (layout.topLevelSessionDirs.length === 0 && layout.legacyDotfiles.length === 0 && perChangeIdDirs.length === 0) {
|
|
596
|
+
checks.push({
|
|
597
|
+
id: 'build:workspace-layout-canonical',
|
|
598
|
+
ok: true,
|
|
599
|
+
message: 'Workspace layout is canonical: no top-level session dirs, no legacy runtime dotfiles, no per-change-id top-level dirs'
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
else {
|
|
603
|
+
const offenders = [
|
|
604
|
+
...layout.topLevelSessionDirs.map((p) => `top-level session dir: ${p}`),
|
|
605
|
+
...layout.legacyDotfiles.map((p) => `legacy dotfile: ${p}`),
|
|
606
|
+
...perChangeIdDirs.map((p) => `per-change-id top-level dir: ${p}`)
|
|
607
|
+
];
|
|
608
|
+
checks.push({
|
|
609
|
+
id: 'build:workspace-layout-canonical',
|
|
610
|
+
ok: false,
|
|
611
|
+
message: `Workspace layout is not canonical. Offenders: ${offenders.join('; ')}. Run \`peaks workspace migrate --to-runtime --project <repo> --apply\` to consolidate.`
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
catch (error) {
|
|
616
|
+
checks.push({
|
|
617
|
+
id: 'build:workspace-layout-canonical',
|
|
618
|
+
ok: false,
|
|
619
|
+
message: `Workspace layout check failed: ${getErrorMessage(error)}`
|
|
620
|
+
});
|
|
621
|
+
}
|
|
329
622
|
try {
|
|
330
623
|
const schemaText = await readText(join(schemaRoot, 'doctor-report.schema.json'));
|
|
331
624
|
const schema = JSON.parse(schemaText);
|
|
@@ -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))
|
|
@@ -197,12 +197,24 @@ function readSessionJsonBinding(projectRoot) {
|
|
|
197
197
|
* session does not own the slice.
|
|
198
198
|
*/
|
|
199
199
|
function sessionOwnsSlice(projectRoot, sessionId, sliceId) {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
200
|
+
// As of slice 2026-06-05-change-id-as-unit-of-work, the same
|
|
201
|
+
// session id can live at multiple umbrella locations:
|
|
202
|
+
// - `.peaks/<sessionId>/` (legacy or top-level active)
|
|
203
|
+
// - `.peaks/retrospective/<sessionId>/` (shipped slice)
|
|
204
|
+
// - `.peaks/_dogfood/<sessionId>/` (dogfood evidence)
|
|
205
|
+
// Try each in turn; the first match wins.
|
|
206
|
+
const candidateDirs = [
|
|
207
|
+
join(projectRoot, '.peaks', sessionId),
|
|
208
|
+
join(projectRoot, '.peaks', 'retrospective', sessionId),
|
|
209
|
+
join(projectRoot, '.peaks', '_dogfood', sessionId)
|
|
210
|
+
];
|
|
211
|
+
for (const sessionDir of candidateDirs) {
|
|
212
|
+
if (!existsSync(sessionDir))
|
|
213
|
+
continue;
|
|
214
|
+
for (const marker of [`qa/test-cases/${sliceId}.md`, `qa/test-reports/${sliceId}.md`]) {
|
|
215
|
+
if (existsSync(join(sessionDir, marker)))
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
206
218
|
}
|
|
207
219
|
return false;
|
|
208
220
|
}
|
|
@@ -215,23 +227,69 @@ function findSessionOwningSlice(projectRoot, sliceId) {
|
|
|
215
227
|
const peaksRoot = join(projectRoot, '.peaks');
|
|
216
228
|
if (!existsSync(peaksRoot))
|
|
217
229
|
return null;
|
|
218
|
-
let
|
|
230
|
+
let topLevel;
|
|
219
231
|
try {
|
|
220
|
-
|
|
232
|
+
topLevel = readdirSync(peaksRoot);
|
|
221
233
|
}
|
|
222
234
|
catch {
|
|
223
235
|
return null;
|
|
224
236
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
237
|
+
topLevel.sort();
|
|
238
|
+
// As of slice 2026-06-05-change-id-as-unit-of-work, shipped slices
|
|
239
|
+
// are archived under `.peaks/retrospective/<dir>/` and dogfood
|
|
240
|
+
// evidence lives under `.peaks/_dogfood/<dir>/`. Both umbrellas host
|
|
241
|
+
// scopes at one level deeper than the top-level `.peaks/<dir>/`
|
|
242
|
+
// shape. Expand the candidate list so the find-fallback tier can
|
|
243
|
+
// locate these. Accept both legacy session-id format
|
|
244
|
+
// (`YYYY-MM-DD-session-<6hex>`) and new change-id format
|
|
245
|
+
// (`YYYY-MM-DD-<slug>`) as valid scope dir names.
|
|
246
|
+
const candidateSessionIds = [];
|
|
247
|
+
for (const entry of topLevel) {
|
|
248
|
+
if (entry === 'retrospective' || entry === '_dogfood') {
|
|
249
|
+
const nested = readdirSyncSafe(join(peaksRoot, entry));
|
|
250
|
+
for (const n of nested) {
|
|
251
|
+
candidateSessionIds.push(n);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
candidateSessionIds.push(entry);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
candidateSessionIds.sort();
|
|
259
|
+
for (const id of candidateSessionIds) {
|
|
260
|
+
// Legacy session-id format OR new change-id format. Reject files
|
|
261
|
+
// and other non-scope entries (e.g. .peaks-init-hooks-decision.json,
|
|
262
|
+
// PROJECT.md, _runtime, memory, sops, issues, perf-baseline, etc.)
|
|
263
|
+
if (!isScopeDirName(id))
|
|
228
264
|
continue;
|
|
229
|
-
if (sessionOwnsSlice(projectRoot,
|
|
230
|
-
return
|
|
265
|
+
if (sessionOwnsSlice(projectRoot, id, sliceId)) {
|
|
266
|
+
return id;
|
|
231
267
|
}
|
|
232
268
|
}
|
|
233
269
|
return null;
|
|
234
270
|
}
|
|
271
|
+
/** Is this a valid legacy session-id OR new change-id dir name? */
|
|
272
|
+
function isScopeDirName(name) {
|
|
273
|
+
// Legacy: 2026-MM-DD-session-<6hex>
|
|
274
|
+
if (/^\d{4}-\d{2}-\d{2}-session-[a-f0-9]+$/.test(name))
|
|
275
|
+
return true;
|
|
276
|
+
// New: 2026-MM-DD-<slug> where slug is letters / digits / hyphens / dots
|
|
277
|
+
// (the slice-migration kept the 4-digit year prefix, so 3-digit
|
|
278
|
+
// numbered filenames from request artifacts stay out of this path —
|
|
279
|
+
// they live in retrospective/ nested with the year-prefixed change-id
|
|
280
|
+
// as the dir, not at top level).
|
|
281
|
+
if (/^\d{4}-\d{2}-\d{2}-[\w.\-]+$/.test(name))
|
|
282
|
+
return true;
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
function readdirSyncSafe(dir) {
|
|
286
|
+
try {
|
|
287
|
+
return readdirSync(dir);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return [];
|
|
291
|
+
}
|
|
292
|
+
}
|
|
235
293
|
/**
|
|
236
294
|
* Resolve the session id that owns the slice's artifacts using a 3-tier
|
|
237
295
|
* precedence:
|
|
@@ -104,8 +104,12 @@ export async function getAcceptanceCoverage(options) {
|
|
|
104
104
|
if (prdArtifact === null) {
|
|
105
105
|
return { kind: 'prd-not-found' };
|
|
106
106
|
}
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
// As of slice 2026-06-05-change-id-as-unit-of-work, test-cases live
|
|
108
|
+
// under the same change-id dir as the PRD itself (the on-disk scope),
|
|
109
|
+
// not under the body's `- session:` line. The `prdArtifact.changeId`
|
|
110
|
+
// is the dir the PRD was found in.
|
|
111
|
+
const changeId = prdArtifact.changeId;
|
|
112
|
+
const testCasesPath = join(options.projectRoot, '.peaks', changeId, 'qa', 'test-cases', `${options.requestId}.md`);
|
|
109
113
|
if (!(await pathExists(testCasesPath))) {
|
|
110
114
|
return { kind: 'test-cases-not-found', expectedPath: testCasesPath };
|
|
111
115
|
}
|
|
@@ -45,6 +45,13 @@ export type SessionMeta = {
|
|
|
45
45
|
* no binding was present. The caller is expected to do something
|
|
46
46
|
* with that — at minimum surface it in the CLI response so the
|
|
47
47
|
* user can find the directory again if they need to.
|
|
48
|
+
*
|
|
49
|
+
* Slice 008 (F22 fix): the read uses the canonicalize-on-read
|
|
50
|
+
* variant so a binding written with `projectRoot: "."` (relative
|
|
51
|
+
* form, anchored from inside the project dir) is still found when
|
|
52
|
+
* the caller passes the absolute realpath. Pre-F22 the
|
|
53
|
+
* strict-equality read returned null in that case, and rotate
|
|
54
|
+
* silently no-op'd (the CLI reported "no prior binding").
|
|
48
55
|
*/
|
|
49
56
|
export declare function rotateSessionBinding(projectRoot: string): string | null;
|
|
50
57
|
/**
|
|
@@ -75,6 +82,11 @@ export declare function setSessionTitle(projectRoot: string, sessionId: string,
|
|
|
75
82
|
/**
|
|
76
83
|
* List all session directories under .peaks with their metadata.
|
|
77
84
|
* Returns sessions sorted by sessionId descending (most recent first).
|
|
85
|
+
*
|
|
86
|
+
* As of slice 2026-06-06-session-layout-canonicalize the session
|
|
87
|
+
* dirs live at the canonical runtime home `.peaks/_runtime/<sid>/`.
|
|
88
|
+
* The legacy top-level layout is read for back-compat (one minor
|
|
89
|
+
* release) but is not authoritative.
|
|
78
90
|
*/
|
|
79
91
|
export declare function listSessionMetas(projectRoot: string): SessionMeta[];
|
|
80
92
|
export declare function ensureSession(projectRoot: string): Promise<string>;
|
|
@@ -118,14 +130,23 @@ export declare function getSessionIdCanonical(projectRoot: string): string | nul
|
|
|
118
130
|
* Get the absolute path to the current session directory.
|
|
119
131
|
* Creates the session if it doesn't exist.
|
|
120
132
|
*
|
|
133
|
+
* As of slice 2026-06-06-session-layout-canonicalize the canonical
|
|
134
|
+
* home is `.peaks/_runtime/<sid>/`. The legacy top-level layout
|
|
135
|
+
* `.peaks/<sid>/` is the back-compat read fallback only.
|
|
136
|
+
*
|
|
121
137
|
* @param projectRoot - Root directory of the project
|
|
122
|
-
* @returns Absolute path to session directory (e.g., "/path/to/project/.peaks/2026-05-26-session-a3f8b1")
|
|
138
|
+
* @returns Absolute path to session directory (e.g., "/path/to/project/.peaks/_runtime/2026-05-26-session-a3f8b1")
|
|
123
139
|
*/
|
|
124
140
|
export declare function getCurrentSessionDir(projectRoot: string): Promise<string>;
|
|
125
141
|
/**
|
|
126
142
|
* List all session directories in the .peaks folder.
|
|
127
143
|
* Returns session IDs (directory names) sorted by date.
|
|
128
144
|
*
|
|
145
|
+
* As of slice 2026-06-06-session-layout-canonicalize the canonical
|
|
146
|
+
* home is `.peaks/_runtime/<sid>/`. The legacy top-level layout
|
|
147
|
+
* `.peaks/<sid>/` is read for back-compat (one minor release) so
|
|
148
|
+
* pre-migration trees keep working.
|
|
149
|
+
*
|
|
129
150
|
* @param projectRoot - Root directory of the project
|
|
130
151
|
* @returns Array of session IDs
|
|
131
152
|
*/
|