peaks-cli 1.2.9 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +62 -46
  2. package/bin/peaks.js +0 -0
  3. package/dist/src/cli/commands/hooks-commands.js +24 -9
  4. package/dist/src/cli/commands/progress-commands.js +26 -2
  5. package/dist/src/cli/commands/request-commands.js +5 -0
  6. package/dist/src/cli/commands/slice-commands.d.ts +3 -0
  7. package/dist/src/cli/commands/slice-commands.js +42 -0
  8. package/dist/src/cli/commands/workflow-commands.js +3 -3
  9. package/dist/src/cli/commands/workspace-commands.d.ts +63 -0
  10. package/dist/src/cli/commands/workspace-commands.js +347 -5
  11. package/dist/src/cli/program.js +4 -0
  12. package/dist/src/services/artifacts/artifact-prerequisites.d.ts +17 -1
  13. package/dist/src/services/artifacts/artifact-prerequisites.js +38 -5
  14. package/dist/src/services/artifacts/request-artifact-service.d.ts +22 -0
  15. package/dist/src/services/artifacts/request-artifact-service.js +172 -54
  16. package/dist/src/services/doctor/doctor-service.d.ts +7 -0
  17. package/dist/src/services/doctor/doctor-service.js +20 -2
  18. package/dist/src/services/progress/progress-service.d.ts +26 -0
  19. package/dist/src/services/progress/progress-service.js +25 -0
  20. package/dist/src/services/sc/sc-service.d.ts +52 -1
  21. package/dist/src/services/sc/sc-service.js +324 -17
  22. package/dist/src/services/scan/acceptance-coverage-service.js +6 -2
  23. package/dist/src/services/session/session-manager.d.ts +7 -5
  24. package/dist/src/services/session/session-manager.js +60 -16
  25. package/dist/src/services/skills/hooks-settings-service.d.ts +25 -3
  26. package/dist/src/services/skills/hooks-settings-service.js +57 -13
  27. package/dist/src/services/skills/skill-presence-service.js +102 -68
  28. package/dist/src/services/skills/skill-runbook-service.js +2 -1
  29. package/dist/src/services/skills/skill-statusline-service.js +13 -7
  30. package/dist/src/services/slice/slice-check-service.d.ts +2 -0
  31. package/dist/src/services/slice/slice-check-service.js +248 -0
  32. package/dist/src/services/slice/slice-check-types.d.ts +61 -0
  33. package/dist/src/services/slice/slice-check-types.js +18 -0
  34. package/dist/src/services/workflow/pipeline-verify-service.d.ts +5 -2
  35. package/dist/src/services/workflow/pipeline-verify-service.js +35 -35
  36. package/dist/src/services/workspace/migrate-service.d.ts +2 -0
  37. package/dist/src/services/workspace/migrate-service.js +484 -0
  38. package/dist/src/services/workspace/migrate-types.d.ts +84 -0
  39. package/dist/src/services/workspace/migrate-types.js +21 -0
  40. package/dist/src/services/workspace/reconcile-service.d.ts +119 -0
  41. package/dist/src/services/workspace/reconcile-service.js +464 -0
  42. package/dist/src/services/workspace/reconcile-types.d.ts +93 -0
  43. package/dist/src/services/workspace/reconcile-types.js +13 -0
  44. package/dist/src/services/workspace/workspace-service.d.ts +11 -0
  45. package/dist/src/services/workspace/workspace-service.js +87 -7
  46. package/dist/src/shared/change-id.d.ts +59 -0
  47. package/dist/src/shared/change-id.js +194 -16
  48. package/dist/src/shared/version.d.ts +1 -1
  49. package/dist/src/shared/version.js +1 -1
  50. package/package.json +13 -2
  51. package/skills/peaks-solo/SKILL.md +28 -4
  52. package/skills/peaks-solo/references/micro-cycle.md +155 -0
  53. package/skills/peaks-solo/references/runbook.md +2 -0
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Reconcile service for `.peaks/2026-MM-DD-session-<6hex>/` directories.
3
+ *
4
+ * Reconcile scans the project root's `.peaks/` directory, identifies a
5
+ * canonical session via a 4-tier heuristic, re-points
6
+ * `.peaks/_runtime/session.json` (the canonical new home of the
7
+ * binding; legacy `.peaks/.session.json` is read-only back-compat),
8
+ * and (optionally, with apply === true) deletes empty / abandoned
9
+ * session dirs older than olderThanMs.
10
+ *
11
+ * As of slice 2026-06-05-peaks-runtime-layer the top-level orchestrator
12
+ * also runs `migrateOldRuntimeState` at the start so pre-migration
13
+ * trees have their `.peaks/.session.json` / `.peaks/.active-skill.json`
14
+ * / `.peaks/sop-state/` files moved into `.peaks/_runtime/`.
15
+ *
16
+ * Pure hand-rolled; uses only node:fs, node:path, and the existing
17
+ * session-manager helper for writing the binding. No new dependencies.
18
+ */
19
+ import type { ReconcileOptions, ReconcileResult, SessionEntry } from './reconcile-types.js';
20
+ /**
21
+ * Walk the project root's `.peaks/` directory and return an entry per
22
+ * session dir matching the standard naming pattern, sorted by name
23
+ * ascending (the most recent is last by sort order, since the date
24
+ * prefix dominates the lexicographic order).
25
+ *
26
+ * Each entry's `lastActivity` is the mtime of the inner `session.json`
27
+ * file, or null if that file is missing. `artifactCount` is the count
28
+ * of files under the dir excluding `session.json` itself.
29
+ */
30
+ export declare function discoverSessions(projectRoot: string): SessionEntry[];
31
+ /**
32
+ * 4-tier canonical selection. Tiers evaluated in order; first one that
33
+ * yields a session id wins.
34
+ *
35
+ * 1. active-skill sessionId, if it matches a real entry
36
+ * 2. entry with the most recent `session.json` mtime
37
+ * 3. entry with the most recent mtime of any file inside it
38
+ * 4. entry whose dir name sorts last lexicographically
39
+ */
40
+ export declare function pickCanonicalSession(entries: SessionEntry[], activeSkillSessionId: string | null): {
41
+ sessionId: string;
42
+ source: ReconcileResult['canonicalSource'];
43
+ } | null;
44
+ /**
45
+ * Write `.peaks/.session.json` to bind the project to `canonicalSessionId`.
46
+ * Preserves the projectRoot. The previous binding is returned so the CLI
47
+ * can surface the re-point delta.
48
+ */
49
+ export declare function repointSessionJson(projectRoot: string, canonicalSessionId: string, repointedFrom: string | null): {
50
+ repointedFrom: string | null;
51
+ repointedTo: string;
52
+ };
53
+ /**
54
+ * Identify deletion candidates. A session is a candidate when:
55
+ * - the resolved `lastActivity` is older than `ageThresholdMs`, AND
56
+ * - the dir is "empty or auto-only" (artifactCount === 0, OR the
57
+ * only file is `session.json` which is auto-generated).
58
+ *
59
+ * If `lastActivity` is null (no `session.json` inside), the session's
60
+ * own dir mtime is used as a fallback so empty dirs without inner
61
+ * metadata are still fair-game.
62
+ */
63
+ export declare function findDeletionCandidates(entries: SessionEntry[], ageThresholdMs: number): SessionEntry[];
64
+ /**
65
+ * Apply or report deletion of the given candidates. When `apply` is
66
+ * false, just return `wouldDelete` and do not touch disk. When `apply`
67
+ * is true, actually `rm -rf` each dir and accumulate any per-dir
68
+ * errors in the result.
69
+ */
70
+ export declare function applyDeletions(candidates: SessionEntry[], apply: boolean): {
71
+ deleted: string[];
72
+ wouldDelete: string[];
73
+ errors: Array<{
74
+ sessionId: string;
75
+ message: string;
76
+ }>;
77
+ };
78
+ /**
79
+ * One-time migration step (added in slice 2026-06-05-peaks-runtime-layer).
80
+ *
81
+ * Move the legacy runtime files at:
82
+ * - `.peaks/.session.json`
83
+ * - `.peaks/.active-skill.json`
84
+ * - `.peaks/sop-state/`
85
+ * into their new canonical home at:
86
+ * - `.peaks/_runtime/session.json`
87
+ * - `.peaks/_runtime/active-skill.json`
88
+ * - `.peaks/_runtime/sop-state/`
89
+ *
90
+ * Behavior:
91
+ * - Idempotent: re-running on a tree that is already on the new
92
+ * layout produces `migratedFiles: []`.
93
+ * - Best-effort: uses `fs.renameSync` (atomic on POSIX, best-effort
94
+ * on Windows) and falls back to `copyFileSync` + `unlinkSync` if
95
+ * rename throws (e.g. cross-device move on Windows). Errors are
96
+ * collected per file and returned in the `errors` array so the
97
+ * reconcile envelope can surface them without blocking the rest of
98
+ * the migration.
99
+ * - Creates `.peaks/_runtime/` on demand if any of the old paths
100
+ * are present.
101
+ *
102
+ * @returns `{ migratedFiles, errors }`. `migratedFiles` lists the
103
+ * *old* relative paths (e.g. `.peaks/.session.json`) that were
104
+ * successfully moved, in move order. `errors` lists per-file
105
+ * failures with the old path and a human-readable message.
106
+ */
107
+ export declare function migrateOldRuntimeState(projectRoot: string): {
108
+ migratedFiles: string[];
109
+ errors: Array<{
110
+ path: string;
111
+ message: string;
112
+ }>;
113
+ };
114
+ /**
115
+ * Top-level orchestrator. Wires migration (added in slice
116
+ * 2026-06-05-peaks-runtime-layer), discovery, canonical pick, re-point,
117
+ * deletion-candidate selection, and deletion into a single result.
118
+ */
119
+ export declare function reconcileWorkspace(options: ReconcileOptions): ReconcileResult;
@@ -0,0 +1,464 @@
1
+ /**
2
+ * Reconcile service for `.peaks/2026-MM-DD-session-<6hex>/` directories.
3
+ *
4
+ * Reconcile scans the project root's `.peaks/` directory, identifies a
5
+ * canonical session via a 4-tier heuristic, re-points
6
+ * `.peaks/_runtime/session.json` (the canonical new home of the
7
+ * binding; legacy `.peaks/.session.json` is read-only back-compat),
8
+ * and (optionally, with apply === true) deletes empty / abandoned
9
+ * session dirs older than olderThanMs.
10
+ *
11
+ * As of slice 2026-06-05-peaks-runtime-layer the top-level orchestrator
12
+ * also runs `migrateOldRuntimeState` at the start so pre-migration
13
+ * trees have their `.peaks/.session.json` / `.peaks/.active-skill.json`
14
+ * / `.peaks/sop-state/` files moved into `.peaks/_runtime/`.
15
+ *
16
+ * Pure hand-rolled; uses only node:fs, node:path, and the existing
17
+ * session-manager helper for writing the binding. No new dependencies.
18
+ */
19
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, unlinkSync } from 'node:fs';
20
+ import { dirname, join, resolve } from 'node:path';
21
+ import { getSessionIdCanonical, setCurrentSessionBinding } from '../session/session-manager.js';
22
+ const SESSION_ID_PATTERN = /^\d{4}-\d{2}-\d{2}-session-[a-f0-9]+$/;
23
+ const META_FILE = 'session.json';
24
+ // As of slice 2026-06-05-peaks-runtime-layer these old paths are the
25
+ // back-compat read-only fallbacks; the canonical new home is
26
+ // `.peaks/_runtime/`. `migrateOldRuntimeState` moves them to the new
27
+ // location on disk. The leading dot is dropped when computing the
28
+ // new basename (e.g. `.session.json` → `session.json`), so the new
29
+ // layout is `.peaks/_runtime/{session.json,active-skill.json,sop-state/}`.
30
+ const RUNTIME_OLD_PATHS = [
31
+ '.session.json',
32
+ '.active-skill.json',
33
+ 'sop-state'
34
+ ];
35
+ const RUNTIME_DIR = join('.peaks', '_runtime');
36
+ /**
37
+ * Map a legacy path basename (e.g. `.session.json`) to its canonical
38
+ * new basename (e.g. `session.json`). The dot is dropped so the new
39
+ * layer reads naturally. Directories pass through unchanged.
40
+ */
41
+ function runtimeNewBasename(oldBasename) {
42
+ if (oldBasename.startsWith('.') && oldBasename.length > 1) {
43
+ return oldBasename.slice(1);
44
+ }
45
+ return oldBasename;
46
+ }
47
+ /**
48
+ * Walk the project root's `.peaks/` directory and return an entry per
49
+ * session dir matching the standard naming pattern, sorted by name
50
+ * ascending (the most recent is last by sort order, since the date
51
+ * prefix dominates the lexicographic order).
52
+ *
53
+ * Each entry's `lastActivity` is the mtime of the inner `session.json`
54
+ * file, or null if that file is missing. `artifactCount` is the count
55
+ * of files under the dir excluding `session.json` itself.
56
+ */
57
+ export function discoverSessions(projectRoot) {
58
+ const peaksRoot = join(projectRoot, '.peaks');
59
+ if (!existsSync(peaksRoot))
60
+ return [];
61
+ let names;
62
+ try {
63
+ names = readdirSync(peaksRoot);
64
+ }
65
+ catch {
66
+ return [];
67
+ }
68
+ const entries = [];
69
+ for (const name of names) {
70
+ if (!SESSION_ID_PATTERN.test(name))
71
+ continue;
72
+ const dir = join(peaksRoot, name);
73
+ let stat;
74
+ try {
75
+ // lstatSync: false for symlinks (prevents rm -rf from following a
76
+ // malicious symlink that points outside the project root).
77
+ stat = lstatSync(dir);
78
+ }
79
+ catch {
80
+ continue;
81
+ }
82
+ if (!stat.isDirectory())
83
+ continue;
84
+ const metaPath = join(dir, META_FILE);
85
+ let lastActivity = null;
86
+ if (existsSync(metaPath)) {
87
+ try {
88
+ lastActivity = statSync(metaPath).mtimeMs;
89
+ }
90
+ catch {
91
+ lastActivity = null;
92
+ }
93
+ }
94
+ let childNames;
95
+ try {
96
+ childNames = readdirSync(dir);
97
+ }
98
+ catch {
99
+ childNames = [];
100
+ }
101
+ let artifactCount = 0;
102
+ for (const child of childNames) {
103
+ if (child === META_FILE)
104
+ continue;
105
+ artifactCount += 1;
106
+ }
107
+ entries.push({ sessionId: name, path: dir, lastActivity, artifactCount });
108
+ }
109
+ entries.sort((a, b) => a.sessionId.localeCompare(b.sessionId));
110
+ return entries;
111
+ }
112
+ /**
113
+ * 4-tier canonical selection. Tiers evaluated in order; first one that
114
+ * yields a session id wins.
115
+ *
116
+ * 1. active-skill sessionId, if it matches a real entry
117
+ * 2. entry with the most recent `session.json` mtime
118
+ * 3. entry with the most recent mtime of any file inside it
119
+ * 4. entry whose dir name sorts last lexicographically
120
+ */
121
+ export function pickCanonicalSession(entries, activeSkillSessionId) {
122
+ if (entries.length === 0)
123
+ return null;
124
+ // Tier 1
125
+ if (activeSkillSessionId !== null) {
126
+ const hit = entries.find((e) => e.sessionId === activeSkillSessionId);
127
+ if (hit !== undefined) {
128
+ return { sessionId: hit.sessionId, source: 'active-skill' };
129
+ }
130
+ }
131
+ // Tier 2
132
+ let tier2Best = null;
133
+ for (const e of entries) {
134
+ if (e.lastActivity === null)
135
+ continue;
136
+ if (tier2Best === null || e.lastActivity > tier2Best.lastActivity) {
137
+ tier2Best = { sessionId: e.sessionId, lastActivity: e.lastActivity };
138
+ }
139
+ }
140
+ if (tier2Best !== null) {
141
+ return { sessionId: tier2Best.sessionId, source: 'latest-session-json-mtime' };
142
+ }
143
+ // Tier 3
144
+ let tier3Best = null;
145
+ let tier3Mtime = -Infinity;
146
+ for (const e of entries) {
147
+ const mtime = newestMtimeRecursive(e.path);
148
+ if (mtime === null)
149
+ continue;
150
+ if (mtime > tier3Mtime) {
151
+ tier3Mtime = mtime;
152
+ tier3Best = { sessionId: e.sessionId, path: e.path };
153
+ }
154
+ }
155
+ if (tier3Best !== null) {
156
+ return { sessionId: tier3Best.sessionId, source: 'latest-any-file-mtime' };
157
+ }
158
+ // Tier 4
159
+ const sortedAsc = [...entries].sort((a, b) => a.sessionId.localeCompare(b.sessionId));
160
+ const last = sortedAsc[sortedAsc.length - 1];
161
+ if (last !== undefined) {
162
+ return { sessionId: last.sessionId, source: 'dir-name-sort' };
163
+ }
164
+ return null;
165
+ }
166
+ function newestMtimeRecursive(dirPath) {
167
+ let best = null;
168
+ let stack = [dirPath];
169
+ while (stack.length > 0) {
170
+ const current = stack.pop();
171
+ let names;
172
+ try {
173
+ names = readdirSync(current);
174
+ }
175
+ catch {
176
+ continue;
177
+ }
178
+ for (const name of names) {
179
+ const childPath = join(current, name);
180
+ let stat;
181
+ try {
182
+ stat = statSync(childPath);
183
+ }
184
+ catch {
185
+ continue;
186
+ }
187
+ if (stat.isDirectory()) {
188
+ stack.push(childPath);
189
+ continue;
190
+ }
191
+ if (stat.mtimeMs > (best ?? -Infinity)) {
192
+ best = stat.mtimeMs;
193
+ }
194
+ }
195
+ }
196
+ return best;
197
+ }
198
+ /**
199
+ * Write `.peaks/.session.json` to bind the project to `canonicalSessionId`.
200
+ * Preserves the projectRoot. The previous binding is returned so the CLI
201
+ * can surface the re-point delta.
202
+ */
203
+ export function repointSessionJson(projectRoot, canonicalSessionId, repointedFrom) {
204
+ setCurrentSessionBinding(projectRoot, canonicalSessionId);
205
+ return { repointedFrom, repointedTo: canonicalSessionId };
206
+ }
207
+ /**
208
+ * Identify deletion candidates. A session is a candidate when:
209
+ * - the resolved `lastActivity` is older than `ageThresholdMs`, AND
210
+ * - the dir is "empty or auto-only" (artifactCount === 0, OR the
211
+ * only file is `session.json` which is auto-generated).
212
+ *
213
+ * If `lastActivity` is null (no `session.json` inside), the session's
214
+ * own dir mtime is used as a fallback so empty dirs without inner
215
+ * metadata are still fair-game.
216
+ */
217
+ export function findDeletionCandidates(entries, ageThresholdMs) {
218
+ const now = Date.now();
219
+ const candidates = [];
220
+ for (const e of entries) {
221
+ const isEmptyOrAutoOnly = e.artifactCount === 0;
222
+ if (!isEmptyOrAutoOnly)
223
+ continue;
224
+ const mtime = e.lastActivity !== null
225
+ ? e.lastActivity
226
+ : readDirMtime(e.path);
227
+ if (mtime === null)
228
+ continue;
229
+ if (now - mtime < ageThresholdMs)
230
+ continue;
231
+ candidates.push(e);
232
+ }
233
+ return candidates;
234
+ }
235
+ function readDirMtime(dirPath) {
236
+ try {
237
+ return statSync(dirPath).mtimeMs;
238
+ }
239
+ catch {
240
+ return null;
241
+ }
242
+ }
243
+ /**
244
+ * Apply or report deletion of the given candidates. When `apply` is
245
+ * false, just return `wouldDelete` and do not touch disk. When `apply`
246
+ * is true, actually `rm -rf` each dir and accumulate any per-dir
247
+ * errors in the result.
248
+ */
249
+ export function applyDeletions(candidates, apply) {
250
+ if (!apply) {
251
+ return {
252
+ deleted: [],
253
+ wouldDelete: candidates.map((c) => c.sessionId),
254
+ errors: []
255
+ };
256
+ }
257
+ const deleted = [];
258
+ const errors = [];
259
+ for (const c of candidates) {
260
+ try {
261
+ rmSync(c.path, { recursive: true, force: true });
262
+ deleted.push(c.sessionId);
263
+ }
264
+ catch (error) {
265
+ errors.push({
266
+ sessionId: c.sessionId,
267
+ message: error instanceof Error ? error.message : String(error)
268
+ });
269
+ }
270
+ }
271
+ return { deleted, wouldDelete: [], errors };
272
+ }
273
+ /**
274
+ * Read the orchestrator's active-skill marker and extract the
275
+ * session id. As of slice 2026-06-05-peaks-runtime-layer the
276
+ * canonical home is `.peaks/_runtime/active-skill.json`; the legacy
277
+ * `.peaks/.active-skill.json` is consulted as a one-minor-release
278
+ * back-compat fallback (the new path wins when both exist).
279
+ *
280
+ * Returns null when the file is missing or malformed.
281
+ */
282
+ function readActiveSkillSessionId(projectRoot) {
283
+ const newPath = join(projectRoot, '.peaks', '_runtime', 'active-skill.json');
284
+ const legacyPath = join(projectRoot, '.peaks', '.active-skill.json');
285
+ const pathToRead = existsSync(newPath) ? newPath : legacyPath;
286
+ if (!existsSync(pathToRead))
287
+ return null;
288
+ try {
289
+ // Sync read: tiny file, no I/O benefit from async
290
+ const { readFileSync } = require('node:fs');
291
+ const raw = readFileSync(pathToRead, 'utf8');
292
+ const parsed = JSON.parse(raw);
293
+ if (typeof parsed?.sessionId === 'string' && parsed.sessionId.length > 0) {
294
+ return parsed.sessionId;
295
+ }
296
+ }
297
+ catch {
298
+ return null;
299
+ }
300
+ return null;
301
+ }
302
+ /**
303
+ * One-time migration step (added in slice 2026-06-05-peaks-runtime-layer).
304
+ *
305
+ * Move the legacy runtime files at:
306
+ * - `.peaks/.session.json`
307
+ * - `.peaks/.active-skill.json`
308
+ * - `.peaks/sop-state/`
309
+ * into their new canonical home at:
310
+ * - `.peaks/_runtime/session.json`
311
+ * - `.peaks/_runtime/active-skill.json`
312
+ * - `.peaks/_runtime/sop-state/`
313
+ *
314
+ * Behavior:
315
+ * - Idempotent: re-running on a tree that is already on the new
316
+ * layout produces `migratedFiles: []`.
317
+ * - Best-effort: uses `fs.renameSync` (atomic on POSIX, best-effort
318
+ * on Windows) and falls back to `copyFileSync` + `unlinkSync` if
319
+ * rename throws (e.g. cross-device move on Windows). Errors are
320
+ * collected per file and returned in the `errors` array so the
321
+ * reconcile envelope can surface them without blocking the rest of
322
+ * the migration.
323
+ * - Creates `.peaks/_runtime/` on demand if any of the old paths
324
+ * are present.
325
+ *
326
+ * @returns `{ migratedFiles, errors }`. `migratedFiles` lists the
327
+ * *old* relative paths (e.g. `.peaks/.session.json`) that were
328
+ * successfully moved, in move order. `errors` lists per-file
329
+ * failures with the old path and a human-readable message.
330
+ */
331
+ export function migrateOldRuntimeState(projectRoot) {
332
+ const root = resolve(projectRoot);
333
+ const peaksRoot = join(root, '.peaks');
334
+ const newDir = join(root, RUNTIME_DIR);
335
+ const migratedFiles = [];
336
+ const errors = [];
337
+ for (const rel of RUNTIME_OLD_PATHS) {
338
+ const oldPath = join(peaksRoot, rel);
339
+ if (!existsSync(oldPath))
340
+ continue;
341
+ // Skip if the corresponding new path already exists — we treat the
342
+ // new path as authoritative when both exist, so the old file would
343
+ // only be stale data.
344
+ const newPath = join(newDir, runtimeNewBasename(rel));
345
+ if (existsSync(newPath)) {
346
+ // Best-effort cleanup of the stale old file so a re-run stays
347
+ // idempotent and the tree converges on the new layout.
348
+ try {
349
+ rmSync(oldPath, { recursive: true, force: true });
350
+ }
351
+ catch (error) {
352
+ errors.push({
353
+ path: rel,
354
+ message: `Could not remove stale legacy file after migration: ${error instanceof Error ? error.message : String(error)}`
355
+ });
356
+ }
357
+ continue;
358
+ }
359
+ try {
360
+ // Ensure the new parent dir exists. `mkdirSync(dirname(newPath), { recursive: true })`
361
+ // covers both the file case (`.peaks/_runtime`) and the
362
+ // directory case (`.peaks/_runtime/sop-state`).
363
+ mkdirSync(dirname(newPath), { recursive: true });
364
+ try {
365
+ renameSync(oldPath, newPath);
366
+ }
367
+ catch (renameError) {
368
+ // Cross-device or locked-file fallback: copy + unlink.
369
+ const stat = lstatSync(oldPath);
370
+ if (stat.isDirectory()) {
371
+ // Recursive copy for the sop-state dir.
372
+ copyDirRecursiveSync(oldPath, newPath);
373
+ rmSync(oldPath, { recursive: true, force: true });
374
+ }
375
+ else {
376
+ copyFileSync(oldPath, newPath);
377
+ unlinkSync(oldPath);
378
+ }
379
+ }
380
+ migratedFiles.push(join('.peaks', rel));
381
+ }
382
+ catch (error) {
383
+ errors.push({
384
+ path: rel,
385
+ message: error instanceof Error ? error.message : String(error)
386
+ });
387
+ }
388
+ }
389
+ return { migratedFiles, errors };
390
+ }
391
+ function copyDirRecursiveSync(src, dest) {
392
+ mkdirSync(dest, { recursive: true });
393
+ for (const name of readdirSync(src)) {
394
+ const childSrc = join(src, name);
395
+ const childDest = join(dest, name);
396
+ const stat = lstatSync(childSrc);
397
+ if (stat.isDirectory()) {
398
+ copyDirRecursiveSync(childSrc, childDest);
399
+ }
400
+ else {
401
+ copyFileSync(childSrc, childDest);
402
+ }
403
+ }
404
+ }
405
+ /**
406
+ * Top-level orchestrator. Wires migration (added in slice
407
+ * 2026-06-05-peaks-runtime-layer), discovery, canonical pick, re-point,
408
+ * deletion-candidate selection, and deletion into a single result.
409
+ */
410
+ export function reconcileWorkspace(options) {
411
+ const projectRoot = resolve(options.projectRoot);
412
+ const apply = options.apply === true;
413
+ const ageThresholdMs = options.olderThanMs;
414
+ // Migration runs FIRST. The canonical-session logic still consults
415
+ // the session-manager helper which already reads the new path first
416
+ // and falls back to the old path; moving the old file out of the way
417
+ // before that read means the new path is the only path observed by
418
+ // `getSessionIdCanonical` after this call returns.
419
+ const migration = migrateOldRuntimeState(projectRoot);
420
+ const migrateErrors = migration.errors.map((e) => ({
421
+ kind: 'migrate',
422
+ path: e.path,
423
+ message: e.message
424
+ }));
425
+ const sessions = discoverSessions(projectRoot);
426
+ const activeSkillSessionId = readActiveSkillSessionId(projectRoot);
427
+ const canonical = pickCanonicalSession(sessions, activeSkillSessionId);
428
+ const previousBinding = getSessionIdCanonical(projectRoot);
429
+ let repointedFrom = previousBinding;
430
+ let repointedTo = null;
431
+ let repointed = false;
432
+ if (canonical !== null) {
433
+ if (previousBinding !== canonical.sessionId) {
434
+ const repoint = repointSessionJson(projectRoot, canonical.sessionId, previousBinding);
435
+ repointedFrom = repoint.repointedFrom;
436
+ repointedTo = repoint.repointedTo;
437
+ repointed = true;
438
+ }
439
+ else {
440
+ // No-op: re-point the same binding so lastActivity is refreshed
441
+ const repoint = repointSessionJson(projectRoot, canonical.sessionId, previousBinding);
442
+ repointedFrom = repoint.repointedFrom;
443
+ repointedTo = repoint.repointedTo;
444
+ }
445
+ }
446
+ const deletionCandidates = findDeletionCandidates(sessions, ageThresholdMs);
447
+ const deletionResult = applyDeletions(deletionCandidates, apply);
448
+ return {
449
+ projectRoot,
450
+ sessions,
451
+ canonicalSessionId: canonical === null ? null : canonical.sessionId,
452
+ canonicalSource: canonical === null ? null : canonical.source,
453
+ repointedFrom,
454
+ repointedTo,
455
+ deletionCandidates,
456
+ deleted: deletionResult.deleted,
457
+ wouldDelete: deletionResult.wouldDelete,
458
+ ageThresholdMs,
459
+ apply,
460
+ repointed,
461
+ migratedFiles: migration.migratedFiles,
462
+ errors: [...migrateErrors, ...deletionResult.errors]
463
+ };
464
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Type envelope for the `peaks workspace reconcile` CLI command.
3
+ *
4
+ * The reconcile command scans `.peaks/2026-MM-DD-session-<6hex>/` directories
5
+ * under the project root, identifies a canonical session via a 4-tier
6
+ * heuristic (active-skill binding -> most-recent session.json mtime ->
7
+ * most-recent any-file mtime -> dir-name sort), re-points
8
+ * `.peaks/.session.json`, and (optionally) deletes empty / abandoned
9
+ * session dirs older than a configurable age threshold.
10
+ *
11
+ * Types are hand-rolled, no new top-level dependencies.
12
+ */
13
+ export type SessionEntry = {
14
+ /** Directory name (e.g. "2026-06-04-session-89f7cb"). */
15
+ sessionId: string;
16
+ /** Absolute path to the session directory. */
17
+ path: string;
18
+ /** mtime of `session.json` inside the dir in ms since epoch; null if missing. */
19
+ lastActivity: number | null;
20
+ /**
21
+ * Count of files under the session dir, EXCLUDING `session.json`
22
+ * (which is auto-generated by `peaks workspace init`). A count of
23
+ * 0 means the dir is empty / abandoned.
24
+ */
25
+ artifactCount: number;
26
+ };
27
+ export type DeletionCandidateReason = 'empty-or-auto-only' | 'older-than-threshold';
28
+ export type ReconcileResult = {
29
+ /** Absolute project root the command operated on. */
30
+ projectRoot: string;
31
+ /** All discovered `.peaks/2026-MM-DD-session-<6hex>/` directories, sorted by name. */
32
+ sessions: SessionEntry[];
33
+ /** The session id selected by the 4-tier canonical heuristic. */
34
+ canonicalSessionId: string | null;
35
+ /**
36
+ * The tier that decided the canonical pick (1..4), where tier 1 is
37
+ * active-skill and tier 4 is lexicographic last. Null when there are
38
+ * no sessions at all.
39
+ */
40
+ canonicalSource: 'active-skill' | 'latest-session-json-mtime' | 'latest-any-file-mtime' | 'dir-name-sort' | null;
41
+ /** The session id the binding pointed at before reconcile. Null if no prior binding. */
42
+ repointedFrom: string | null;
43
+ /** The session id the binding now points at. Null if there were no sessions. */
44
+ repointedTo: string | null;
45
+ /** Sessions that match the age-threshold + empty-or-auto-only deletion rule. */
46
+ deletionCandidates: SessionEntry[];
47
+ /** Actual deletions performed (only populated when `apply === true`). */
48
+ deleted: string[];
49
+ /** Sessions that WOULD be deleted if `--apply` were set (only populated when `apply === false`). */
50
+ wouldDelete: string[];
51
+ /** Age threshold in ms used to compute deletion candidates. */
52
+ ageThresholdMs: number;
53
+ /** Whether `--apply` was set. */
54
+ apply: boolean;
55
+ /** Whether the canonical session id differs from the prior binding. */
56
+ repointed: boolean;
57
+ /**
58
+ * Old-path runtime files that `migrateOldRuntimeState` moved into
59
+ * `.peaks/_runtime/` during this reconcile run. Each entry is the
60
+ * legacy path relative to the project root (e.g. ".peaks/.session.json",
61
+ * ".peaks/.active-skill.json", ".peaks/sop-state"). Empty when the
62
+ * tree is already on the new layout (idempotent re-runs return []).
63
+ *
64
+ * Added in slice 2026-06-05-peaks-runtime-layer; additive — older
65
+ * consumers can ignore this field.
66
+ */
67
+ migratedFiles: string[];
68
+ /**
69
+ * Errors encountered during the migration step. Each entry has a
70
+ * `kind: 'migrate'` discriminator so consumers can tell migration
71
+ * errors apart from deletion errors. The shape is additive.
72
+ */
73
+ errors: Array<{
74
+ sessionId: string;
75
+ message: string;
76
+ } | {
77
+ kind: 'migrate';
78
+ path: string;
79
+ message: string;
80
+ }>;
81
+ };
82
+ export type ReconcileOptions = {
83
+ projectRoot: string;
84
+ /** When true, actually `rm -rf` the deletion candidates. */
85
+ apply: boolean;
86
+ /**
87
+ * Age threshold in milliseconds; sessions whose mtime-based
88
+ * `lastActivity` is older than this AND whose `artifactCount === 0`
89
+ * (or 1 if the only file is `session.json`) are deletion candidates.
90
+ * Default: 7 days in the CLI layer.
91
+ */
92
+ olderThanMs: number;
93
+ };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Type envelope for the `peaks workspace reconcile` CLI command.
3
+ *
4
+ * The reconcile command scans `.peaks/2026-MM-DD-session-<6hex>/` directories
5
+ * under the project root, identifies a canonical session via a 4-tier
6
+ * heuristic (active-skill binding -> most-recent session.json mtime ->
7
+ * most-recent any-file mtime -> dir-name sort), re-points
8
+ * `.peaks/.session.json`, and (optionally) deletes empty / abandoned
9
+ * session dirs older than a configurable age threshold.
10
+ *
11
+ * Types are hand-rolled, no new top-level dependencies.
12
+ */
13
+ export {};