peaks-cli 1.2.3 → 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/dist/src/cli/commands/openspec-commands.js +31 -0
- package/dist/src/cli/commands/project-commands.js +51 -1
- package/dist/src/cli/commands/sop-commands.js +2 -2
- package/dist/src/cli/commands/workspace-commands.js +38 -4
- package/dist/src/services/memory/project-memory-service.d.ts +50 -0
- package/dist/src/services/memory/project-memory-service.js +412 -35
- package/dist/src/services/openspec/openspec-init-service.d.ts +23 -0
- package/dist/src/services/openspec/openspec-init-service.js +122 -0
- package/dist/src/services/session/index.d.ts +1 -1
- package/dist/src/services/session/index.js +1 -1
- package/dist/src/services/session/session-manager.d.ts +11 -0
- package/dist/src/services/session/session-manager.js +19 -0
- package/dist/src/services/skills/skill-presence-service.js +11 -0
- package/dist/src/services/sop/sop-check-service.d.ts +16 -0
- package/dist/src/services/sop/sop-check-service.js +35 -2
- package/dist/src/services/sop/sop-service.d.ts +8 -0
- package/dist/src/services/sop/sop-service.js +13 -2
- package/dist/src/services/sop/sop-types.d.ts +7 -0
- package/dist/src/services/workspace/workspace-service.d.ts +15 -0
- package/dist/src/services/workspace/workspace-service.js +60 -1
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +1 -1
- package/skills/peaks-prd/SKILL.md +36 -0
- package/skills/peaks-qa/SKILL.md +92 -2
- package/skills/peaks-rd/SKILL.md +70 -2
- package/skills/peaks-solo/SKILL.md +253 -40
- package/skills/peaks-solo/references/swarm-dispatch-contract.md +186 -0
- package/skills/peaks-sop/SKILL.md +17 -0
- package/skills/peaks-sop/references/sop-authoring.md +1 -1
- package/skills/peaks-txt/SKILL.md +16 -0
- package/skills/peaks-ui/SKILL.md +61 -2
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import { closeSync, constants, copyFileSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
1
|
+
import { closeSync, constants, copyFileSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
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
|
+
// Hot kinds: full body kept in index for always-available context
|
|
6
|
+
const HOT_KINDS = new Set(['feedback', 'decision', 'rule', 'convention', 'module']);
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Internal helpers (kept from original, sorted by dependency order)
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
5
10
|
const START_MARKER = '<!-- peaks-memory:start -->';
|
|
6
11
|
const END_MARKER = '<!-- peaks-memory:end -->';
|
|
7
12
|
const VALID_MEMORY_KINDS = new Set(['project', 'rule', 'decision', 'reference', 'feedback', 'convention', 'module']);
|
|
@@ -171,47 +176,25 @@ function parseStoredMemoryFile(content, filePath) {
|
|
|
171
176
|
filePath
|
|
172
177
|
};
|
|
173
178
|
}
|
|
174
|
-
function
|
|
175
|
-
return {
|
|
176
|
-
apply: result.apply,
|
|
177
|
-
projectRoot: result.projectRoot,
|
|
178
|
-
primaryMemoryDir: result.primaryMemoryDir,
|
|
179
|
-
backupPolicy: result.backupPolicy,
|
|
180
|
-
extractedCount: result.extractedMemories.length,
|
|
181
|
-
plannedWrites: result.plannedWrites.map((write) => ({
|
|
182
|
-
filePath: write.filePath,
|
|
183
|
-
title: write.memory.title,
|
|
184
|
-
kind: write.memory.kind,
|
|
185
|
-
sourceArtifact: write.memory.sourceArtifact
|
|
186
|
-
})),
|
|
187
|
-
writtenFiles: result.writtenFiles
|
|
188
|
-
};
|
|
189
|
-
}
|
|
190
|
-
function summarizeBackupResult(result) {
|
|
191
|
-
return {
|
|
192
|
-
apply: result.apply,
|
|
193
|
-
projectRoot: result.projectRoot,
|
|
194
|
-
artifactWorkspacePath: result.artifactWorkspacePath,
|
|
195
|
-
primaryMemoryDir: result.primaryMemoryDir,
|
|
196
|
-
backupMemoryDir: result.backupMemoryDir,
|
|
197
|
-
plannedCopies: result.plannedCopies,
|
|
198
|
-
copiedFiles: result.copiedFiles
|
|
199
|
-
};
|
|
200
|
-
}
|
|
201
|
-
function listMarkdownFiles(dirPath) {
|
|
179
|
+
function listMarkdownFiles(dirPath, options = {}) {
|
|
202
180
|
if (!existsSync(dirPath))
|
|
203
181
|
return [];
|
|
182
|
+
const { maxDepth = Infinity, skipDotfiles = true } = options;
|
|
204
183
|
const files = [];
|
|
205
|
-
const stack = [dirPath];
|
|
184
|
+
const stack = [{ path: dirPath, depth: 0 }];
|
|
206
185
|
while (stack.length > 0) {
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
186
|
+
const frame = stack.pop();
|
|
187
|
+
if (frame.depth > maxDepth)
|
|
188
|
+
continue;
|
|
189
|
+
for (const entry of readdirSync(frame.path, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
|
|
190
|
+
if (skipDotfiles && entry.name.startsWith('.'))
|
|
191
|
+
continue;
|
|
192
|
+
const entryPath = join(frame.path, entry.name);
|
|
210
193
|
if (entry.isSymbolicLink()) {
|
|
211
194
|
continue;
|
|
212
195
|
}
|
|
213
196
|
if (entry.isDirectory()) {
|
|
214
|
-
stack.push(entryPath);
|
|
197
|
+
stack.push({ path: entryPath, depth: frame.depth + 1 });
|
|
215
198
|
continue;
|
|
216
199
|
}
|
|
217
200
|
if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
@@ -221,6 +204,285 @@ function listMarkdownFiles(dirPath) {
|
|
|
221
204
|
}
|
|
222
205
|
return files.sort((left, right) => left.localeCompare(right));
|
|
223
206
|
}
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// Description summarization (deterministic, no LLM call)
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
function summarizeMemoryBody(body) {
|
|
211
|
+
const cleaned = body
|
|
212
|
+
.replace(/^#{1,6}\s+/gm, '')
|
|
213
|
+
.replace(/`{1,3}[^`]*`{1,3}/g, '')
|
|
214
|
+
.replace(/^\s*[-*+]\s+/gm, '')
|
|
215
|
+
.replace(/\n+/g, ' ')
|
|
216
|
+
.trim();
|
|
217
|
+
const sentences = cleaned.split(/(?<=[.!?])\s+/).filter((s) => s.length > 20 && !/^\[.+\]$/.test(s));
|
|
218
|
+
if (sentences.length === 0) {
|
|
219
|
+
return cleaned.slice(0, 120) || 'Project memory';
|
|
220
|
+
}
|
|
221
|
+
const first = sentences[0];
|
|
222
|
+
if (first.length <= 120) {
|
|
223
|
+
return first;
|
|
224
|
+
}
|
|
225
|
+
return first.slice(0, 117) + '...';
|
|
226
|
+
}
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// Session memory extraction (new extract path)
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
function assertSafeSessionDir(projectRoot, sessionId) {
|
|
231
|
+
const normalizedRoot = normalizeRoot(projectRoot);
|
|
232
|
+
const realRoot = normalizeRealRoot(projectRoot);
|
|
233
|
+
const sessionDir = join(normalizedRoot, '.peaks', sessionId);
|
|
234
|
+
if (!existsSync(sessionDir)) {
|
|
235
|
+
// Distinguish "not found" (caller will treat as no-op) from "escapes project
|
|
236
|
+
// root" (caller must surface a hard error). We probe by checking whether the
|
|
237
|
+
// joined path, after realpath, would still be inside the project root.
|
|
238
|
+
if (isAbsolute(join(normalizedRoot, '.peaks', sessionId))) {
|
|
239
|
+
const realJoined = safeRealpath(join(normalizedRoot, '.peaks', sessionId));
|
|
240
|
+
if (realJoined && !isInsidePath(realJoined, realRoot)) {
|
|
241
|
+
throw new Error('Session directory must stay inside the project root');
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
throw new Error('SESSION_DIR_NOT_FOUND');
|
|
245
|
+
}
|
|
246
|
+
const stats = lstatSync(sessionDir);
|
|
247
|
+
if (stats.isSymbolicLink()) {
|
|
248
|
+
throw new Error('Session directory must stay inside the project root');
|
|
249
|
+
}
|
|
250
|
+
const realSessionDir = realpathSync(sessionDir);
|
|
251
|
+
if (!isInsidePath(realSessionDir, realRoot)) {
|
|
252
|
+
throw new Error('Session directory must stay inside the project root');
|
|
253
|
+
}
|
|
254
|
+
return sessionDir;
|
|
255
|
+
}
|
|
256
|
+
function safeRealpath(path) {
|
|
257
|
+
try {
|
|
258
|
+
return realpathSync(path);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function readMemoryFileMtime(filePath) {
|
|
265
|
+
try {
|
|
266
|
+
return statSync(filePath).mtime.toISOString().slice(0, 10);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return new Date().toISOString().slice(0, 10);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
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.
|
|
280
|
+
const names = new Set();
|
|
281
|
+
for (const filePath of listMarkdownFiles(memoryDir)) {
|
|
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
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return names;
|
|
295
|
+
}
|
|
296
|
+
function generateMemoryIndexFile(projectRoot, memoryDir, indexPath) {
|
|
297
|
+
const memories = readProjectMemories(projectRoot);
|
|
298
|
+
const hot = {
|
|
299
|
+
feedback: [], decision: [], rule: [], convention: [], module: []
|
|
300
|
+
};
|
|
301
|
+
const warm = {
|
|
302
|
+
project: [], reference: []
|
|
303
|
+
};
|
|
304
|
+
for (const memory of memories.memories) {
|
|
305
|
+
const entry = {
|
|
306
|
+
name: memory.name,
|
|
307
|
+
kind: memory.kind,
|
|
308
|
+
description: memory.body ? summarizeMemoryBody(memory.body) : memory.title,
|
|
309
|
+
sourcePath: memory.filePath,
|
|
310
|
+
sourceArtifact: memory.sourceArtifact,
|
|
311
|
+
updatedAt: readMemoryFileMtime(memory.filePath)
|
|
312
|
+
};
|
|
313
|
+
if (HOT_KINDS.has(memory.kind)) {
|
|
314
|
+
hot[memory.kind].push(entry);
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
warm[memory.kind].push(entry);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
for (const kind of [...Object.keys(hot), ...Object.keys(warm)]) {
|
|
321
|
+
const arr = hot[kind] ?? warm[kind];
|
|
322
|
+
if (arr)
|
|
323
|
+
arr.sort((a, b) => a.name.localeCompare(b.name));
|
|
324
|
+
}
|
|
325
|
+
const index = {
|
|
326
|
+
version: 1,
|
|
327
|
+
updatedAt: new Date().toISOString(),
|
|
328
|
+
hot: hot,
|
|
329
|
+
warm: warm
|
|
330
|
+
};
|
|
331
|
+
const fd = openSync(indexPath, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC, 0o644);
|
|
332
|
+
try {
|
|
333
|
+
writeFileSync(fd, JSON.stringify(index, null, 2), 'utf8');
|
|
334
|
+
}
|
|
335
|
+
finally {
|
|
336
|
+
closeSync(fd);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
function readExistingIndex(indexPath) {
|
|
340
|
+
if (!existsSync(indexPath))
|
|
341
|
+
return null;
|
|
342
|
+
try {
|
|
343
|
+
const raw = readFileSync(indexPath, 'utf8');
|
|
344
|
+
const parsed = JSON.parse(raw);
|
|
345
|
+
if (parsed.version === 1)
|
|
346
|
+
return parsed;
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
export function readMemoryIndex(projectRoot) {
|
|
354
|
+
const normalizedRoot = normalizeRoot(projectRoot);
|
|
355
|
+
const memoryDir = assertSafeProjectMemoryDir(normalizedRoot);
|
|
356
|
+
const indexPath = join(memoryDir, 'index.json');
|
|
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
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return readExistingIndex(indexPath);
|
|
384
|
+
}
|
|
385
|
+
export function extractSessionMemories(options) {
|
|
386
|
+
const projectRoot = normalizeRoot(options.projectRoot);
|
|
387
|
+
const apply = options.apply ?? false;
|
|
388
|
+
const primaryMemoryDir = assertSafeProjectMemoryDir(projectRoot);
|
|
389
|
+
const memoryIndexPath = join(primaryMemoryDir, 'index.json');
|
|
390
|
+
// Resolve sessionDir through realpath + inside-project guard so a hostile
|
|
391
|
+
// sessionId (`..`, abs path, symlink chain) cannot walk the scanner outside
|
|
392
|
+
// the project root. A sentinel "SESSION_DIR_NOT_FOUND" distinguishes a
|
|
393
|
+
// benign miss from an escape attempt.
|
|
394
|
+
let sessionDir;
|
|
395
|
+
try {
|
|
396
|
+
sessionDir = assertSafeSessionDir(projectRoot, options.sessionId);
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
if (error instanceof Error && error.message === 'SESSION_DIR_NOT_FOUND') {
|
|
400
|
+
return {
|
|
401
|
+
apply,
|
|
402
|
+
projectRoot,
|
|
403
|
+
sessionId: options.sessionId,
|
|
404
|
+
primaryMemoryDir,
|
|
405
|
+
memoryIndexPath,
|
|
406
|
+
scannedFiles: 0,
|
|
407
|
+
extractedCount: 0,
|
|
408
|
+
writtenFiles: [],
|
|
409
|
+
updatedIndex: false
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
414
|
+
const scannedFiles = listMarkdownFiles(sessionDir, { maxDepth: 6, skipDotfiles: true });
|
|
415
|
+
const allExtracted = [];
|
|
416
|
+
for (const filePath of scannedFiles) {
|
|
417
|
+
try {
|
|
418
|
+
const content = readFileSync(filePath, 'utf8');
|
|
419
|
+
const relativePath = relative(projectRoot, filePath).replaceAll('\\', '/');
|
|
420
|
+
const extracted = extractStableProjectMemories(content, relativePath);
|
|
421
|
+
allExtracted.push(...extracted);
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
// skip unreadable files
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (allExtracted.length === 0) {
|
|
428
|
+
return {
|
|
429
|
+
apply,
|
|
430
|
+
projectRoot,
|
|
431
|
+
sessionId: options.sessionId,
|
|
432
|
+
primaryMemoryDir,
|
|
433
|
+
memoryIndexPath,
|
|
434
|
+
scannedFiles: scannedFiles.length,
|
|
435
|
+
extractedCount: 0,
|
|
436
|
+
writtenFiles: [],
|
|
437
|
+
updatedIndex: false
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
const slugCounts = new Map();
|
|
441
|
+
for (const memory of allExtracted) {
|
|
442
|
+
const slug = slugify(memory.title);
|
|
443
|
+
slugCounts.set(slug, (slugCounts.get(slug) ?? 0) + 1);
|
|
444
|
+
}
|
|
445
|
+
const duplicateTitles = [...slugCounts.entries()].filter(([, count]) => count > 1).map(([slug]) => slug);
|
|
446
|
+
if (duplicateTitles.length > 0) {
|
|
447
|
+
throw new Error(`Duplicate memory titles are not allowed: ${duplicateTitles.join(', ')}`);
|
|
448
|
+
}
|
|
449
|
+
// Idempotency: pre-read existing memory names so a re-run of the same
|
|
450
|
+
// session does not throw EEXIST. `writtenFiles` reports only the new
|
|
451
|
+
// writes so callers can still tell what the run actually produced.
|
|
452
|
+
const existingNames = apply ? readStoredMemoryNames(primaryMemoryDir) : new Set();
|
|
453
|
+
const writtenFiles = [];
|
|
454
|
+
if (apply) {
|
|
455
|
+
mkdirSync(primaryMemoryDir, { recursive: true });
|
|
456
|
+
for (const memory of allExtracted) {
|
|
457
|
+
const slug = slugify(memory.title);
|
|
458
|
+
if (existingNames.has(slug))
|
|
459
|
+
continue;
|
|
460
|
+
const targetPath = join(primaryMemoryDir, `${slug}.md`);
|
|
461
|
+
const safePath = resolveInputPath(targetPath);
|
|
462
|
+
const stableSafePath = stablePath(safePath);
|
|
463
|
+
if (!isInsidePath(stableSafePath, stableRealPath(primaryMemoryDir))) {
|
|
464
|
+
throw new Error('Project memory write target must stay inside the project memory directory');
|
|
465
|
+
}
|
|
466
|
+
writeNewFile(safePath, renderMemoryFile(memory));
|
|
467
|
+
writtenFiles.push(safePath);
|
|
468
|
+
}
|
|
469
|
+
generateMemoryIndexFile(projectRoot, primaryMemoryDir, memoryIndexPath);
|
|
470
|
+
}
|
|
471
|
+
return {
|
|
472
|
+
apply,
|
|
473
|
+
projectRoot,
|
|
474
|
+
sessionId: options.sessionId,
|
|
475
|
+
primaryMemoryDir,
|
|
476
|
+
memoryIndexPath,
|
|
477
|
+
scannedFiles: scannedFiles.length,
|
|
478
|
+
extractedCount: allExtracted.length,
|
|
479
|
+
writtenFiles,
|
|
480
|
+
updatedIndex: apply && writtenFiles.length > 0
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
// ---------------------------------------------------------------------------
|
|
484
|
+
// Old extract path (kept for core-artifact-commands.ts)
|
|
485
|
+
// ---------------------------------------------------------------------------
|
|
224
486
|
export function extractStableProjectMemories(content, sourceArtifact) {
|
|
225
487
|
const memories = [];
|
|
226
488
|
let searchStart = 0;
|
|
@@ -241,6 +503,33 @@ export function extractStableProjectMemories(content, sourceArtifact) {
|
|
|
241
503
|
}
|
|
242
504
|
return memories.sort((left, right) => slugify(left.title).localeCompare(slugify(right.title)));
|
|
243
505
|
}
|
|
506
|
+
function summarizeExtractResult(result) {
|
|
507
|
+
return {
|
|
508
|
+
apply: result.apply,
|
|
509
|
+
projectRoot: result.projectRoot,
|
|
510
|
+
primaryMemoryDir: result.primaryMemoryDir,
|
|
511
|
+
backupPolicy: result.backupPolicy,
|
|
512
|
+
extractedCount: result.extractedMemories.length,
|
|
513
|
+
plannedWrites: result.plannedWrites.map((write) => ({
|
|
514
|
+
filePath: write.filePath,
|
|
515
|
+
title: write.memory.title,
|
|
516
|
+
kind: write.memory.kind,
|
|
517
|
+
sourceArtifact: write.memory.sourceArtifact
|
|
518
|
+
})),
|
|
519
|
+
writtenFiles: result.writtenFiles
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
function summarizeBackupResult(result) {
|
|
523
|
+
return {
|
|
524
|
+
apply: result.apply,
|
|
525
|
+
projectRoot: result.projectRoot,
|
|
526
|
+
artifactWorkspacePath: result.artifactWorkspacePath,
|
|
527
|
+
primaryMemoryDir: result.primaryMemoryDir,
|
|
528
|
+
backupMemoryDir: result.backupMemoryDir,
|
|
529
|
+
plannedCopies: result.plannedCopies,
|
|
530
|
+
copiedFiles: result.copiedFiles
|
|
531
|
+
};
|
|
532
|
+
}
|
|
244
533
|
export function createProjectMemoryExtractPlan(options) {
|
|
245
534
|
const projectRoot = normalizeRoot(options.projectRoot);
|
|
246
535
|
const primaryMemoryDir = assertSafeProjectMemoryDir(projectRoot);
|
|
@@ -278,7 +567,18 @@ export function executeProjectMemoryExtract(options) {
|
|
|
278
567
|
if (plan.apply) {
|
|
279
568
|
mkdirSync(plan.primaryMemoryDir, { recursive: true });
|
|
280
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);
|
|
281
578
|
for (const write of plan.plannedWrites) {
|
|
579
|
+
const slug = slugify(write.memory.title);
|
|
580
|
+
if (existingNames.has(slug))
|
|
581
|
+
continue;
|
|
282
582
|
const targetPath = resolveInputPath(write.filePath);
|
|
283
583
|
const stableTargetPath = stablePath(targetPath);
|
|
284
584
|
if (!isInsidePath(stableTargetPath, stableRealPath(safeMemoryDir))) {
|
|
@@ -287,6 +587,18 @@ export function executeProjectMemoryExtract(options) {
|
|
|
287
587
|
writeNewFile(targetPath, write.content);
|
|
288
588
|
writtenFiles.push(targetPath);
|
|
289
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);
|
|
290
602
|
}
|
|
291
603
|
return { ...plan, writtenFiles };
|
|
292
604
|
}
|
|
@@ -350,9 +662,74 @@ function emptyByKind() {
|
|
|
350
662
|
module: []
|
|
351
663
|
};
|
|
352
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
|
+
}
|
|
353
722
|
export function readProjectMemories(projectRoot) {
|
|
354
723
|
const normalizedRoot = normalizeRoot(projectRoot);
|
|
355
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
|
+
}
|
|
356
733
|
const memories = [];
|
|
357
734
|
for (const filePath of listMarkdownFiles(memoryDir)) {
|
|
358
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.
|