peaks-cli 1.2.2 → 1.2.4

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.
@@ -275,10 +275,14 @@ export function registerCoreAndArtifactCommands(program, io) {
275
275
  .requiredOption('--project <path>', 'target project root')
276
276
  .requiredOption('--artifact <path...>', 'skill artifact paths inside the project')
277
277
  .option('--dry-run', 'preview writes without changing files')
278
- .option('--apply', 'write extracted memories into project .peaks/memory (default behavior)')).action((options) => {
278
+ .option('--apply', 'write extracted memories into project .peaks/memory')).action((options) => {
279
+ if (options.dryRun === true && options.apply === true) {
280
+ printResult(io, fail('memory.extract', 'INVALID_MEMORY_EXTRACT_FLAGS', 'Use either --dry-run or --apply, not both', {}, ['Run without --apply to preview writes, or pass --apply to write memories']), options.json);
281
+ process.exitCode = 1;
282
+ return;
283
+ }
279
284
  try {
280
- const shouldApply = options.dryRun !== true;
281
- const result = executeProjectMemoryExtract({ projectRoot: options.project, artifactPaths: options.artifact, apply: shouldApply });
285
+ const result = executeProjectMemoryExtract({ projectRoot: options.project, artifactPaths: options.artifact, apply: options.apply === true });
282
286
  printResult(io, ok('memory.extract', summarizeProjectMemoryExtractResult(result)), options.json);
283
287
  }
284
288
  catch (error) {
@@ -292,10 +296,14 @@ export function registerCoreAndArtifactCommands(program, io) {
292
296
  .requiredOption('--project <path>', 'target project root')
293
297
  .requiredOption('--workspace <path>', 'artifact workspace path')
294
298
  .option('--dry-run', 'preview copies without changing files')
295
- .option('--apply', 'copy project .peaks/memory into artifact workspace backup (default behavior)')).action((options) => {
299
+ .option('--apply', 'copy project .peaks/memory into artifact workspace backup')).action((options) => {
300
+ if (options.dryRun === true && options.apply === true) {
301
+ printResult(io, fail('memory.sync', 'INVALID_MEMORY_SYNC_FLAGS', 'Use either --dry-run or --apply, not both', {}, ['Run without --apply to preview copies, or pass --apply to back up memories']), options.json);
302
+ process.exitCode = 1;
303
+ return;
304
+ }
296
305
  try {
297
- const shouldApply = options.dryRun !== true;
298
- const result = executeProjectMemoryBackup({ projectRoot: options.project, artifactWorkspacePath: options.workspace, apply: shouldApply });
306
+ const result = executeProjectMemoryBackup({ projectRoot: options.project, artifactWorkspacePath: options.workspace, apply: options.apply === true });
299
307
  printResult(io, ok('memory.sync', summarizeProjectMemoryBackupResult(result)), options.json);
300
308
  }
301
309
  catch (error) {
@@ -1,6 +1,6 @@
1
1
  import { loadProjectDashboard } from '../../services/dashboard/project-dashboard-service.js';
2
2
  import { generateProjectContext, readProjectContext } from '../../services/memory/project-context-service.js';
3
- import { readProjectMemories } from '../../services/memory/project-memory-service.js';
3
+ import { extractSessionMemories, readMemoryIndex, readProjectMemories } from '../../services/memory/project-memory-service.js';
4
4
  import { fail, ok } from '../../shared/result.js';
5
5
  import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
6
6
  export function registerProjectCommands(program, io) {
@@ -68,6 +68,56 @@ export function registerProjectCommands(program, io) {
68
68
  process.exitCode = 1;
69
69
  }
70
70
  });
71
+ // --- Extract memories from a session's artifacts into .peaks/memory ---
72
+ addJsonOption(project
73
+ .command('memories:extract')
74
+ .description('Scan a session artifact directory and extract <!-- peaks-memory:start --> blocks into .peaks/memory/')
75
+ .requiredOption('--session-id <id>', 'session id (e.g. 2026-05-29-session-89ff35)')
76
+ .requiredOption('--project <path>', 'target project root')
77
+ .option('--dry-run', 'preview writes without changing files', true)
78
+ .option('--apply', 'write extracted memories into .peaks/memory/')).action((options) => {
79
+ if (options.dryRun === true && options.apply === true) {
80
+ printResult(io, fail('project.memories:extract', 'INVALID_MEMORY_EXTRACT_FLAGS', 'Use either --dry-run or --apply, not both', { sessionId: options.sessionId, projectRoot: options.project }, ['Run without --apply to preview writes, or pass --apply to write memories']), options.json);
81
+ process.exitCode = 1;
82
+ return;
83
+ }
84
+ try {
85
+ const result = extractSessionMemories({
86
+ projectRoot: options.project,
87
+ sessionId: options.sessionId,
88
+ apply: options.apply === true
89
+ });
90
+ printResult(io, ok('project.memories:extract', {
91
+ scannedFiles: result.scannedFiles,
92
+ extractedCount: result.extractedCount,
93
+ writtenFiles: result.writtenFiles,
94
+ memoryDir: result.primaryMemoryDir,
95
+ indexUpdated: result.updatedIndex
96
+ }), options.json);
97
+ }
98
+ catch (error) {
99
+ printResult(io, fail('project.memories:extract', 'MEMORY_EXTRACT_FAILED', getErrorMessage(error), { sessionId: options.sessionId, projectRoot: options.project }, ['Check the session-id and project path']), options.json);
100
+ process.exitCode = 1;
101
+ }
102
+ });
103
+ // --- Read memory index (lightweight, always-safe to load) ---
104
+ addJsonOption(project
105
+ .command('memory-index')
106
+ .description('Read the memory index — lightweight hot/warm分层 view of all project memories')
107
+ .requiredOption('--project <path>', 'target project root')).action((options) => {
108
+ try {
109
+ const index = readMemoryIndex(options.project);
110
+ if (!index) {
111
+ printResult(io, ok('project.memory-index', { exists: false, message: 'No memory index found. Run `peaks project memories:extract` first.' }), options.json);
112
+ return;
113
+ }
114
+ printResult(io, ok('project.memory-index', { exists: true, index }), options.json);
115
+ }
116
+ catch (error) {
117
+ printResult(io, fail('project.memory-index', 'MEMORY_INDEX_FAILED', getErrorMessage(error), { projectRoot: options.project }, ['Check the project path and .peaks/memory directory']), options.json);
118
+ process.exitCode = 1;
119
+ }
120
+ });
71
121
  // --- Structured project memory (durable, LLM-authored, stored under .peaks/memory) ---
72
122
  addJsonOption(project
73
123
  .command('memories')
@@ -105,8 +105,8 @@ export function registerSopCommands(program, io) {
105
105
  });
106
106
  addJsonOption(sop
107
107
  .command('registry')
108
- .description('List registered SOPs and gates (global; --project merges in the repo layer)')
109
- .option('--project <path>', 'also include and prefer the repo layer (<path>/.peaks/sops)')).action(async (options) => {
108
+ .description('List registered SOPs and gates (global; merges in the cwd project layer by default)')
109
+ .option('--project <path>', 'also include and prefer the repo layer (<path>/.peaks/sops) (default: current directory)', '.')).action(async (options) => {
110
110
  try {
111
111
  const registry = await readRegistry(options.project);
112
112
  printResult(io, ok('sop.registry', registry), options.json);
@@ -74,6 +74,36 @@ export type ProjectMemoryReadResult = {
74
74
  byKind: Record<ProjectMemoryKind, StoredProjectMemory[]>;
75
75
  memories: StoredProjectMemory[];
76
76
  };
77
+ export type MemoryIndexEntry = {
78
+ name: string;
79
+ kind: ProjectMemoryKind;
80
+ description: string;
81
+ sourcePath: string;
82
+ sourceArtifact: string | null;
83
+ updatedAt: string;
84
+ };
85
+ export type MemoryIndex = {
86
+ version: 1;
87
+ updatedAt: string;
88
+ hot: Record<ProjectMemoryKind, MemoryIndexEntry[]>;
89
+ warm: Record<ProjectMemoryKind, MemoryIndexEntry[]>;
90
+ };
91
+ export type ExtractSessionMemoriesOptions = {
92
+ projectRoot: string;
93
+ sessionId: string;
94
+ apply?: boolean;
95
+ };
96
+ export type ExtractSessionMemoriesResult = {
97
+ apply: boolean;
98
+ projectRoot: string;
99
+ sessionId: string;
100
+ primaryMemoryDir: string;
101
+ memoryIndexPath: string;
102
+ scannedFiles: number;
103
+ extractedCount: number;
104
+ writtenFiles: string[];
105
+ updatedIndex: boolean;
106
+ };
77
107
  type ExtractPlanOptions = {
78
108
  projectRoot: string;
79
109
  artifactPaths: string[];
@@ -84,6 +114,8 @@ type BackupPlanOptions = {
84
114
  artifactWorkspacePath: string;
85
115
  apply?: boolean;
86
116
  };
117
+ export declare function readMemoryIndex(projectRoot: string): MemoryIndex | null;
118
+ export declare function extractSessionMemories(options: ExtractSessionMemoriesOptions): ExtractSessionMemoriesResult;
87
119
  export declare function extractStableProjectMemories(content: string, sourceArtifact: string): ExtractedProjectMemory[];
88
120
  export declare function createProjectMemoryExtractPlan(options: ExtractPlanOptions): ProjectMemoryExtractPlan;
89
121
  export declare function executeProjectMemoryExtract(options: ExtractPlanOptions): ProjectMemoryExtractResult;
@@ -1,7 +1,12 @@
1
- import { closeSync, constants, copyFileSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
1
+ import { closeSync, constants, copyFileSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, 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 summarizeExtractResult(result) {
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 currentDir = stack.pop();
208
- for (const entry of readdirSync(currentDir, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
209
- const entryPath = join(currentDir, entry.name);
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,255 @@ 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
+ const names = new Set();
274
+ for (const filePath of listMarkdownFiles(memoryDir)) {
275
+ const parsed = parseStoredMemoryFile(readFileSync(filePath, 'utf8'), filePath);
276
+ if (parsed)
277
+ names.add(parsed.name);
278
+ }
279
+ return names;
280
+ }
281
+ function generateMemoryIndexFile(projectRoot, memoryDir, indexPath) {
282
+ const memories = readProjectMemories(projectRoot);
283
+ const hot = {
284
+ feedback: [], decision: [], rule: [], convention: [], module: []
285
+ };
286
+ const warm = {
287
+ project: [], reference: []
288
+ };
289
+ for (const memory of memories.memories) {
290
+ const entry = {
291
+ name: memory.name,
292
+ kind: memory.kind,
293
+ description: memory.body ? summarizeMemoryBody(memory.body) : memory.title,
294
+ sourcePath: memory.filePath,
295
+ sourceArtifact: memory.sourceArtifact,
296
+ updatedAt: readMemoryFileMtime(memory.filePath)
297
+ };
298
+ if (HOT_KINDS.has(memory.kind)) {
299
+ hot[memory.kind].push(entry);
300
+ }
301
+ else {
302
+ warm[memory.kind].push(entry);
303
+ }
304
+ }
305
+ for (const kind of [...Object.keys(hot), ...Object.keys(warm)]) {
306
+ const arr = hot[kind] ?? warm[kind];
307
+ if (arr)
308
+ arr.sort((a, b) => a.name.localeCompare(b.name));
309
+ }
310
+ const index = {
311
+ version: 1,
312
+ updatedAt: new Date().toISOString(),
313
+ hot: hot,
314
+ warm: warm
315
+ };
316
+ const fd = openSync(indexPath, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC, 0o644);
317
+ try {
318
+ writeFileSync(fd, JSON.stringify(index, null, 2), 'utf8');
319
+ }
320
+ finally {
321
+ closeSync(fd);
322
+ }
323
+ }
324
+ function readExistingIndex(indexPath) {
325
+ if (!existsSync(indexPath))
326
+ return null;
327
+ try {
328
+ const raw = readFileSync(indexPath, 'utf8');
329
+ const parsed = JSON.parse(raw);
330
+ if (parsed.version === 1)
331
+ return parsed;
332
+ return null;
333
+ }
334
+ catch {
335
+ return null;
336
+ }
337
+ }
338
+ export function readMemoryIndex(projectRoot) {
339
+ const normalizedRoot = normalizeRoot(projectRoot);
340
+ const memoryDir = assertSafeProjectMemoryDir(normalizedRoot);
341
+ 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
+ }
351
+ }
352
+ }
353
+ return readExistingIndex(indexPath);
354
+ }
355
+ export function extractSessionMemories(options) {
356
+ const projectRoot = normalizeRoot(options.projectRoot);
357
+ const apply = options.apply ?? false;
358
+ const primaryMemoryDir = assertSafeProjectMemoryDir(projectRoot);
359
+ const memoryIndexPath = join(primaryMemoryDir, 'index.json');
360
+ // Resolve sessionDir through realpath + inside-project guard so a hostile
361
+ // sessionId (`..`, abs path, symlink chain) cannot walk the scanner outside
362
+ // the project root. A sentinel "SESSION_DIR_NOT_FOUND" distinguishes a
363
+ // benign miss from an escape attempt.
364
+ let sessionDir;
365
+ try {
366
+ sessionDir = assertSafeSessionDir(projectRoot, options.sessionId);
367
+ }
368
+ catch (error) {
369
+ if (error instanceof Error && error.message === 'SESSION_DIR_NOT_FOUND') {
370
+ return {
371
+ apply,
372
+ projectRoot,
373
+ sessionId: options.sessionId,
374
+ primaryMemoryDir,
375
+ memoryIndexPath,
376
+ scannedFiles: 0,
377
+ extractedCount: 0,
378
+ writtenFiles: [],
379
+ updatedIndex: false
380
+ };
381
+ }
382
+ throw error;
383
+ }
384
+ const scannedFiles = listMarkdownFiles(sessionDir, { maxDepth: 6, skipDotfiles: true });
385
+ const allExtracted = [];
386
+ for (const filePath of scannedFiles) {
387
+ try {
388
+ const content = readFileSync(filePath, 'utf8');
389
+ const relativePath = relative(projectRoot, filePath).replaceAll('\\', '/');
390
+ const extracted = extractStableProjectMemories(content, relativePath);
391
+ allExtracted.push(...extracted);
392
+ }
393
+ catch {
394
+ // skip unreadable files
395
+ }
396
+ }
397
+ if (allExtracted.length === 0) {
398
+ return {
399
+ apply,
400
+ projectRoot,
401
+ sessionId: options.sessionId,
402
+ primaryMemoryDir,
403
+ memoryIndexPath,
404
+ scannedFiles: scannedFiles.length,
405
+ extractedCount: 0,
406
+ writtenFiles: [],
407
+ updatedIndex: false
408
+ };
409
+ }
410
+ const slugCounts = new Map();
411
+ for (const memory of allExtracted) {
412
+ const slug = slugify(memory.title);
413
+ slugCounts.set(slug, (slugCounts.get(slug) ?? 0) + 1);
414
+ }
415
+ const duplicateTitles = [...slugCounts.entries()].filter(([, count]) => count > 1).map(([slug]) => slug);
416
+ if (duplicateTitles.length > 0) {
417
+ throw new Error(`Duplicate memory titles are not allowed: ${duplicateTitles.join(', ')}`);
418
+ }
419
+ // Idempotency: pre-read existing memory names so a re-run of the same
420
+ // session does not throw EEXIST. `writtenFiles` reports only the new
421
+ // writes so callers can still tell what the run actually produced.
422
+ const existingNames = apply ? readStoredMemoryNames(primaryMemoryDir) : new Set();
423
+ const writtenFiles = [];
424
+ if (apply) {
425
+ mkdirSync(primaryMemoryDir, { recursive: true });
426
+ for (const memory of allExtracted) {
427
+ const slug = slugify(memory.title);
428
+ if (existingNames.has(slug))
429
+ continue;
430
+ const targetPath = join(primaryMemoryDir, `${slug}.md`);
431
+ const safePath = resolveInputPath(targetPath);
432
+ const stableSafePath = stablePath(safePath);
433
+ if (!isInsidePath(stableSafePath, stableRealPath(primaryMemoryDir))) {
434
+ throw new Error('Project memory write target must stay inside the project memory directory');
435
+ }
436
+ writeNewFile(safePath, renderMemoryFile(memory));
437
+ writtenFiles.push(safePath);
438
+ }
439
+ generateMemoryIndexFile(projectRoot, primaryMemoryDir, memoryIndexPath);
440
+ }
441
+ return {
442
+ apply,
443
+ projectRoot,
444
+ sessionId: options.sessionId,
445
+ primaryMemoryDir,
446
+ memoryIndexPath,
447
+ scannedFiles: scannedFiles.length,
448
+ extractedCount: allExtracted.length,
449
+ writtenFiles,
450
+ updatedIndex: apply && writtenFiles.length > 0
451
+ };
452
+ }
453
+ // ---------------------------------------------------------------------------
454
+ // Old extract path (kept for core-artifact-commands.ts)
455
+ // ---------------------------------------------------------------------------
224
456
  export function extractStableProjectMemories(content, sourceArtifact) {
225
457
  const memories = [];
226
458
  let searchStart = 0;
@@ -241,6 +473,33 @@ export function extractStableProjectMemories(content, sourceArtifact) {
241
473
  }
242
474
  return memories.sort((left, right) => slugify(left.title).localeCompare(slugify(right.title)));
243
475
  }
476
+ function summarizeExtractResult(result) {
477
+ return {
478
+ apply: result.apply,
479
+ projectRoot: result.projectRoot,
480
+ primaryMemoryDir: result.primaryMemoryDir,
481
+ backupPolicy: result.backupPolicy,
482
+ extractedCount: result.extractedMemories.length,
483
+ plannedWrites: result.plannedWrites.map((write) => ({
484
+ filePath: write.filePath,
485
+ title: write.memory.title,
486
+ kind: write.memory.kind,
487
+ sourceArtifact: write.memory.sourceArtifact
488
+ })),
489
+ writtenFiles: result.writtenFiles
490
+ };
491
+ }
492
+ function summarizeBackupResult(result) {
493
+ return {
494
+ apply: result.apply,
495
+ projectRoot: result.projectRoot,
496
+ artifactWorkspacePath: result.artifactWorkspacePath,
497
+ primaryMemoryDir: result.primaryMemoryDir,
498
+ backupMemoryDir: result.backupMemoryDir,
499
+ plannedCopies: result.plannedCopies,
500
+ copiedFiles: result.copiedFiles
501
+ };
502
+ }
244
503
  export function createProjectMemoryExtractPlan(options) {
245
504
  const projectRoot = normalizeRoot(options.projectRoot);
246
505
  const primaryMemoryDir = assertSafeProjectMemoryDir(projectRoot);
@@ -20,6 +20,22 @@ export declare class SopCheckError extends Error {
20
20
  readonly code: string;
21
21
  constructor(code: string, message: string);
22
22
  }
23
+ /**
24
+ * Strip meta content from a string for grep evaluation. The grep gate's
25
+ * `stripMeta:true` opt-in lets content-publishing SOPs avoid the "literal-word
26
+ * trap" (the author discussing the gate's pattern in the post would itself
27
+ * trigger the gate). Three classes of meta are removed:
28
+ *
29
+ * - HTML comments: `<!-- ... -->`
30
+ * - Fenced code blocks: 3+ backticks on their own line, opening through the
31
+ * matching close on its own line (or end of string)
32
+ * - C-style block comments: `/* ... *​/`
33
+ *
34
+ * Conservative fail-safe: unclosed fences and unclosed block comments are left
35
+ * as-is (no partial strip), so the regex still matches any embedded pattern
36
+ * rather than silently hiding it. This helper is pure; no side effects.
37
+ */
38
+ export declare function stripMetaForGrep(content: string): string;
23
39
  export type EvaluateGateOptions = {
24
40
  allowCommands?: boolean;
25
41
  commandTimeoutMs?: number;
@@ -40,7 +40,7 @@ function evaluateFileExists(projectRoot, path) {
40
40
  }
41
41
  return existsSync(resolved) ? { result: 'pass' } : { result: 'fail', reason: `file "${path}" does not exist` };
42
42
  }
43
- function evaluateGrep(projectRoot, file, pattern, absent) {
43
+ function evaluateGrep(projectRoot, file, pattern, absent, stripMeta) {
44
44
  const resolved = resolveInsideProject(projectRoot, file);
45
45
  if (resolved === null) {
46
46
  return { result: 'blocked', reason: `file "${file}" escapes the project root` };
@@ -62,6 +62,9 @@ function evaluateGrep(projectRoot, file, pattern, absent) {
62
62
  catch {
63
63
  return { result: 'blocked', reason: `file "${file}" cannot be read` };
64
64
  }
65
+ if (stripMeta === true) {
66
+ content = stripMetaForGrep(content);
67
+ }
65
68
  const found = regex.test(content);
66
69
  // absent gate: pass when the pattern is NOT present ("must not contain X").
67
70
  const pass = absent ? !found : found;
@@ -72,6 +75,36 @@ function evaluateGrep(projectRoot, file, pattern, absent) {
72
75
  ? { result: 'fail', reason: `pattern "${pattern}" must be absent but was found in "${file}"` }
73
76
  : { result: 'fail', reason: `pattern "${pattern}" not found in "${file}"` };
74
77
  }
78
+ /**
79
+ * Strip meta content from a string for grep evaluation. The grep gate's
80
+ * `stripMeta:true` opt-in lets content-publishing SOPs avoid the "literal-word
81
+ * trap" (the author discussing the gate's pattern in the post would itself
82
+ * trigger the gate). Three classes of meta are removed:
83
+ *
84
+ * - HTML comments: `<!-- ... -->`
85
+ * - Fenced code blocks: 3+ backticks on their own line, opening through the
86
+ * matching close on its own line (or end of string)
87
+ * - C-style block comments: `/* ... *​/`
88
+ *
89
+ * Conservative fail-safe: unclosed fences and unclosed block comments are left
90
+ * as-is (no partial strip), so the regex still matches any embedded pattern
91
+ * rather than silently hiding it. This helper is pure; no side effects.
92
+ */
93
+ export function stripMetaForGrep(content) {
94
+ // HTML comments: from `<!--` through the next `-->`. Multi-line.
95
+ let result = content.replace(/<!--[\s\S]*?-->/g, '');
96
+ // C-style block comments: from `/*` through the next `*/`. Multi-line. Run
97
+ // before the fence regex because code comments often appear inside code
98
+ // blocks and we want them gone from the rendered view too.
99
+ result = result.replace(/\/\*[\s\S]*?\*\//g, '');
100
+ // Fenced code blocks: `^````<lang?>\\n...<closing line>`. Match only if a
101
+ // closing `^```` line exists within the content; unclosed fences fall
102
+ // through un-stripped (the unclosed-`\n?$` branch from the previous version
103
+ // over-matched by eating the rest of the file, which silently hides
104
+ // embedded patterns; we prefer the conservative fail-safe).
105
+ result = result.replace(/^```[^\n]*\n[\s\S]*?\n```[^\n]*\n?/gm, '');
106
+ return result;
107
+ }
75
108
  function evaluateCommand(projectRoot, run, expectExitZero, allowCommands, timeoutMs) {
76
109
  if (!allowCommands) {
77
110
  return { result: 'blocked', reason: 'command checks require --allow-commands' };
@@ -110,7 +143,7 @@ function evaluateCheck(projectRoot, check, allowCommands, timeoutMs) {
110
143
  case 'file-exists':
111
144
  return evaluateFileExists(projectRoot, check.path);
112
145
  case 'grep':
113
- return evaluateGrep(projectRoot, check.file, check.pattern, check.absent === true);
146
+ return evaluateGrep(projectRoot, check.file, check.pattern, check.absent === true, check.stripMeta === true);
114
147
  case 'command':
115
148
  return evaluateCommand(projectRoot, check.run, check.expectExitZero !== false, allowCommands, timeoutMs);
116
149
  default:
@@ -29,6 +29,14 @@ export type SopLintResult = {
29
29
  gateCount: number;
30
30
  gateIds: string[];
31
31
  findings: SopLintFinding[];
32
+ /**
33
+ * Non-blocking hints surfaced alongside the lint verdict. Currently used to
34
+ * flag gates that declared `stripMeta: true` so authors can confirm the
35
+ * opt-in. Empty array for manifests that do not opt in to any new behavior;
36
+ * this is a stable, machine-readable field distinct from `findings` (which
37
+ * are errors that block lint).
38
+ */
39
+ warnings: string[];
32
40
  };
33
41
  export type SopLintOptions = {
34
42
  id: string;
@@ -164,13 +164,14 @@ export async function lintSop(options) {
164
164
  return null;
165
165
  }
166
166
  const findings = [];
167
+ const warnings = [];
167
168
  let manifest = null;
168
169
  try {
169
170
  manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
170
171
  }
171
172
  catch (error) {
172
173
  pushError(findings, 'INVALID_JSON', `Manifest is not valid JSON: ${error instanceof Error ? error.message : 'parse error'}`);
173
- return { ok: false, id: options.id, manifestPath, gateCount: 0, gateIds: [], findings };
174
+ return { ok: false, id: options.id, manifestPath, gateCount: 0, gateIds: [], findings, warnings };
174
175
  }
175
176
  if (typeof manifest.id !== 'string' || !SOP_ID_PATTERN.test(manifest.id)) {
176
177
  pushError(findings, 'INVALID_ID', `Manifest id "${String(manifest.id)}" is invalid (expected lowercase kebab)`);
@@ -200,12 +201,22 @@ export async function lintSop(options) {
200
201
  gates.forEach((gate, index) => lintGate(gate, index, phaseSet, seenGateIds, options.allowCommands === true, findings));
201
202
  const guards = Array.isArray(manifest.guards) ? manifest.guards : [];
202
203
  guards.forEach((guard, index) => lintGuard(guard, index, phaseSet, findings));
204
+ // Non-blocking hints: surface `stripMeta: true` opt-ins so the author can
205
+ // confirm the meta-strip behavior. Distinct from `findings` (which block
206
+ // lint). Per PRD 006 AC6, only gates that declared `stripMeta` warn.
207
+ for (const gate of gates) {
208
+ if (gate?.check?.type === 'grep' && gate.check.stripMeta === true) {
209
+ const label = typeof gate.id === 'string' && gate.id.length > 0 ? gate.id : '?';
210
+ warnings.push(`Gate "${label}" declares stripMeta: true — meta content (HTML comments / fenced code / block comments) is excluded from grep evaluation`);
211
+ }
212
+ }
203
213
  return {
204
214
  ok: findings.every((finding) => finding.severity !== 'error'),
205
215
  id: options.id,
206
216
  manifestPath,
207
217
  gateCount: gates.length,
208
218
  gateIds: [...seenGateIds],
209
- findings
219
+ findings,
220
+ warnings
210
221
  };
211
222
  }
@@ -16,12 +16,19 @@ export type SopGateCheck = {
16
16
  * to invert — pass when the pattern is NOT found. `absent` expresses "must not
17
17
  * contain X" (e.g. no leftover TODO) as a pure-text check, with no command gate
18
18
  * and no `--allow-commands` escalation.
19
+ *
20
+ * Set `stripMeta: true` to evaluate the regex on a meta-stripped copy of the
21
+ * file (HTML comments + fenced code blocks + `/* … *​/` block comments
22
+ * removed). This lets content-publishing SOPs avoid the "literal-word trap"
23
+ * where the author discussing the gate's behavior in the post would itself
24
+ * trigger the gate. Default `false` preserves byte-identical behavior.
19
25
  */
20
26
  | {
21
27
  type: 'grep';
22
28
  file: string;
23
29
  pattern: string;
24
30
  absent?: boolean;
31
+ stripMeta?: boolean;
25
32
  } | {
26
33
  type: 'command';
27
34
  run: string[];
@@ -1 +1 @@
1
- export declare const CLI_VERSION = "1.2.2";
1
+ export declare const CLI_VERSION = "1.2.4";
@@ -1 +1 @@
1
- export const CLI_VERSION = "1.2.2";
1
+ export const CLI_VERSION = "1.2.4";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-cli",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
4
4
  "description": "Peaks CLI and short skill family for Claude Code automation.",
5
5
  "author": "SquabbyZ",
6
6
  "license": "MIT",
@@ -147,6 +147,7 @@ peaks request lint <rid> --role qa --project <repo> --json
147
147
  # 9. on verdict=return-to-rd, route findings back through the request id; otherwise close.
148
148
  peaks request show <request-id> --role qa --project <repo> --json
149
149
  peaks openspec archive <change-id> --project <repo> --json # preview, then --apply on full pass
150
+ peaks project memories:extract --session-id <session-id> --project <repo> --json # extract durable memories
150
151
  peaks skill presence:clear --project <repo> # QA complete, remove presence indicator
151
152
  ```
152
153
 
@@ -89,6 +89,20 @@ peaks codegraph affected --project <repo> <changed-files...> --json
89
89
  # - component library (antd, MUI, shadcn, etc.) and version
90
90
  # - CSS solution (Less, Sass, TailwindCSS, CSS-in-JS) and conflicts
91
91
  # - state management, routing, data fetching libraries
92
+ #
93
+ # After writing project-scan, embed durable memory markers for stable project facts.
94
+ # Append one <!-- peaks-memory:start --> block per fact at the end of project-scan.md:
95
+ #
96
+ # <!-- peaks-memory:start -->
97
+ # title: <component library>
98
+ # kind: module
99
+ # ---
100
+ # <Library> <version> — detected from package.json and source imports.
101
+ # <!-- peaks-memory:end -->
102
+ #
103
+ # Embed markers for: component library, CSS solution, build tool, state management,
104
+ # routing, data fetching, and any legacy constraints. These facts are session-invariant
105
+ # and valuable for future sessions. Do NOT embed secrets, credentials, or transient state.
92
106
 
93
107
  # 4.2 component library detection — verify against package.json, not assumptions
94
108
  # WRONG: "looks like a React project, let me use shadcn/ui"
@@ -151,6 +165,7 @@ peaks openspec validate <change-id> --project <repo> --json # exit gate (re-r
151
165
  # 8. hand off to QA via the cross-linked request id
152
166
  peaks request init --role qa --id <request-id> --project <repo> --apply --json
153
167
  peaks request show <request-id> --role rd --project <repo> --json
168
+ peaks project memories:extract --session-id <session-id> --project <repo> --json # extract durable memories
154
169
  peaks skill presence:clear --project <repo> # handoff complete, remove presence indicator
155
170
  ```
156
171
 
@@ -761,8 +761,11 @@ peaks sc boundary --slice-id <rid> --artifact <artifact> --code <file> --json
761
761
  peaks openspec validate <cid> --project <repo> --json
762
762
  peaks openspec archive <cid> --project <repo> --apply --json
763
763
 
764
- # 10. Peaks-Cli TXT handoff
765
- peaks memory extract --project <repo> --artifact <qa-artifact> --dry-run --json
764
+ # 10. Peaks-Cli TXT handoff — invoke peaks-txt which embeds memory markers and extracts
765
+ # peaks-txt writes the handoff capsule to .peaks/<id>/txt/handoff.md with embedded
766
+ # <!-- peaks-memory:start --> blocks, then runs memory extract on it.
767
+ # --apply is REQUIRED to write .peaks/memory; without it the command only previews.
768
+ peaks memory extract --project <repo> --artifact .peaks/<id>/txt/handoff.md --apply --json
766
769
 
767
770
  # 11. Peaks-Cli Final snapshot
768
771
  peaks project dashboard --project <repo> --json
@@ -833,6 +836,11 @@ Use Peaks-Cli TXT for the compact handoff capsule: mode, validated decisions, ar
833
836
 
834
837
  Do NOT call `peaks skill presence:clear --project <repo>` at workflow end. The presence file and header remain active so the user stays inside the workflow context. The user can continue with follow-up requirements naturally — no need to re-invoke `/peaks-solo`. The header continues to display the active skill and current gate.
835
838
 
839
+ Before ending, extract durable memories from this session:
840
+ ```bash
841
+ peaks project memories:extract --session-id <session-id> --project <repo> --json
842
+ ```
843
+
836
844
  ## Peaks-Cli External references and lifecycle
837
845
 
838
846
  **Codegraph**: Optional project-analysis before RD handoff. Use `peaks codegraph affected --project <path> <changed-files...> --json` for regression-surface hints. Output as untrusted supporting evidence only; never commit `.codegraph/` artifacts.
@@ -97,6 +97,22 @@ The three gate primitives are domain-neutral, so the same engine governs very di
97
97
 
98
98
  The one boundary to explain: a gate must reduce to **a file existing, text matching in a file, or a command's exit code**. A purely human-judgment gate ("did the editor approve?") is expressed by reifying it into a signal — e.g. require an `approved.md` file, or that a status file contains "approved". The `command` gate is the universal adapter for anything scriptable.
99
99
 
100
+ ### Literal-word trap and `stripMeta` (added by PRD 006 on 2026-06-02)
101
+
102
+ A naive `grep` gate for a content-publishing SOP has a self-referential failure mode: the author writing *about* the gate's pattern ("we use a `grep absent: "TODO"` gate to block leftover T-O-D-O") ends up triggering the gate they just described. This is rare in code-review SOPs and very common in content/publishing SOPs.
103
+
104
+ **Opt-in workaround**: add `stripMeta: true` to the gate's check. The evaluator strips HTML comments (`<!-- … -->`), fenced code blocks (` ``` … ``` `), and C-style block comments (`/* … */`) from the file content *before* applying the regex. The author's discussion of the gate in those areas is no longer counted.
105
+
106
+ ```json
107
+ {
108
+ "id": "no-todo",
109
+ "phase": "publish",
110
+ "check": { "type": "grep", "file": "post.md", "pattern": "TODO", "absent": true, "stripMeta": true }
111
+ }
112
+ ```
113
+
114
+ Default `false` preserves byte-identical behavior for existing SOPs. The stripper is conservative on malformed input (unclosed fences / block comments fall through un-stripped). Not covered by this slice: inline code (`` `TODO` ``) and blockquotes (`> TODO`) — both are content the author explicitly chose to render, so they remain subject to the regex. If a future dogfood surfaces a need to strip those too, open a follow-up PRD.
115
+
100
116
  ## Un-bypassable enforcement (optional, opt-in)
101
117
 
102
118
  By default a SOP gate only blocks the `peaks sop advance` command — nothing forces the agent through it. To make a gate **physically un-bypassable**, a SOP can declare **guards** that bind a concrete irreversible Bash action to a phase, and the user installs a PreToolUse hook:
@@ -162,6 +178,7 @@ peaks hooks status --project <repo>
162
178
  peaks gate bypass --sop <sop-id> --phase <phase> --reason "<why>" --project <repo>
163
179
 
164
180
  # 9. hand the SOP to the user; clear presence when done
181
+ peaks project memories:extract --session-id <session-id> --project <repo> --json # extract durable memories
165
182
  peaks skill presence:clear --project <repo>
166
183
  ```
167
184
 
@@ -8,7 +8,7 @@ interview → generate → debug loop, and security notes. The skill drives the
8
8
 
9
9
  SOP **definitions** live in one of two layers:
10
10
  - **Global** `~/.peaks/sops/<sop-id>/sop.json` (+ `SKILL.md`) — personal, reusable across every project. `init` / `lint` / `register` default here.
11
- - **Project** `<project>/.peaks/sops/<sop-id>/sop.json` — committed into the repo and team-shared. Pass `--project <repo>` to `init` / `lint` / `register` to use this layer. The project layer **wins** over global for the same id; execution reads (`check`/`advance`/enforce) and `sop registry --project` see the merged view.
11
+ - **Project** `<project>/.peaks/sops/<sop-id>/sop.json` — committed into the repo and team-shared. Pass `--project <repo>` to `init` / `lint` / `register` to use this layer. The project layer **wins** over global for the same id; execution reads (`check` / `advance` / `gate enforce` / `registry`) default `--project` to the current directory, so they see the merged view without an explicit flag.
12
12
 
13
13
  A SOP's **run-state** is always per-project: `<project>/.peaks/sop-state/<sop-id>/state.json` (git-ignored — runtime, not shared). `check` / `advance` take `--project` (default: current directory) — that says which project the gate paths resolve against, whose progress advances, and which definition layer wins.
14
14
 
@@ -118,7 +118,7 @@ Stable memory body.
118
118
  <!-- peaks-memory:end -->
119
119
  ```
120
120
 
121
- The primary write target is the target project's `.peaks/memory`. Use `peaks memory extract --project <path> --artifact <artifact>` to write durable project memories; pass `--dry-run` to preview without writing.
121
+ The primary write target is the target project's `.peaks/memory`. Use `peaks memory extract --project <path> --artifact <artifact> --apply` to write durable project memories; omit `--apply` to preview without writing.
122
122
 
123
123
  ## Matt Pocock skills integration
124
124
 
@@ -190,13 +190,28 @@ peaks understand show --project <repo> --json
190
190
  # 4. Discover external capabilities before recommending memory or context tools
191
191
  peaks capabilities --json
192
192
 
193
- # 5. Memory extraction writes by default, use --dry-run to preview
194
- peaks memory extract --project <repo> --artifact <artifact-path> --json
195
- peaks memory extract --project <repo> --artifact <artifact-path> --dry-run --json # preview only
193
+ # 5. Write the handoff capsule (see template above), then embed memory markers
194
+ # For each stable project fact, decision, rule, or convention discovered this session,
195
+ # append a <!-- peaks-memory:start --> block inside the capsule body:
196
+ #
197
+ # <!-- peaks-memory:start -->
198
+ # title: Short project memory title
199
+ # kind: project | decision | convention | rule | reference | module
200
+ # ---
201
+ # Stable memory body. Concrete facts only — no secrets, no transient state.
202
+ # <!-- peaks-memory:end -->
203
+ #
204
+ # Mark ONLY facts that survive the session: architectural decisions, stack constraints,
205
+ # naming conventions, API patterns, approved refactors. Do NOT embed: secrets, credentials,
206
+ # transient debugging notes, or session-specific context.
207
+
208
+ # 6. Memory extraction — --apply is REQUIRED to write .peaks/memory
209
+ # (without --apply the command only previews; the directory will NOT be created)
210
+ peaks memory extract --project <repo> --artifact .peaks/<id>/txt/handoff.md --apply --json
196
211
  peaks skill presence:clear --project <repo> # handoff capsule complete, remove presence indicator
197
212
  ```
198
213
 
199
- The default `peaks memory extract` call writes to `.peaks/memory`. Pass `--dry-run` to preview without writing.
214
+ `peaks memory extract --apply` writes to `.peaks/memory` (without `--apply` it only previews). The handoff capsule `.peaks/<id>/txt/handoff.md` is the primary artifact for extraction — embed `<!-- peaks-memory:start -->` blocks in it for stable project facts before running extract.
200
215
 
201
216
  ### Transition verification gates (MANDATORY — run the command, see the output)
202
217