peaks-cli 1.2.9 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -46
- package/bin/peaks.js +0 -0
- package/dist/src/cli/commands/hooks-commands.js +24 -9
- package/dist/src/cli/commands/progress-commands.js +26 -2
- package/dist/src/cli/commands/request-commands.js +5 -0
- package/dist/src/cli/commands/slice-commands.d.ts +3 -0
- package/dist/src/cli/commands/slice-commands.js +42 -0
- package/dist/src/cli/commands/workflow-commands.js +3 -3
- package/dist/src/cli/commands/workspace-commands.d.ts +63 -0
- package/dist/src/cli/commands/workspace-commands.js +347 -5
- package/dist/src/cli/program.js +4 -0
- package/dist/src/services/artifacts/artifact-prerequisites.d.ts +17 -1
- package/dist/src/services/artifacts/artifact-prerequisites.js +38 -5
- package/dist/src/services/artifacts/request-artifact-service.d.ts +22 -0
- package/dist/src/services/artifacts/request-artifact-service.js +172 -54
- package/dist/src/services/doctor/doctor-service.d.ts +7 -0
- package/dist/src/services/doctor/doctor-service.js +20 -2
- package/dist/src/services/progress/progress-service.d.ts +26 -0
- package/dist/src/services/progress/progress-service.js +25 -0
- package/dist/src/services/sc/sc-service.d.ts +52 -1
- package/dist/src/services/sc/sc-service.js +324 -17
- package/dist/src/services/scan/acceptance-coverage-service.js +6 -2
- package/dist/src/services/session/session-manager.d.ts +7 -5
- package/dist/src/services/session/session-manager.js +60 -16
- package/dist/src/services/skills/hooks-settings-service.d.ts +25 -3
- package/dist/src/services/skills/hooks-settings-service.js +57 -13
- package/dist/src/services/skills/skill-presence-service.js +102 -68
- package/dist/src/services/skills/skill-runbook-service.js +2 -1
- package/dist/src/services/skills/skill-statusline-service.js +13 -7
- package/dist/src/services/slice/slice-check-service.d.ts +2 -0
- package/dist/src/services/slice/slice-check-service.js +248 -0
- package/dist/src/services/slice/slice-check-types.d.ts +61 -0
- package/dist/src/services/slice/slice-check-types.js +18 -0
- package/dist/src/services/workflow/pipeline-verify-service.d.ts +5 -2
- package/dist/src/services/workflow/pipeline-verify-service.js +35 -35
- package/dist/src/services/workspace/migrate-service.d.ts +2 -0
- package/dist/src/services/workspace/migrate-service.js +484 -0
- package/dist/src/services/workspace/migrate-types.d.ts +84 -0
- package/dist/src/services/workspace/migrate-types.js +21 -0
- package/dist/src/services/workspace/reconcile-service.d.ts +119 -0
- package/dist/src/services/workspace/reconcile-service.js +464 -0
- package/dist/src/services/workspace/reconcile-types.d.ts +93 -0
- package/dist/src/services/workspace/reconcile-types.js +13 -0
- package/dist/src/services/workspace/workspace-service.d.ts +11 -0
- package/dist/src/services/workspace/workspace-service.js +87 -7
- package/dist/src/shared/change-id.d.ts +59 -0
- package/dist/src/shared/change-id.js +194 -16
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +13 -2
- package/skills/peaks-solo/SKILL.md +28 -4
- package/skills/peaks-solo/references/micro-cycle.md +155 -0
- package/skills/peaks-solo/references/runbook.md +2 -0
|
@@ -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 {};
|