peaks-cli 1.2.8 → 1.3.0

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 (41) hide show
  1. package/README.md +12 -0
  2. package/dist/src/cli/commands/project-commands.js +1 -1
  3. package/dist/src/cli/commands/scan-commands.js +22 -0
  4. package/dist/src/cli/commands/workspace-commands.js +59 -1
  5. package/dist/src/services/memory/project-memory-service.d.ts +1 -1
  6. package/dist/src/services/memory/project-memory-service.js +52 -23
  7. package/dist/src/services/sc/sc-service.d.ts +52 -1
  8. package/dist/src/services/sc/sc-service.js +266 -17
  9. package/dist/src/services/scan/libraries-service.d.ts +24 -0
  10. package/dist/src/services/scan/libraries-service.js +419 -0
  11. package/dist/src/services/scan/libraries-types.d.ts +59 -0
  12. package/dist/src/services/scan/libraries-types.js +9 -0
  13. package/dist/src/services/session/session-manager.d.ts +7 -5
  14. package/dist/src/services/session/session-manager.js +48 -14
  15. package/dist/src/services/skills/skill-presence-service.js +102 -68
  16. package/dist/src/services/skills/skill-runbook-service.js +36 -2
  17. package/dist/src/services/skills/skill-statusline-service.js +13 -7
  18. package/dist/src/services/workflow/autonomous-resume-writer.js +7 -7
  19. package/dist/src/services/workspace/reconcile-service.d.ts +119 -0
  20. package/dist/src/services/workspace/reconcile-service.js +464 -0
  21. package/dist/src/services/workspace/reconcile-types.d.ts +93 -0
  22. package/dist/src/services/workspace/reconcile-types.js +13 -0
  23. package/dist/src/shared/change-id.d.ts +30 -0
  24. package/dist/src/shared/change-id.js +40 -6
  25. package/dist/src/shared/paths.d.ts +1 -1
  26. package/dist/src/shared/paths.js +2 -1
  27. package/dist/src/shared/version.d.ts +1 -1
  28. package/dist/src/shared/version.js +1 -1
  29. package/package.json +4 -1
  30. package/schemas/library-breaking-changes.data.json +141 -0
  31. package/schemas/library-breaking-changes.meta.json +6 -0
  32. package/schemas/library-breaking-changes.schema.json +50 -0
  33. package/skills/peaks-qa/SKILL.md +12 -0
  34. package/skills/peaks-rd/SKILL.md +145 -2
  35. package/skills/peaks-solo/SKILL.md +93 -319
  36. package/skills/peaks-solo/references/runbook.md +168 -0
  37. package/skills/peaks-solo/references/workflow-gates-and-types.md +177 -0
  38. package/skills/peaks-solo-resume/SKILL.md +81 -0
  39. package/skills/peaks-solo-status/SKILL.md +120 -0
  40. package/skills/peaks-solo-test/SKILL.md +84 -0
  41. package/skills/peaks-txt/SKILL.md +8 -5
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
- import { dirname, resolve } from 'node:path';
2
+ import { dirname, join, resolve } from 'node:path';
3
3
  import { findProjectRoot } from '../config/config-safety.js';
4
4
  import { ensureMemoryBootstrap } from '../memory/project-memory-service.js';
5
5
  import { getSessionMeta } from '../session/session-manager.js';
@@ -31,8 +31,16 @@ function getCurrentOuterSessionId() {
31
31
  return claude;
32
32
  return undefined;
33
33
  }
34
- const PRESENCE_FILE = '.peaks/.active-skill.json';
35
- const SESSION_FILE = '.peaks/.session.json';
34
+ // As of slice 2026-06-05-peaks-runtime-layer the orchestrator's
35
+ // active-skill marker lives under `.peaks/_runtime/active-skill.json`.
36
+ // The legacy `.peaks/.active-skill.json` path is preserved as a
37
+ // read-only fallback for one minor release so older CLI versions (or
38
+ // trees that have not been migrated by `peaks workspace reconcile`)
39
+ // keep working without a forced re-init.
40
+ const PRESENCE_FILE = join('.peaks', '_runtime', 'active-skill.json');
41
+ const PRESENCE_FILE_LEGACY = '.peaks/.active-skill.json';
42
+ const SESSION_FILE = join('.peaks', '_runtime', 'session.json');
43
+ const SESSION_FILE_LEGACY = '.peaks/.session.json';
36
44
  function resolveProjectRoot(override) {
37
45
  if (override)
38
46
  return resolve(override);
@@ -41,12 +49,44 @@ function resolveProjectRoot(override) {
41
49
  function resolvePresencePath(projectRootOverride) {
42
50
  return resolve(resolveProjectRoot(projectRootOverride), PRESENCE_FILE);
43
51
  }
52
+ /**
53
+ * Back-compat read for the active-skill marker. Prefers the new
54
+ * canonical `.peaks/_runtime/active-skill.json`; falls back to the
55
+ * legacy `.peaks/.active-skill.json` for one minor release.
56
+ *
57
+ * Returns the parsed SkillPresence object, or null when neither
58
+ * file is present / valid. The legacy file is never written by
59
+ * current code — only the new path receives writes.
60
+ */
61
+ function readSkillPresenceBackCompat(projectRootOverride) {
62
+ const presencePath = resolvePresencePath(projectRootOverride);
63
+ const legacyPath = resolve(resolveProjectRoot(projectRootOverride), PRESENCE_FILE_LEGACY);
64
+ const pathToRead = existsSync(presencePath) ? presencePath : legacyPath;
65
+ if (!existsSync(pathToRead))
66
+ return null;
67
+ try {
68
+ const raw = readFileSync(pathToRead, 'utf8');
69
+ const parsed = JSON.parse(raw);
70
+ if (typeof parsed?.skill !== 'string' || parsed.skill.length === 0) {
71
+ return null;
72
+ }
73
+ return { presence: parsed, path: pathToRead };
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }
44
79
  function getCurrentSessionId(projectRootOverride) {
45
- const sessionPath = resolve(resolveProjectRoot(projectRootOverride), SESSION_FILE);
46
- if (!existsSync(sessionPath))
80
+ const projectRoot = resolveProjectRoot(projectRootOverride);
81
+ const sessionPath = resolve(projectRoot, SESSION_FILE);
82
+ const legacyPath = resolve(projectRoot, SESSION_FILE_LEGACY);
83
+ // Back-compat window: prefer the new canonical path; fall back to the
84
+ // legacy `.peaks/.session.json` for one minor release.
85
+ const pathToRead = existsSync(sessionPath) ? sessionPath : legacyPath;
86
+ if (!existsSync(pathToRead))
47
87
  return null;
48
88
  try {
49
- const data = JSON.parse(readFileSync(sessionPath, 'utf8'));
89
+ const data = JSON.parse(readFileSync(pathToRead, 'utf8'));
50
90
  return typeof data.sessionId === 'string' && data.sessionId.length > 0
51
91
  ? data.sessionId
52
92
  : null;
@@ -80,7 +120,7 @@ function getBoundOuterSessionId(projectRootOverride) {
80
120
  }
81
121
  /**
82
122
  * Snapshot of the previous peaks session's outer session id, read
83
- * straight off `.peaks/.active-skill.json` *before* we overwrite it.
123
+ * straight off the active-skill marker *before* we overwrite it.
84
124
  * Used to detect "the LLM just opened a fresh outer session" — if
85
125
  * the previously-recorded outer session id differs from the one we
86
126
  * are about to stamp, the user probably closed the previous outer
@@ -93,27 +133,24 @@ function getBoundOuterSessionId(projectRootOverride) {
93
133
  * (most common when the swap is a no-op reconnect) or to roll a
94
134
  * fresh session (when the new outer session is genuinely a new
95
135
  * task).
136
+ *
137
+ * Reads from `.peaks/_runtime/active-skill.json` first; falls back to
138
+ * the legacy `.peaks/.active-skill.json` for one minor release.
96
139
  */
97
140
  function getPreviousOuterSessionId(projectRootOverride) {
98
- const presencePath = resolvePresencePath(projectRootOverride);
99
- if (!existsSync(presencePath))
100
- return undefined;
101
- try {
102
- const raw = readFileSync(presencePath, 'utf8');
103
- const parsed = JSON.parse(raw);
104
- if (typeof parsed.outerSessionId === 'string' && parsed.outerSessionId.length > 0) {
105
- return parsed.outerSessionId;
106
- }
107
- // Legacy field name. Honour it on the read side so v1.2.x
108
- // presence files do not show as a false mismatch.
109
- if (typeof parsed.claudeSessionId === 'string' && parsed.claudeSessionId.length > 0) {
110
- return parsed.claudeSessionId;
111
- }
141
+ const result = readSkillPresenceBackCompat(projectRootOverride);
142
+ if (result === null)
112
143
  return undefined;
144
+ const parsed = result.presence;
145
+ if (typeof parsed.outerSessionId === 'string' && parsed.outerSessionId.length > 0) {
146
+ return parsed.outerSessionId;
113
147
  }
114
- catch {
115
- return undefined;
148
+ // Legacy field name. Honour it on the read side so v1.2.x
149
+ // presence files do not show as a false mismatch.
150
+ if (typeof parsed.claudeSessionId === 'string' && parsed.claudeSessionId.length > 0) {
151
+ return parsed.claudeSessionId;
116
152
  }
153
+ return undefined;
117
154
  }
118
155
  export function exportSkillPresence(projectRootOverride) {
119
156
  return resolvePresencePath(projectRootOverride);
@@ -184,65 +221,62 @@ export function setSkillPresence(skill, mode, gate, projectRootOverride) {
184
221
  return presence;
185
222
  }
186
223
  export function getSkillPresence(projectRootOverride) {
187
- const presencePath = resolvePresencePath(projectRootOverride);
188
- if (!existsSync(presencePath)) {
224
+ const result = readSkillPresenceBackCompat(projectRootOverride);
225
+ if (result === null)
189
226
  return null;
190
- }
191
- try {
192
- const raw = readFileSync(presencePath, 'utf8');
193
- const parsed = JSON.parse(raw);
194
- if (typeof parsed?.skill !== 'string' || parsed.skill.length === 0) {
195
- return null;
196
- }
197
- if (typeof parsed.sessionId === 'string' && parsed.sessionId.length > 0) {
198
- const currentSessionId = getCurrentSessionId(projectRootOverride);
199
- if (currentSessionId && parsed.sessionId !== currentSessionId) {
227
+ const { presence, path: presencePath } = result;
228
+ if (typeof presence.sessionId === 'string' && presence.sessionId.length > 0) {
229
+ const currentSessionId = getCurrentSessionId(projectRootOverride);
230
+ if (currentSessionId && presence.sessionId !== currentSessionId) {
231
+ try {
200
232
  unlinkSync(presencePath);
201
- return null;
202
233
  }
234
+ catch {
235
+ // best effort
236
+ }
237
+ return null;
203
238
  }
204
- return parsed;
205
- }
206
- catch {
207
- return null;
208
239
  }
240
+ return presence;
209
241
  }
210
242
  export function touchSkillHeartbeat(projectRootOverride) {
211
- const presencePath = resolvePresencePath(projectRootOverride);
212
- if (!existsSync(presencePath)) {
243
+ const result = readSkillPresenceBackCompat(projectRootOverride);
244
+ if (result === null)
213
245
  return null;
214
- }
215
- try {
216
- const raw = readFileSync(presencePath, 'utf8');
217
- const parsed = JSON.parse(raw);
218
- if (typeof parsed?.skill !== 'string' || parsed.skill.length === 0) {
219
- return null;
220
- }
221
- if (typeof parsed.sessionId === 'string' && parsed.sessionId.length > 0) {
222
- const currentSessionId = getCurrentSessionId(projectRootOverride);
223
- if (currentSessionId && parsed.sessionId !== currentSessionId) {
246
+ const { presence, path: presencePath } = result;
247
+ if (typeof presence.sessionId === 'string' && presence.sessionId.length > 0) {
248
+ const currentSessionId = getCurrentSessionId(projectRootOverride);
249
+ if (currentSessionId && presence.sessionId !== currentSessionId) {
250
+ try {
224
251
  unlinkSync(presencePath);
225
- return null;
226
252
  }
253
+ catch {
254
+ // best effort
255
+ }
256
+ return null;
227
257
  }
228
- parsed.lastHeartbeat = new Date().toISOString();
229
- writeFileSync(presencePath, JSON.stringify(parsed, null, 2), 'utf8');
230
- return parsed;
231
- }
232
- catch {
233
- return null;
234
258
  }
259
+ presence.lastHeartbeat = new Date().toISOString();
260
+ writeFileSync(presencePath, JSON.stringify(presence, null, 2), 'utf8');
261
+ return presence;
235
262
  }
236
263
  export function clearSkillPresence(projectRootOverride) {
264
+ // Clear both the new canonical path and the legacy path, so a stale
265
+ // presence marker from a prior CLI version cannot resurrect after
266
+ // a fresh `clear`.
237
267
  const presencePath = resolvePresencePath(projectRootOverride);
238
- if (!existsSync(presencePath)) {
239
- return false;
240
- }
241
- try {
242
- unlinkSync(presencePath);
243
- return true;
244
- }
245
- catch {
246
- return false;
268
+ const legacyPath = resolve(resolveProjectRoot(projectRootOverride), PRESENCE_FILE_LEGACY);
269
+ let cleared = false;
270
+ for (const p of [presencePath, legacyPath]) {
271
+ if (!existsSync(p))
272
+ continue;
273
+ try {
274
+ unlinkSync(p);
275
+ cleared = true;
276
+ }
277
+ catch {
278
+ // best effort
279
+ }
247
280
  }
281
+ return cleared;
248
282
  }
@@ -1,3 +1,4 @@
1
+ import { dirname, join } from 'node:path';
1
2
  import { readText } from '../../shared/fs.js';
2
3
  import { loadSkillRegistry } from './skill-registry.js';
3
4
  const DESTRUCTIVE_APPLY_PATTERNS = [
@@ -5,7 +6,8 @@ const DESTRUCTIVE_APPLY_PATTERNS = [
5
6
  /peaks\s+memory\s+extract[^\n]*--apply/,
6
7
  /peaks\s+artifacts\s+sync[^\n]*--apply/,
7
8
  /peaks\s+openspec\s+archive[^\n]*--apply/,
8
- /peaks\s+standards\s+(?:init|update)[^\n]*--apply/
9
+ /peaks\s+standards\s+(?:init|update)[^\n]*--apply/,
10
+ /peaks\s+workspace\s+reconcile[^\n]*--apply/
9
11
  ];
10
12
  const AUTHORIZATION_KEYWORDS_PATTERN = /authoriz|explicit|--dry-run|approv|only after|only when/i;
11
13
  const PEAKS_COMMAND_LINE_PATTERN = /^\s*peaks\s+\w/;
@@ -13,6 +15,38 @@ function extractRunbookSection(body) {
13
15
  const match = /## Default runbook\n+([\s\S]*?)(?=\n## |$)/.exec(body);
14
16
  return match === null ? null : match[1];
15
17
  }
18
+ /**
19
+ * Load the runbook section, falling back to `references/runbook.md` if the
20
+ * SKILL.md only has a pointer section. This supports skills (notably
21
+ * `peaks-solo`) that extracted their 150-line bash runbook to a sibling
22
+ * reference to keep SKILL.md under the 800-line cap. The CLI
23
+ * `peaks skill runbook` command uses the same fallback so a human
24
+ * reviewer sees the full runbook regardless of where it lives.
25
+ *
26
+ * Strategy: prefer the LONGER of the two sections. A short pointer section
27
+ * in SKILL.md (~ 1-2 lines) is treated as a "this runbook is in the
28
+ * reference" marker; a long inline section (>= the reference length) is
29
+ * treated as the canonical runbook. This avoids the false positive where
30
+ * the pointer section's regex match returns a non-null but content-poor
31
+ * string.
32
+ */
33
+ async function loadRunbookSection(skillPath, body) {
34
+ const inline = extractRunbookSection(body);
35
+ const refPath = join(dirname(skillPath), 'references', 'runbook.md');
36
+ let refSection = null;
37
+ try {
38
+ const refBody = await readText(refPath);
39
+ refSection = extractRunbookSection(refBody);
40
+ }
41
+ catch {
42
+ // reference file does not exist or is not readable
43
+ }
44
+ if (inline === null)
45
+ return refSection;
46
+ if (refSection === null)
47
+ return inline;
48
+ return inline.length >= refSection.length ? inline : refSection;
49
+ }
16
50
  function findDestructiveApplyLines(section) {
17
51
  const lines = section.split(/\r?\n/);
18
52
  return lines.filter((line) => DESTRUCTIVE_APPLY_PATTERNS.some((pattern) => pattern.test(line)));
@@ -30,7 +64,7 @@ export async function inspectSkillRunbook(name, baseDir) {
30
64
  throw new Error(`Skill "${name}" not found under skills directory`);
31
65
  }
32
66
  const body = await readText(skill.skillPath);
33
- const section = extractRunbookSection(body);
67
+ const section = await loadRunbookSection(skill.skillPath, body);
34
68
  if (section === null) {
35
69
  return {
36
70
  name: skill.name,
@@ -6,16 +6,19 @@ import { findProjectRoot } from '../config/config-safety.js';
6
6
  *
7
7
  * Claude Code invokes the configured statusLine command on every turn and pipes
8
8
  * a JSON session payload on stdin. This renderer reads the durable presence file
9
- * (.peaks/.active-skill.json) and prints a single line that Claude Code paints at
10
- * the bottom of the terminal. Because it is rendered by the harness — not emitted
11
- * as LLM tokens the signal cannot be forgotten by the model, cannot be confused
12
- * with normal output, and survives context compaction.
9
+ * (`.peaks/_runtime/active-skill.json`, with a one-minor-release back-compat
10
+ * fallback to `.peaks/.active-skill.json`) and prints a single line that
11
+ * Claude Code paints at the bottom of the terminal. Because it is rendered
12
+ * by the harness not emitted as LLM tokens — the signal cannot be forgotten
13
+ * by the model, cannot be confused with normal output, and survives context
14
+ * compaction.
13
15
  *
14
16
  * This module is intentionally READ-ONLY. Unlike getSkillPresence in
15
17
  * skill-presence-service.ts, it never deletes or rewrites the presence file:
16
18
  * the statusLine runs on every turn and must have zero side effects.
17
19
  */
18
- const PRESENCE_FILE = '.peaks/.active-skill.json';
20
+ const PRESENCE_FILE = '.peaks/_runtime/active-skill.json';
21
+ const PRESENCE_FILE_LEGACY = '.peaks/.active-skill.json';
19
22
  const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
20
23
  function resolveCwdFromStdin(stdin) {
21
24
  const fromWorkspace = stdin?.workspace?.current_dir ?? stdin?.workspace?.project_dir;
@@ -48,11 +51,14 @@ export function parseStatusLineStdin(raw) {
48
51
  */
49
52
  function readPresenceReadOnly(projectRoot) {
50
53
  const presencePath = resolve(projectRoot, PRESENCE_FILE);
51
- if (!existsSync(presencePath)) {
54
+ // Back-compat: prefer the new canonical path; fall back to the legacy
55
+ // `.peaks/.active-skill.json` for one minor release.
56
+ const pathToRead = existsSync(presencePath) ? presencePath : resolve(projectRoot, PRESENCE_FILE_LEGACY);
57
+ if (!existsSync(pathToRead)) {
52
58
  return { presence: null, invalid: false };
53
59
  }
54
60
  try {
55
- const parsed = JSON.parse(readFileSync(presencePath, 'utf8'));
61
+ const parsed = JSON.parse(readFileSync(pathToRead, 'utf8'));
56
62
  if (!parsed || typeof parsed !== 'object') {
57
63
  return { presence: null, invalid: true };
58
64
  }
@@ -1,6 +1,6 @@
1
1
  import { mkdir, writeFile } from 'node:fs/promises';
2
2
  import { dirname, join } from 'node:path';
3
- import { buildArtifactRelativePath, validateChangeIdOrThrow } from '../../shared/change-id.js';
3
+ import { buildArtifactRelativePathInRoot, validateChangeIdOrThrow } from '../../shared/change-id.js';
4
4
  import { pathExists } from '../../shared/fs.js';
5
5
  function defaultClock() {
6
6
  return new Date().toISOString();
@@ -109,27 +109,27 @@ Next actions:
109
109
  function buildFiles(changeId, goal, createdAt, artifactWorkspacePath) {
110
110
  return [
111
111
  {
112
- path: join(artifactWorkspacePath, buildArtifactRelativePath(changeId, 'prd', 'autonomous-goal-package.json')),
112
+ path: join(artifactWorkspacePath, buildArtifactRelativePathInRoot(artifactWorkspacePath, changeId, 'prd', 'autonomous-goal-package.json')),
113
113
  content: renderGoalPackage(changeId, goal)
114
114
  },
115
115
  {
116
- path: join(artifactWorkspacePath, buildArtifactRelativePath(changeId, 'rd', 'swarm', 'autonomous-rd-plan.json')),
116
+ path: join(artifactWorkspacePath, buildArtifactRelativePathInRoot(artifactWorkspacePath, changeId, 'rd', 'swarm', 'autonomous-rd-plan.json')),
117
117
  content: renderRdPlan(changeId)
118
118
  },
119
119
  {
120
- path: join(artifactWorkspacePath, buildArtifactRelativePath(changeId, 'rd', 'swarm', 'checkpoints', 'checkpoint-1.json')),
120
+ path: join(artifactWorkspacePath, buildArtifactRelativePathInRoot(artifactWorkspacePath, changeId, 'rd', 'swarm', 'checkpoints', 'checkpoint-1.json')),
121
121
  content: renderCheckpoint(changeId, createdAt)
122
122
  },
123
123
  {
124
- path: join(artifactWorkspacePath, buildArtifactRelativePath(changeId, 'rd', 'swarm', 'evidence', 'unit-tests.md')),
124
+ path: join(artifactWorkspacePath, buildArtifactRelativePathInRoot(artifactWorkspacePath, changeId, 'rd', 'swarm', 'evidence', 'unit-tests.md')),
125
125
  content: renderUnitTestsEvidence(changeId)
126
126
  },
127
127
  {
128
- path: join(artifactWorkspacePath, buildArtifactRelativePath(changeId, 'rd', 'swarm', 'evidence', 'validation-report.md')),
128
+ path: join(artifactWorkspacePath, buildArtifactRelativePathInRoot(artifactWorkspacePath, changeId, 'rd', 'swarm', 'evidence', 'validation-report.md')),
129
129
  content: renderValidationReport(changeId)
130
130
  },
131
131
  {
132
- path: join(artifactWorkspacePath, buildArtifactRelativePath(changeId, 'rd', 'swarm', 'resume-instructions.md')),
132
+ path: join(artifactWorkspacePath, buildArtifactRelativePathInRoot(artifactWorkspacePath, changeId, 'rd', 'swarm', 'resume-instructions.md')),
133
133
  content: renderResumeInstructions(changeId)
134
134
  }
135
135
  ];
@@ -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;