peaks-cli 1.3.1 → 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/bin/peaks.js +0 -0
- package/dist/src/cli/commands/core-artifact-commands.js +49 -11
- package/dist/src/cli/commands/slice-commands.js +4 -2
- package/dist/src/cli/commands/workspace-commands.js +67 -14
- package/dist/src/services/artifacts/artifact-prerequisites.d.ts +12 -0
- package/dist/src/services/artifacts/artifact-prerequisites.js +39 -8
- package/dist/src/services/artifacts/request-artifact-service.js +116 -76
- package/dist/src/services/doctor/doctor-service.d.ts +62 -0
- package/dist/src/services/doctor/doctor-service.js +276 -1
- package/dist/src/services/session/session-manager.d.ts +22 -1
- package/dist/src/services/session/session-manager.js +137 -28
- package/dist/src/services/slice/slice-check-service.js +20 -1
- package/dist/src/services/slice/slice-check-types.d.ts +9 -0
- package/dist/src/services/workspace/migrate-service.js +124 -2
- package/dist/src/services/workspace/migrate-types.d.ts +50 -7
- 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.js +29 -62
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +1 -1
- 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 +6 -0
- package/skills/peaks-txt/SKILL.md +2 -0
- package/skills/peaks-ui/SKILL.md +1 -0
|
@@ -18,6 +18,32 @@ export type CodegraphCapabilityProbe = {
|
|
|
18
18
|
binaryPath: string;
|
|
19
19
|
binaryExists: boolean;
|
|
20
20
|
};
|
|
21
|
+
export type DistVersionComparison = {
|
|
22
|
+
dist: string | null;
|
|
23
|
+
source: string;
|
|
24
|
+
match: boolean;
|
|
25
|
+
distReadable: boolean;
|
|
26
|
+
};
|
|
27
|
+
export type DistVersionProbe = () => DistVersionComparison;
|
|
28
|
+
export type WorkspaceLayoutInspection = {
|
|
29
|
+
topLevelSessionDirs: string[];
|
|
30
|
+
legacyDotfiles: string[];
|
|
31
|
+
/**
|
|
32
|
+
* Slice 007 — per-change-id top-level dirs (e.g. `.peaks/001-2026-06-06-.../`).
|
|
33
|
+
* The pre-F3 canonical layout put reviewable artifacts under a
|
|
34
|
+
* per-change-id top-level dir; the post-F3 canonical layout
|
|
35
|
+
* consolidates them under `.peaks/_runtime/<sid>/<role>/`. Any
|
|
36
|
+
* leftover per-change-id top-level dir is a regression to flag.
|
|
37
|
+
* Slice 008's migration will consolidate these; until then, the
|
|
38
|
+
* check reports them as `ok: false`.
|
|
39
|
+
*
|
|
40
|
+
* Optional in the type for back-compat with test probes that
|
|
41
|
+
* pre-date the slice 007 broadening; the check itself falls back
|
|
42
|
+
* to an empty array when the field is missing.
|
|
43
|
+
*/
|
|
44
|
+
perChangeIdDirs?: string[];
|
|
45
|
+
};
|
|
46
|
+
export type WorkspaceLayoutProbe = () => WorkspaceLayoutInspection;
|
|
21
47
|
export type DoctorOptions = {
|
|
22
48
|
schemasBaseDir?: string;
|
|
23
49
|
skillsBaseDir?: string;
|
|
@@ -29,6 +55,10 @@ export type DoctorOptions = {
|
|
|
29
55
|
workspaceInitializedProbe?: () => boolean;
|
|
30
56
|
/** Platform string (defaults to process.platform); injectable for tests. */
|
|
31
57
|
platform?: NodeJS.Platform;
|
|
58
|
+
/** Injected for the build:dist-version-matches-source check (defaults to compareDistVersion on disk). */
|
|
59
|
+
distVersionProbe?: DistVersionProbe;
|
|
60
|
+
/** Injected for the build:workspace-layout-canonical check (defaults to inspectWorkspaceLayout on disk). */
|
|
61
|
+
workspaceLayoutProbe?: WorkspaceLayoutProbe;
|
|
32
62
|
};
|
|
33
63
|
/**
|
|
34
64
|
* Pure helper extracted from `defaultWorkspaceInitializedProbe` so tests can
|
|
@@ -37,4 +67,36 @@ export type DoctorOptions = {
|
|
|
37
67
|
* `.peaks/_runtime/session.json` OR the legacy `.peaks/.session.json` exists.
|
|
38
68
|
*/
|
|
39
69
|
export declare function isWorkspaceInitializedAt(projectRoot: string): boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Pure helper that compares the published dist `CLI_VERSION` against the
|
|
72
|
+
* source-of-truth `package.json#version`. Default readers fail-soft to `null`
|
|
73
|
+
* on missing/unreadable/malformed input. Exported so tests can drive the
|
|
74
|
+
* filesystem reads without monkey-patching `process.cwd()`.
|
|
75
|
+
*/
|
|
76
|
+
export declare function compareDistVersion(opts: {
|
|
77
|
+
projectRoot: string;
|
|
78
|
+
distVersionReader?: (root: string) => string | null;
|
|
79
|
+
sourceVersionReader?: (root: string) => string | null;
|
|
80
|
+
}): DistVersionComparison;
|
|
81
|
+
/**
|
|
82
|
+
* Pure helper that inspects the on-disk workspace layout for
|
|
83
|
+
* post-F3-canonical violations. The post-F3 canonical layout puts
|
|
84
|
+
* session dirs under `.peaks/_runtime/<sid>/` and the runtime
|
|
85
|
+
* binding at `.peaks/_runtime/session.json`; the legacy paths
|
|
86
|
+
* (top-level `<YYYY-MM-DD-session-<hex>>/` dirs and the legacy
|
|
87
|
+
* top-level `.peaks/.session.json` / `.peaks/.active-skill.json`
|
|
88
|
+
* dotfiles) must be absent. This helper is exported so tests can
|
|
89
|
+
* drive the filesystem walk without monkey-patching `process.cwd()`
|
|
90
|
+
* or `findProjectRoot`.
|
|
91
|
+
*
|
|
92
|
+
* Both scanners fail-soft (return `[]` on read errors) so a flaky
|
|
93
|
+
* filesystem read on a non-fatal probe path never escalates into a
|
|
94
|
+
* doctor failure.
|
|
95
|
+
*/
|
|
96
|
+
export declare function inspectWorkspaceLayout(opts: {
|
|
97
|
+
projectRoot: string;
|
|
98
|
+
topLevelScanner?: (root: string) => string[];
|
|
99
|
+
dotfileScanner?: (root: string) => string[];
|
|
100
|
+
perChangeIdScanner?: (root: string) => string[];
|
|
101
|
+
}): WorkspaceLayoutInspection;
|
|
40
102
|
export declare function runDoctor(options?: DoctorOptions): Promise<DoctorReport>;
|
|
@@ -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';
|
|
@@ -68,6 +68,192 @@ export function isWorkspaceInitializedAt(projectRoot) {
|
|
|
68
68
|
return (existsSync(join(projectRoot, '.peaks', '_runtime', 'session.json')) ||
|
|
69
69
|
existsSync(join(projectRoot, '.peaks', '.session.json')));
|
|
70
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;
|
|
256
|
+
}
|
|
71
257
|
const DESTRUCTIVE_APPLY_PATTERNS = [
|
|
72
258
|
/peaks\s+memory\s+sync[^\n]*--apply/,
|
|
73
259
|
/peaks\s+memory\s+extract[^\n]*--apply/,
|
|
@@ -344,6 +530,95 @@ export async function runDoctor(options = {}) {
|
|
|
344
530
|
message: `@colbymchenry/codegraph not resolvable: ${getErrorMessage(error)}`
|
|
345
531
|
});
|
|
346
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
|
+
}
|
|
347
622
|
try {
|
|
348
623
|
const schemaText = await readText(join(schemaRoot, 'doctor-report.schema.json'));
|
|
349
624
|
const schema = JSON.parse(schemaText);
|
|
@@ -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
|
*/
|
|
@@ -180,9 +180,16 @@ function writeSessionFile(projectRoot, info) {
|
|
|
180
180
|
* no binding was present. The caller is expected to do something
|
|
181
181
|
* with that — at minimum surface it in the CLI response so the
|
|
182
182
|
* user can find the directory again if they need to.
|
|
183
|
+
*
|
|
184
|
+
* Slice 008 (F22 fix): the read uses the canonicalize-on-read
|
|
185
|
+
* variant so a binding written with `projectRoot: "."` (relative
|
|
186
|
+
* form, anchored from inside the project dir) is still found when
|
|
187
|
+
* the caller passes the absolute realpath. Pre-F22 the
|
|
188
|
+
* strict-equality read returned null in that case, and rotate
|
|
189
|
+
* silently no-op'd (the CLI reported "no prior binding").
|
|
183
190
|
*/
|
|
184
191
|
export function rotateSessionBinding(projectRoot) {
|
|
185
|
-
const previous =
|
|
192
|
+
const previous = readSessionFileCanonical(projectRoot);
|
|
186
193
|
if (previous === null) {
|
|
187
194
|
return null;
|
|
188
195
|
}
|
|
@@ -221,7 +228,14 @@ export function setCurrentSessionBinding(projectRoot, sessionId) {
|
|
|
221
228
|
return info;
|
|
222
229
|
}
|
|
223
230
|
function getMetaFilePath(projectRoot, sessionId) {
|
|
224
|
-
|
|
231
|
+
// As of slice 2026-06-06-session-layout-canonicalize, the per-session
|
|
232
|
+
// `session.json` (the file written by `setSessionMeta`) lives at the
|
|
233
|
+
// canonical runtime home `.peaks/_runtime/<sid>/session.json`, NOT
|
|
234
|
+
// at the top-level `.peaks/<sid>/session.json` (which would imply
|
|
235
|
+
// the legacy session-scoped layout and conflict with the workspace
|
|
236
|
+
// service's `_runtime/<sid>/` invariant). The migration in slice
|
|
237
|
+
// 003 moved any top-level meta files into the runtime home.
|
|
238
|
+
return join(projectRoot, '.peaks', '_runtime', sessionId, META_FILE);
|
|
225
239
|
}
|
|
226
240
|
function readSessionMeta(projectRoot, sessionId) {
|
|
227
241
|
const metaPath = getMetaFilePath(projectRoot, sessionId);
|
|
@@ -241,7 +255,9 @@ function readSessionMeta(projectRoot, sessionId) {
|
|
|
241
255
|
}
|
|
242
256
|
function writeSessionMeta(projectRoot, sessionId, meta) {
|
|
243
257
|
const metaPath = getMetaFilePath(projectRoot, sessionId);
|
|
244
|
-
|
|
258
|
+
// As of slice 003, the meta file lives at `.peaks/_runtime/<sid>/session.json`.
|
|
259
|
+
// The parent dir of that file is the canonical runtime session dir.
|
|
260
|
+
const metaDir = dirname(metaPath);
|
|
245
261
|
if (!existsSync(metaDir)) {
|
|
246
262
|
mkdirSync(metaDir, { recursive: true });
|
|
247
263
|
}
|
|
@@ -282,23 +298,67 @@ export function setSessionTitle(projectRoot, sessionId, title) {
|
|
|
282
298
|
/**
|
|
283
299
|
* List all session directories under .peaks with their metadata.
|
|
284
300
|
* Returns sessions sorted by sessionId descending (most recent first).
|
|
301
|
+
*
|
|
302
|
+
* As of slice 2026-06-06-session-layout-canonicalize the session
|
|
303
|
+
* dirs live at the canonical runtime home `.peaks/_runtime/<sid>/`.
|
|
304
|
+
* The legacy top-level layout is read for back-compat (one minor
|
|
305
|
+
* release) but is not authoritative.
|
|
285
306
|
*/
|
|
286
307
|
export function listSessionMetas(projectRoot) {
|
|
308
|
+
const runtimeRoot = join(projectRoot, '.peaks', '_runtime');
|
|
287
309
|
const peaksRoot = join(projectRoot, '.peaks');
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
310
|
+
const seen = new Set();
|
|
311
|
+
const result = [];
|
|
312
|
+
const collect = (root) => {
|
|
313
|
+
if (!existsSync(root))
|
|
314
|
+
return;
|
|
315
|
+
const names = [];
|
|
316
|
+
try {
|
|
317
|
+
const out = readdirSync(root, { withFileTypes: true });
|
|
318
|
+
for (const e of out) {
|
|
319
|
+
if (e.isDirectory())
|
|
320
|
+
names.push(e.name);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
for (const name of names) {
|
|
327
|
+
if (!/^\d{4}-\d{2}-\d{2}-session-[a-f0-9]+$/.test(name))
|
|
328
|
+
continue;
|
|
329
|
+
if (seen.has(name))
|
|
330
|
+
continue;
|
|
331
|
+
seen.add(name);
|
|
332
|
+
const meta = readSessionMeta(projectRoot, name);
|
|
333
|
+
result.push(meta ?? { sessionId: name, projectRoot, createdAt: '' });
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
// Canonical home first, then the legacy top-level (back-compat).
|
|
337
|
+
collect(runtimeRoot);
|
|
338
|
+
collect(peaksRoot);
|
|
339
|
+
result.sort((a, b) => b.sessionId.localeCompare(a.sessionId));
|
|
340
|
+
return result;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Back-compat read for the legacy top-level meta file (the one that
|
|
344
|
+
* pre-slice 003 trees still have at `.peaks/<sid>/session.json`).
|
|
345
|
+
* Kept as a separate helper so the canonical reader is the default.
|
|
346
|
+
*/
|
|
347
|
+
function readSessionMetaCompat(peaksRoot, sessionId) {
|
|
348
|
+
const metaPath = join(peaksRoot, sessionId, META_FILE);
|
|
349
|
+
if (!existsSync(metaPath))
|
|
350
|
+
return null;
|
|
351
|
+
try {
|
|
352
|
+
const raw = readFileSync(metaPath, 'utf8');
|
|
353
|
+
const parsed = JSON.parse(raw);
|
|
354
|
+
if (typeof parsed?.sessionId !== 'string' || parsed.sessionId.length === 0) {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
return parsed;
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
302
362
|
}
|
|
303
363
|
/**
|
|
304
364
|
* Get or create the current session for a project.
|
|
@@ -322,6 +382,26 @@ export async function ensureSession(projectRoot) {
|
|
|
322
382
|
if (existing) {
|
|
323
383
|
return existing.sessionId;
|
|
324
384
|
}
|
|
385
|
+
// Slice 007 — sub-agent session sharing. When the strict-equality
|
|
386
|
+
// read returns null (e.g. the binding was written with the relative
|
|
387
|
+
// form "." from inside the project dir, but the caller passes the
|
|
388
|
+
// absolute realpath), fall through to the canonical-fallback read.
|
|
389
|
+
// `ensureSession` is a session-creating primitive — its caller
|
|
390
|
+
// wants the existing binding if one exists, even if the projectRoot
|
|
391
|
+
// forms differ. Without this fallback, a sub-agent that anchors via
|
|
392
|
+
// `cd <repo> && peaks skill presence:set` and then runs
|
|
393
|
+
// `peaks request init --project <abs-path>` would auto-generate a
|
|
394
|
+
// new session and create an orphan dir.
|
|
395
|
+
//
|
|
396
|
+
// The strict-equality read is preserved for other modules
|
|
397
|
+
// (notably `shared/change-id.ts` via `buildArtifactRelativePath`)
|
|
398
|
+
// that depend on the "no session bound" code path — switching the
|
|
399
|
+
// default would cascade into ~30 test failures in those modules.
|
|
400
|
+
// The canonical-fallback is opt-in for `ensureSession` only.
|
|
401
|
+
const canonical = getSessionIdCanonical(projectRoot);
|
|
402
|
+
if (canonical !== null) {
|
|
403
|
+
return canonical;
|
|
404
|
+
}
|
|
325
405
|
const sessionId = generateSessionId();
|
|
326
406
|
const now = new Date().toISOString();
|
|
327
407
|
const info = {
|
|
@@ -387,31 +467,60 @@ export function getSessionIdCanonical(projectRoot) {
|
|
|
387
467
|
* Get the absolute path to the current session directory.
|
|
388
468
|
* Creates the session if it doesn't exist.
|
|
389
469
|
*
|
|
470
|
+
* As of slice 2026-06-06-session-layout-canonicalize the canonical
|
|
471
|
+
* home is `.peaks/_runtime/<sid>/`. The legacy top-level layout
|
|
472
|
+
* `.peaks/<sid>/` is the back-compat read fallback only.
|
|
473
|
+
*
|
|
390
474
|
* @param projectRoot - Root directory of the project
|
|
391
|
-
* @returns Absolute path to session directory (e.g., "/path/to/project/.peaks/2026-05-26-session-a3f8b1")
|
|
475
|
+
* @returns Absolute path to session directory (e.g., "/path/to/project/.peaks/_runtime/2026-05-26-session-a3f8b1")
|
|
392
476
|
*/
|
|
393
477
|
export async function getCurrentSessionDir(projectRoot) {
|
|
394
478
|
const sessionId = await ensureSession(projectRoot);
|
|
395
|
-
return join(projectRoot, '.peaks', sessionId);
|
|
479
|
+
return join(projectRoot, '.peaks', '_runtime', sessionId);
|
|
396
480
|
}
|
|
397
481
|
/**
|
|
398
482
|
* List all session directories in the .peaks folder.
|
|
399
483
|
* Returns session IDs (directory names) sorted by date.
|
|
400
484
|
*
|
|
485
|
+
* As of slice 2026-06-06-session-layout-canonicalize the canonical
|
|
486
|
+
* home is `.peaks/_runtime/<sid>/`. The legacy top-level layout
|
|
487
|
+
* `.peaks/<sid>/` is read for back-compat (one minor release) so
|
|
488
|
+
* pre-migration trees keep working.
|
|
489
|
+
*
|
|
401
490
|
* @param projectRoot - Root directory of the project
|
|
402
491
|
* @returns Array of session IDs
|
|
403
492
|
*/
|
|
404
493
|
export function listSessions(projectRoot) {
|
|
494
|
+
const runtimeRoot = join(projectRoot, '.peaks', '_runtime');
|
|
405
495
|
const peaksRoot = join(projectRoot, '.peaks');
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
496
|
+
const seen = new Set();
|
|
497
|
+
const result = [];
|
|
498
|
+
const collect = (root) => {
|
|
499
|
+
if (!existsSync(root))
|
|
500
|
+
return;
|
|
501
|
+
const names = [];
|
|
502
|
+
try {
|
|
503
|
+
const out = readdirSync(root, { withFileTypes: true });
|
|
504
|
+
for (const e of out) {
|
|
505
|
+
if (e.isDirectory())
|
|
506
|
+
names.push(e.name);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
for (const name of names) {
|
|
513
|
+
if (!/^\d{4}-\d{2}-\d{2}-session-[a-f0-9]+$/.test(name))
|
|
514
|
+
continue;
|
|
515
|
+
if (seen.has(name))
|
|
516
|
+
continue;
|
|
517
|
+
seen.add(name);
|
|
518
|
+
result.push(name);
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
collect(runtimeRoot);
|
|
522
|
+
collect(peaksRoot);
|
|
523
|
+
return result.sort().reverse();
|
|
415
524
|
}
|
|
416
525
|
/**
|
|
417
526
|
* Get the path to project-scan.md for the current session.
|
|
@@ -210,7 +210,26 @@ export async function sliceCheck(options) {
|
|
|
210
210
|
stages.push(await runTypecheck(options.projectRoot));
|
|
211
211
|
// Stage 2: full vitest
|
|
212
212
|
if (!options.skipTests) {
|
|
213
|
-
|
|
213
|
+
const unitTests = await runUnitTests(options.projectRoot);
|
|
214
|
+
// Opt-in override: if --allow-pre-existing-failures is set AND the
|
|
215
|
+
// unit-test stage failed, downgrade `failed` to `skipped` with a
|
|
216
|
+
// reason that names the failure count and points to the long-term
|
|
217
|
+
// fix. Does NOT affect the other 3 stages.
|
|
218
|
+
if (options.allowPreExistingFailures === true &&
|
|
219
|
+
unitTests.status === 'fail') {
|
|
220
|
+
const failureCount = unitTests.data?.failed ?? 0;
|
|
221
|
+
stages.push({
|
|
222
|
+
name: 'unit-tests',
|
|
223
|
+
description: 'npx vitest run (overridden via --allow-pre-existing-failures)',
|
|
224
|
+
status: 'skipped',
|
|
225
|
+
durationMs: unitTests.durationMs,
|
|
226
|
+
detail: `pre-existing failures: ${failureCount} failing test(s) under coverage.exclude or unrelated to this slice; user opted in via --allow-pre-existing-failures. For the long-term fix, mark these tests .skip or move to coverage.exclude (see dogfood-2-f1-f4.md F17c).`,
|
|
227
|
+
data: { ...(unitTests.data ?? {}), overriddenFrom: 'fail', failureCount }
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
stages.push(unitTests);
|
|
232
|
+
}
|
|
214
233
|
}
|
|
215
234
|
else {
|
|
216
235
|
stages.push({
|