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.
- package/dist/src/cli/commands/core-artifact-commands.js +14 -6
- package/dist/src/cli/commands/project-commands.js +51 -1
- package/dist/src/cli/commands/sop-commands.js +2 -2
- package/dist/src/services/memory/project-memory-service.d.ts +32 -0
- package/dist/src/services/memory/project-memory-service.js +293 -34
- 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/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +1 -1
- package/skills/peaks-qa/SKILL.md +1 -0
- package/skills/peaks-rd/SKILL.md +15 -0
- package/skills/peaks-solo/SKILL.md +10 -2
- package/skills/peaks-sop/SKILL.md +17 -0
- package/skills/peaks-sop/references/sop-authoring.md +1 -1
- package/skills/peaks-txt/SKILL.md +20 -5
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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;
|
|
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
|
|
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,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.
|
|
1
|
+
export declare const CLI_VERSION = "1.2.4";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "1.2.
|
|
1
|
+
export const CLI_VERSION = "1.2.4";
|
package/package.json
CHANGED
package/skills/peaks-qa/SKILL.md
CHANGED
|
@@ -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
|
|
package/skills/peaks-rd/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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.
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
|