peaks-cli 1.2.4 → 1.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/peaks.js CHANGED
File without changes
@@ -4,6 +4,7 @@ import { projectOpenSpecToRdInput } from '../../services/openspec/openspec-bridg
4
4
  import { renderOpenSpecChange } from '../../services/openspec/openspec-render-service.js';
5
5
  import { validateOpenSpecChange } from '../../services/openspec/openspec-validate-service.js';
6
6
  import { archiveOpenSpecChange } from '../../services/openspec/openspec-archive-service.js';
7
+ import { executeOpenSpecInit } from '../../services/openspec/openspec-init-service.js';
7
8
  import { fail, ok } from '../../shared/result.js';
8
9
  import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
9
10
  function resolveScanOptions(project) {
@@ -166,4 +167,34 @@ export function registerOpenSpecCommands(program, io) {
166
167
  process.exitCode = 1;
167
168
  }
168
169
  });
170
+ addJsonOption(openspec
171
+ .command('init')
172
+ .description('Scaffold the openspec/ directory in the target project (changes/, archive/, README.md, CHANGES.md). Idempotent — refuses to overwrite an existing openspec/')
173
+ .requiredOption('--project <path>', 'target project root')
174
+ .option('--apply', 'write files to disk (default: dry-run preview)', false)).action(async (options) => {
175
+ try {
176
+ const result = await executeOpenSpecInit({
177
+ projectRoot: options.project,
178
+ apply: options.apply === true
179
+ });
180
+ const nextActions = [];
181
+ if (result.alreadyInitialized) {
182
+ nextActions.push('openspec/ already exists; no files were created or modified.');
183
+ if (result.existingFiles.length > 0) {
184
+ nextActions.push(`Existing files preserved: ${result.existingFiles.join(', ')}`);
185
+ }
186
+ }
187
+ else if (!result.apply) {
188
+ nextActions.push('Re-run with --apply to write the planned files to disk.');
189
+ }
190
+ else {
191
+ nextActions.push('Run `peaks openspec render --request <path> --apply` to scaffold a first change proposal.');
192
+ }
193
+ printResult(io, ok('openspec.init', result, [], nextActions), options.json);
194
+ }
195
+ catch (error) {
196
+ printResult(io, fail('openspec.init', 'OPENSPEC_INIT_FAILED', getErrorMessage(error), { projectRoot: options.project }, ['Verify the project path exists and is writable']), options.json);
197
+ process.exitCode = 1;
198
+ }
199
+ });
169
200
  }
@@ -1,16 +1,39 @@
1
- import { initWorkspace, InvalidSessionIdError } from '../../services/workspace/workspace-service.js';
1
+ import { initWorkspace, InvalidSessionIdError, ConflictingSessionError } from '../../services/workspace/workspace-service.js';
2
+ import { ensureSession } from '../../services/session/session-manager.js';
2
3
  import { fail, ok } from '../../shared/result.js';
3
4
  import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
4
5
  export function registerWorkspaceCommands(program, io) {
5
6
  const workspace = program.command('workspace').description('Manage the Peaks per-session artifact workspace (.peaks/<session-id>/)');
6
7
  addJsonOption(workspace
7
8
  .command('init')
8
- .description('Create the .peaks/<session-id>/ directory structure (prd, ui, rd, qa, sc, txt, system) validates the session id format')
9
+ .description('Create the .peaks/<session-id>/ directory structure (prd, ui, rd, qa, sc, txt, system) and bind the session as the project current one. Pass --session-id to use a specific id, or omit it to auto-generate one (and adopt an existing binding if present).')
9
10
  .requiredOption('--project <path>', 'target project root')
10
- .requiredOption('--session-id <id>', 'session id in YYYY-MM-DD-<kebab-slug> format')).action(async (options) => {
11
+ .option('--session-id <id>', 'optional session id in YYYY-MM-DD-<kebab-slug> format. When omitted, the CLI is the single source of truth: an existing binding is reused, otherwise a fresh id is auto-generated.')
12
+ .option('--allow-session-rebind', 'overwrite an existing session binding when the requested session id differs from the project current one', false)).action(async (options) => {
11
13
  try {
12
- const report = await initWorkspace({ projectRoot: options.project, sessionId: options.sessionId });
14
+ // Resolve the session id. Two paths:
15
+ // - explicit --session-id: use it as the requested binding target
16
+ // (ConflictingSessionError fires if it conflicts with an in-flight
17
+ // session, unless --allow-session-rebind is set)
18
+ // - omitted: defer to ensureSession(), which reuses an existing
19
+ // binding or auto-generates a fresh one. The init then writes
20
+ // .session.json so the binding sticks.
21
+ let sessionId;
22
+ if (options.sessionId !== undefined && options.sessionId.length > 0) {
23
+ sessionId = options.sessionId;
24
+ }
25
+ else {
26
+ sessionId = await ensureSession(options.project);
27
+ }
28
+ const report = await initWorkspace({
29
+ projectRoot: options.project,
30
+ sessionId,
31
+ allowSessionRebind: options.allowSessionRebind === true
32
+ });
13
33
  const nextActions = [];
34
+ if (report.previousSessionId !== null && report.bound) {
35
+ nextActions.push(`Replaced prior session binding "${report.previousSessionId}" with "${report.sessionId}".`);
36
+ }
14
37
  if (report.created.length === 0) {
15
38
  nextActions.push('Workspace already initialized — proceed to project scan.');
16
39
  }
@@ -25,6 +48,17 @@ export function registerWorkspaceCommands(program, io) {
25
48
  process.exitCode = 1;
26
49
  return;
27
50
  }
51
+ if (error instanceof ConflictingSessionError) {
52
+ printResult(io, fail('workspace.init', error.code, error.message, {
53
+ existingSessionId: error.existingSessionId,
54
+ requestedSessionId: error.requestedSessionId
55
+ }, [
56
+ `Finish or abandon session "${error.existingSessionId}" first, then re-run workspace init.`,
57
+ 'Or pass --allow-session-rebind to override the binding (overwrites the prior binding).'
58
+ ]), options.json);
59
+ process.exitCode = 1;
60
+ return;
61
+ }
28
62
  printResult(io, fail('workspace.init', 'WORKSPACE_INIT_FAILED', getErrorMessage(error), { projectRoot: options.project, sessionId: options.sessionId }, ['Verify the project path exists and is writable']), options.json);
29
63
  process.exitCode = 1;
30
64
  }
@@ -123,5 +123,23 @@ export declare function createProjectMemoryBackupPlan(options: BackupPlanOptions
123
123
  export declare function executeProjectMemoryBackup(options: BackupPlanOptions): ProjectMemoryBackupResult;
124
124
  export declare function summarizeProjectMemoryExtractResult(result: ProjectMemoryExtractResult): ProjectMemoryExtractSummary;
125
125
  export declare function summarizeProjectMemoryBackupResult(result: ProjectMemoryBackupResult): ProjectMemoryBackupSummary;
126
+ /**
127
+ * Ensure `.peaks/memory/` and its `index.json` exist for a project, with
128
+ * the same full-shape empty index the generator emits when there are zero
129
+ * memories. Idempotent — safe to call on every skill activation.
130
+ *
131
+ * Why this exists: before this helper, `.peaks/memory/` was only created
132
+ * by `extractSessionMemories` when at least one memory markdown was being
133
+ * written, and `index.json` was only emitted by the generator when at
134
+ * least one markdown was on disk. Stock projects therefore had no
135
+ * `.peaks/memory/` directory and no index, even after `peaks project
136
+ * memories` was read. Bootstrap closes that cold-start gap.
137
+ *
138
+ * This function is fail-open for the same reason the rest of the
139
+ * presence layer is fail-open: a failure here must NOT block skill
140
+ * activation. Any error is swallowed and surfaced only via the returned
141
+ * boolean. Callers that need the truth should check the result.
142
+ */
143
+ export declare function ensureMemoryBootstrap(projectRoot: string): boolean;
126
144
  export declare function readProjectMemories(projectRoot: string): ProjectMemoryReadResult;
127
145
  export {};
@@ -1,5 +1,5 @@
1
1
  import { closeSync, constants, copyFileSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
2
- import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
2
+ import { dirname, basename, isAbsolute, join, relative, resolve } from 'node:path';
3
3
  import { isInsidePath, isWindowsAbsolutePath, normalizePath, resolveInputPath, stablePath, stableRealPath } from '../../shared/path-utils.js';
4
4
  import { containsSensitiveConfigValue, isSensitiveConfigPath } from '../config/config-service.js';
5
5
  // Hot kinds: full body kept in index for always-available context
@@ -270,11 +270,26 @@ function readMemoryFileMtime(filePath) {
270
270
  }
271
271
  }
272
272
  function readStoredMemoryNames(memoryDir) {
273
+ // Two source-of-truth fallbacks for the slug-collision check:
274
+ // 1. Parse frontmatter (the canonical form rendered by
275
+ // renderMemoryFile / written by both extract paths).
276
+ // 2. Fall back to the bare filename stem, so user-dropped files
277
+ // without frontmatter (e.g. hand-written memories, legacy
278
+ // content) still count as a collision and are not overwritten
279
+ // by an idempotent re-extract.
273
280
  const names = new Set();
274
281
  for (const filePath of listMarkdownFiles(memoryDir)) {
275
- const parsed = parseStoredMemoryFile(readFileSync(filePath, 'utf8'), filePath);
276
- if (parsed)
277
- names.add(parsed.name);
282
+ const stem = basename(filePath, '.md');
283
+ if (stem.length > 0 && stem !== 'index')
284
+ names.add(stem);
285
+ try {
286
+ const parsed = parseStoredMemoryFile(readFileSync(filePath, 'utf8'), filePath);
287
+ if (parsed)
288
+ names.add(parsed.name);
289
+ }
290
+ catch {
291
+ // ignore unreadable files
292
+ }
278
293
  }
279
294
  return names;
280
295
  }
@@ -339,15 +354,30 @@ export function readMemoryIndex(projectRoot) {
339
354
  const normalizedRoot = normalizeRoot(projectRoot);
340
355
  const memoryDir = assertSafeProjectMemoryDir(normalizedRoot);
341
356
  const indexPath = join(memoryDir, 'index.json');
342
- if (existsSync(memoryDir)) {
343
- const files = listMarkdownFiles(memoryDir);
344
- if (files.length > 0) {
345
- try {
346
- generateMemoryIndexFile(normalizedRoot, memoryDir, indexPath);
347
- }
348
- catch {
349
- // fall through to read existing
350
- }
357
+ // Read-side bootstrap: if the memory dir is missing entirely, build a full
358
+ // empty index so downstream readers always see a well-formed result. We
359
+ // also fall through if the dir is present but the index is missing — the
360
+ // user may have nuked the index file, or never had one because no
361
+ // memory has ever been extracted in this project.
362
+ if (!existsSync(memoryDir)) {
363
+ ensureMemoryBootstrap(normalizedRoot);
364
+ return readExistingIndex(indexPath);
365
+ }
366
+ if (!existsSync(indexPath)) {
367
+ try {
368
+ writeFileSync(indexPath, renderEmptyIndex(), { mode: 0o644 });
369
+ }
370
+ catch {
371
+ // fall through — readExistingIndex will return null
372
+ }
373
+ }
374
+ const files = listMarkdownFiles(memoryDir);
375
+ if (files.length > 0) {
376
+ try {
377
+ generateMemoryIndexFile(normalizedRoot, memoryDir, indexPath);
378
+ }
379
+ catch {
380
+ // fall through to read existing
351
381
  }
352
382
  }
353
383
  return readExistingIndex(indexPath);
@@ -537,7 +567,18 @@ export function executeProjectMemoryExtract(options) {
537
567
  if (plan.apply) {
538
568
  mkdirSync(plan.primaryMemoryDir, { recursive: true });
539
569
  const safeMemoryDir = assertSafeProjectMemoryDir(plan.projectRoot);
570
+ // Idempotency: skip writes for memories whose slug already lives in
571
+ // .peaks/memory/. Re-running `peaks memory extract --apply` on the
572
+ // same handoff is a normal peaks-solo / peaks-txt retry pattern (the
573
+ // skill prompt may invoke extract more than once when a handoff is
574
+ // edited and re-extracted). Without this, writeNewFile's O_EXCL
575
+ // throws EEXIST and aborts the whole batch. Symmetric with
576
+ // extractSessionMemories (line ~614) which does the same skip.
577
+ const existingNames = readStoredMemoryNames(plan.primaryMemoryDir);
540
578
  for (const write of plan.plannedWrites) {
579
+ const slug = slugify(write.memory.title);
580
+ if (existingNames.has(slug))
581
+ continue;
541
582
  const targetPath = resolveInputPath(write.filePath);
542
583
  const stableTargetPath = stablePath(targetPath);
543
584
  if (!isInsidePath(stableTargetPath, stableRealPath(safeMemoryDir))) {
@@ -546,6 +587,18 @@ export function executeProjectMemoryExtract(options) {
546
587
  writeNewFile(targetPath, write.content);
547
588
  writtenFiles.push(targetPath);
548
589
  }
590
+ // After writing any markdown, regenerate the index so downstream
591
+ // readers (peaks project memory-index, peaks-txt re-runs, the next
592
+ // session's presence-set bootstrap) see the new memory. Without
593
+ // this, `peaks memory extract --apply` would leave the index stale
594
+ // and `readMemoryIndex` would either return the empty bootstrap or
595
+ // — pre-bootstrap-fix — return null. Symmetric with
596
+ // extractSessionMemories, which already regenerates the index on
597
+ // apply (see line ~626). We regen whenever --apply is set, even
598
+ // if every write was skipped by idempotency, so the index is
599
+ // always rebuilt against the current .peaks/memory/ directory.
600
+ const indexPath = join(plan.primaryMemoryDir, 'index.json');
601
+ generateMemoryIndexFile(plan.projectRoot, plan.primaryMemoryDir, indexPath);
549
602
  }
550
603
  return { ...plan, writtenFiles };
551
604
  }
@@ -609,9 +662,74 @@ function emptyByKind() {
609
662
  module: []
610
663
  };
611
664
  }
665
+ function emptyIndex() {
666
+ // Cast through unknown: we *intend* the two halves to together cover the
667
+ // union `ProjectMemoryKind`, but TS does not know that. The `MemoryIndex`
668
+ // type's `hot` / `warm` fields together cover the union; we split the
669
+ // construction so the JSON output mirrors the hot/warm layout the reader
670
+ // expects.
671
+ return {
672
+ version: 1,
673
+ updatedAt: new Date().toISOString(),
674
+ hot: {
675
+ feedback: [],
676
+ decision: [],
677
+ rule: [],
678
+ convention: [],
679
+ module: []
680
+ },
681
+ warm: {
682
+ project: [],
683
+ reference: []
684
+ }
685
+ };
686
+ }
687
+ function renderEmptyIndex() {
688
+ return JSON.stringify(emptyIndex(), null, 2) + '\n';
689
+ }
690
+ /**
691
+ * Ensure `.peaks/memory/` and its `index.json` exist for a project, with
692
+ * the same full-shape empty index the generator emits when there are zero
693
+ * memories. Idempotent — safe to call on every skill activation.
694
+ *
695
+ * Why this exists: before this helper, `.peaks/memory/` was only created
696
+ * by `extractSessionMemories` when at least one memory markdown was being
697
+ * written, and `index.json` was only emitted by the generator when at
698
+ * least one markdown was on disk. Stock projects therefore had no
699
+ * `.peaks/memory/` directory and no index, even after `peaks project
700
+ * memories` was read. Bootstrap closes that cold-start gap.
701
+ *
702
+ * This function is fail-open for the same reason the rest of the
703
+ * presence layer is fail-open: a failure here must NOT block skill
704
+ * activation. Any error is swallowed and surfaced only via the returned
705
+ * boolean. Callers that need the truth should check the result.
706
+ */
707
+ export function ensureMemoryBootstrap(projectRoot) {
708
+ try {
709
+ const normalizedRoot = normalizeRoot(projectRoot);
710
+ const memoryDir = assertSafeProjectMemoryDir(normalizedRoot);
711
+ const indexPath = join(memoryDir, 'index.json');
712
+ mkdirSync(memoryDir, { recursive: true });
713
+ if (!existsSync(indexPath)) {
714
+ writeFileSync(indexPath, renderEmptyIndex(), { mode: 0o644 });
715
+ }
716
+ return true;
717
+ }
718
+ catch {
719
+ return false;
720
+ }
721
+ }
612
722
  export function readProjectMemories(projectRoot) {
613
723
  const normalizedRoot = normalizeRoot(projectRoot);
614
724
  const memoryDir = assertSafeProjectMemoryDir(normalizedRoot);
725
+ // Read-side bootstrap: on a stock project the directory does not exist
726
+ // yet. Reading must not return an error, but we also want the directory
727
+ // to materialise (along with a full-shape empty index) so subsequent
728
+ // `peaks project memories` invocations, `readMemoryIndex`, and any
729
+ // extraction call find a stable target. The helper is fail-open.
730
+ if (!existsSync(memoryDir)) {
731
+ ensureMemoryBootstrap(normalizedRoot);
732
+ }
615
733
  const memories = [];
616
734
  for (const filePath of listMarkdownFiles(memoryDir)) {
617
735
  const parsed = parseStoredMemoryFile(readFileSync(filePath, 'utf8'), filePath);
@@ -0,0 +1,23 @@
1
+ export type OpenSpecInitOptions = {
2
+ projectRoot: string;
3
+ apply?: boolean;
4
+ };
5
+ export type OpenSpecInitPlan = {
6
+ apply: boolean;
7
+ projectRoot: string;
8
+ openspecRoot: string;
9
+ plannedWrites: Array<{
10
+ path: string;
11
+ kind: 'directory' | 'file';
12
+ bytes: number;
13
+ content: string;
14
+ }>;
15
+ alreadyInitialized: boolean;
16
+ existingFiles: string[];
17
+ };
18
+ export type OpenSpecInitResult = OpenSpecInitPlan & {
19
+ writtenFiles: string[];
20
+ createdDirectories: string[];
21
+ };
22
+ export declare function planOpenSpecInit(options: OpenSpecInitOptions): Promise<OpenSpecInitPlan>;
23
+ export declare function executeOpenSpecInit(options: OpenSpecInitOptions): Promise<OpenSpecInitResult>;
@@ -0,0 +1,122 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { isDirectory, pathExists } from '../../shared/fs.js';
4
+ const README_BODY = `# OpenSpec — change proposals for this project
5
+
6
+ This directory hosts the \`peaks openspec\` change proposal lifecycle:
7
+
8
+ render → validate → show → to-rd → ... → archive
9
+
10
+ Each in-flight proposal lives in \`changes/<id>/\` and contains:
11
+
12
+ - \`proposal.md\` — why, what, non-goals, impact
13
+ - \`tasks.md\` — concrete slices and commit boundaries (consumed by peaks-sc)
14
+ - \`design.md\` — optional, for non-trivial designs
15
+ - \`specs/\` — optional, delta-style spec changes (## ADDED / ## MODIFIED / ## REMOVED)
16
+ - \`*.md\` — any additional narrative the change needs
17
+
18
+ When a change ships, \`peaks openspec archive <id> --apply\` moves it into
19
+ \`changes/archive/<id>/\`. The archive is the historical record of what
20
+ landed and why.
21
+
22
+ To scaffold a fresh change in this project:
23
+
24
+ peaks openspec render --request <path-to-render-request.json> --apply
25
+
26
+ For the full lifecycle see \`peaks openspec --help\` and the
27
+ peaks-clause-code skill family.
28
+ `;
29
+ const CHANGES_INDEX_HEADER = `# OpenSpec — change log
30
+
31
+ This file is the human-readable index of every change that has shipped in
32
+ this project. New entries are added by \`peaks openspec archive\` (when
33
+ \`.apply\` is used); do not hand-edit.
34
+
35
+ | Date | Change | Status |
36
+ |------|--------|--------|
37
+ `;
38
+ function renderReadme() {
39
+ return README_BODY;
40
+ }
41
+ function renderChangesIndex() {
42
+ return CHANGES_INDEX_HEADER;
43
+ }
44
+ function buildPlan(projectRoot, apply) {
45
+ const openspecRoot = join(projectRoot, 'openspec');
46
+ const changesRoot = join(openspecRoot, 'changes');
47
+ const archiveRoot = join(changesRoot, 'archive');
48
+ const plannedWrites = [
49
+ { path: openspecRoot, kind: 'directory', bytes: 0, content: '' },
50
+ { path: changesRoot, kind: 'directory', bytes: 0, content: '' },
51
+ { path: archiveRoot, kind: 'directory', bytes: 0, content: '' },
52
+ { path: join(openspecRoot, 'README.md'), kind: 'file', bytes: 0, content: renderReadme() },
53
+ { path: join(openspecRoot, 'CHANGES.md'), kind: 'file', bytes: 0, content: renderChangesIndex() }
54
+ ];
55
+ // Stamp byte counts now that content is finalised.
56
+ for (const write of plannedWrites) {
57
+ if (write.kind === 'file') {
58
+ write.bytes = Buffer.byteLength(write.content, 'utf8');
59
+ }
60
+ }
61
+ return {
62
+ apply,
63
+ projectRoot,
64
+ openspecRoot,
65
+ plannedWrites,
66
+ alreadyInitialized: false,
67
+ existingFiles: []
68
+ };
69
+ }
70
+ export async function planOpenSpecInit(options) {
71
+ const openspecRoot = join(options.projectRoot, 'openspec');
72
+ const plan = buildPlan(options.projectRoot, options.apply ?? false);
73
+ if (await isDirectory(openspecRoot)) {
74
+ // Already initialised. Report the existing files so the user can audit
75
+ // before re-running with --apply. We never overwrite an existing
76
+ // openspec/ — that is a destructive action and out of scope for init.
77
+ const existing = [];
78
+ for (const write of plan.plannedWrites) {
79
+ if (write.kind === 'file' && (await pathExists(write.path))) {
80
+ existing.push(write.path);
81
+ }
82
+ }
83
+ // Pre-compute which directory writes to keep (those whose target does
84
+ // not exist yet). .filter cannot be async, so resolve the boolean
85
+ // set up front.
86
+ const directoryKeep = new Set();
87
+ for (const write of plan.plannedWrites) {
88
+ if (write.kind === 'directory' && !(await isDirectory(write.path))) {
89
+ directoryKeep.add(write.path);
90
+ }
91
+ }
92
+ plan.plannedWrites = plan.plannedWrites.filter((write) => {
93
+ if (write.kind === 'directory')
94
+ return directoryKeep.has(write.path);
95
+ return !existing.includes(write.path);
96
+ });
97
+ plan.alreadyInitialized = existing.length > 0 || (await isDirectory(join(openspecRoot, 'changes')));
98
+ plan.existingFiles = existing;
99
+ }
100
+ return plan;
101
+ }
102
+ export async function executeOpenSpecInit(options) {
103
+ const plan = await planOpenSpecInit(options);
104
+ const writtenFiles = [];
105
+ const createdDirectories = [];
106
+ if (plan.apply && !plan.alreadyInitialized) {
107
+ for (const write of plan.plannedWrites) {
108
+ if (write.kind === 'directory') {
109
+ if (!(await isDirectory(write.path))) {
110
+ await mkdir(write.path, { recursive: true });
111
+ createdDirectories.push(write.path);
112
+ }
113
+ continue;
114
+ }
115
+ if (write.content.length === 0)
116
+ continue;
117
+ await writeFile(write.path, write.content, 'utf8');
118
+ writtenFiles.push(write.path);
119
+ }
120
+ }
121
+ return { ...plan, writtenFiles, createdDirectories };
122
+ }
@@ -1 +1 @@
1
- export { ensureSession, getSessionId, getCurrentSessionDir, listSessions, getSessionMeta, setSessionMeta, setSessionTitle, listSessionMetas, getProjectScanPath, hasProjectScan, type SessionInfo, type SessionMeta } from './session-manager.js';
1
+ export { ensureSession, getSessionId, getCurrentSessionDir, listSessions, getSessionMeta, setSessionMeta, setSessionTitle, listSessionMetas, getProjectScanPath, hasProjectScan, setCurrentSessionBinding, type SessionInfo, type SessionMeta } from './session-manager.js';
@@ -1 +1 @@
1
- export { ensureSession, getSessionId, getCurrentSessionDir, listSessions, getSessionMeta, setSessionMeta, setSessionTitle, listSessionMetas, getProjectScanPath, hasProjectScan } from './session-manager.js';
1
+ export { ensureSession, getSessionId, getCurrentSessionDir, listSessions, getSessionMeta, setSessionMeta, setSessionTitle, listSessionMetas, getProjectScanPath, hasProjectScan, setCurrentSessionBinding } from './session-manager.js';
@@ -20,6 +20,17 @@ export type SessionMeta = {
20
20
  lastActivity?: string;
21
21
  projectRoot: string;
22
22
  };
23
+ /**
24
+ * Bind the project's current session to the given session id by writing
25
+ * `.peaks/.session.json`. The single-session binding is the source of truth
26
+ * for `ensureSession()` and any other path that needs to discover the
27
+ * active session without an explicit --session-id flag.
28
+ *
29
+ * This does NOT touch the per-session `session.json` inside `.peaks/<id>/`;
30
+ * that file is owned by `setSessionMeta` and records session-scoped
31
+ * metadata (title, skill, mode, gate, etc.).
32
+ */
33
+ export declare function setCurrentSessionBinding(projectRoot: string, sessionId: string): SessionInfo;
23
34
  /**
24
35
  * Read metadata for a specific session directory.
25
36
  * Returns null if the session directory or its session.json does not exist.
@@ -61,6 +61,25 @@ function writeSessionFile(projectRoot, info) {
61
61
  }
62
62
  writeFileSync(sessionFile, JSON.stringify(info, null, 2), 'utf8');
63
63
  }
64
+ /**
65
+ * Bind the project's current session to the given session id by writing
66
+ * `.peaks/.session.json`. The single-session binding is the source of truth
67
+ * for `ensureSession()` and any other path that needs to discover the
68
+ * active session without an explicit --session-id flag.
69
+ *
70
+ * This does NOT touch the per-session `session.json` inside `.peaks/<id>/`;
71
+ * that file is owned by `setSessionMeta` and records session-scoped
72
+ * metadata (title, skill, mode, gate, etc.).
73
+ */
74
+ export function setCurrentSessionBinding(projectRoot, sessionId) {
75
+ const info = {
76
+ sessionId,
77
+ createdAt: new Date().toISOString(),
78
+ projectRoot
79
+ };
80
+ writeSessionFile(projectRoot, info);
81
+ return info;
82
+ }
64
83
  function getMetaFilePath(projectRoot, sessionId) {
65
84
  return join(projectRoot, '.peaks', sessionId, META_FILE);
66
85
  }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, resolve } from 'node:path';
3
3
  import { findProjectRoot } from '../config/config-safety.js';
4
+ import { ensureMemoryBootstrap } from '../memory/project-memory-service.js';
4
5
  export const VALID_SKILL_PRESENCE_MODES = [
5
6
  'full-auto',
6
7
  'assisted',
@@ -67,6 +68,16 @@ export function setSkillPresence(skill, mode, gate, projectRootOverride) {
67
68
  mkdirSync(presenceDir, { recursive: true });
68
69
  }
69
70
  writeFileSync(presencePath, JSON.stringify(presence, null, 2), 'utf8');
71
+ // Skill-activation side effect: ensure `.peaks/memory/` and a full-shape
72
+ // empty `index.json` exist for the project. This is the user-facing fix
73
+ // for "stock projects never get a memory directory or index". Every peaks
74
+ // skill starts with `peaks skill presence:set peaks-<role>`, so doing the
75
+ // bootstrap here means the very first skill invocation in a fresh project
76
+ // (or in a stock project that pre-dates the memory layer) brings the
77
+ // memory store into existence. The helper is fail-open, so a failure here
78
+ // does not block presence from being written.
79
+ const projectRoot = resolveProjectRoot(projectRootOverride);
80
+ ensureMemoryBootstrap(projectRoot);
70
81
  return presence;
71
82
  }
72
83
  export function getSkillPresence(projectRootOverride) {
@@ -1,16 +1,31 @@
1
1
  export type WorkspaceInitOptions = {
2
2
  projectRoot: string;
3
3
  sessionId: string;
4
+ /**
5
+ * When true, the conflict check is skipped and the new session id is
6
+ * written to .peaks/.session.json even if the project is already bound
7
+ * to a different (real) session. Use only with explicit user authorization
8
+ * — the CLI surfaces this as `--allow-session-rebind`.
9
+ */
10
+ allowSessionRebind?: boolean;
4
11
  };
5
12
  export type WorkspaceInitReport = {
6
13
  sessionId: string;
7
14
  sessionRoot: string;
8
15
  created: string[];
9
16
  alreadyExisted: string[];
17
+ bound: boolean;
18
+ previousSessionId: string | null;
10
19
  };
11
20
  export declare class InvalidSessionIdError extends Error {
12
21
  readonly code = "INVALID_SESSION_ID";
13
22
  constructor(message: string);
14
23
  }
24
+ export declare class ConflictingSessionError extends Error {
25
+ readonly existingSessionId: string;
26
+ readonly requestedSessionId: string;
27
+ readonly code = "CONFLICTING_SESSION";
28
+ constructor(message: string, existingSessionId: string, requestedSessionId: string);
29
+ }
15
30
  export declare function validateSessionId(sessionId: string): void;
16
31
  export declare function initWorkspace(options: WorkspaceInitOptions): Promise<WorkspaceInitReport>;