peaks-cli 1.2.3 → 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/bin/peaks.js +0 -0
- 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 +1 -0
- package/skills/peaks-solo/SKILL.md +5 -0
- package/skills/peaks-sop/SKILL.md +17 -0
- package/skills/peaks-sop/references/sop-authoring.md +1 -1
package/bin/peaks.js
CHANGED
|
File without changes
|
|
@@ -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
|
@@ -165,6 +165,7 @@ peaks openspec validate <change-id> --project <repo> --json # exit gate (re-r
|
|
|
165
165
|
# 8. hand off to QA via the cross-linked request id
|
|
166
166
|
peaks request init --role qa --id <request-id> --project <repo> --apply --json
|
|
167
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
|
|
168
169
|
peaks skill presence:clear --project <repo> # handoff complete, remove presence indicator
|
|
169
170
|
```
|
|
170
171
|
|
|
@@ -836,6 +836,11 @@ Use Peaks-Cli TXT for the compact handoff capsule: mode, validated decisions, ar
|
|
|
836
836
|
|
|
837
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.
|
|
838
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
|
+
|
|
839
844
|
## Peaks-Cli External references and lifecycle
|
|
840
845
|
|
|
841
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
|
|