peaks-cli 1.0.28 → 1.1.1
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/core-artifact-commands.js +4 -4
- package/dist/src/cli/commands/project-commands.js +23 -101
- package/dist/src/cli/commands/statusline-commands.d.ts +3 -0
- package/dist/src/cli/commands/statusline-commands.js +111 -0
- package/dist/src/cli/program.js +2 -0
- package/dist/src/services/doctor/doctor-service.d.ts +1 -0
- package/dist/src/services/doctor/doctor-service.js +40 -0
- package/dist/src/services/memory/project-context-service.d.ts +0 -38
- package/dist/src/services/memory/project-context-service.js +2 -304
- package/dist/src/services/memory/project-memory-service.d.ts +17 -1
- package/dist/src/services/memory/project-memory-service.js +72 -4
- package/dist/src/services/skills/skill-statusline-renderer.d.ts +6 -0
- package/dist/src/services/skills/skill-statusline-renderer.js +55 -0
- package/dist/src/services/skills/skill-statusline-service.d.ts +22 -0
- package/dist/src/services/skills/skill-statusline-service.js +94 -0
- package/dist/src/services/skills/statusline-settings-service.d.ts +32 -0
- package/dist/src/services/skills/statusline-settings-service.js +144 -0
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +1 -1
- package/schemas/doctor-report.schema.json +2 -2
- package/skills/peaks-prd/SKILL.md +10 -3
- package/skills/peaks-qa/SKILL.md +10 -3
- package/skills/peaks-rd/SKILL.md +10 -3
- package/skills/peaks-sc/SKILL.md +11 -4
- package/skills/peaks-solo/SKILL.md +16 -9
- package/skills/peaks-txt/SKILL.md +12 -5
- package/skills/peaks-ui/SKILL.md +10 -3
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { join
|
|
2
|
+
import { join } from 'node:path';
|
|
3
3
|
import { listSessionMetas } from '../session/session-manager.js';
|
|
4
4
|
const PROJECT_CONTEXT_FILE = '.peaks/PROJECT.md';
|
|
5
|
-
const ONTOLOGY_FILE = '.peaks/ontology.json';
|
|
6
5
|
const CONTEXT_HEADER = `# Peaks Project Context
|
|
7
6
|
|
|
8
7
|
> Auto-generated project memory. Peaks reads this at the start of each session to understand
|
|
@@ -46,26 +45,6 @@ function listMdFiles(dir, maxDepth = 3) {
|
|
|
46
45
|
}
|
|
47
46
|
return results;
|
|
48
47
|
}
|
|
49
|
-
function extractArtifactSummary(filePath, sessionRoot) {
|
|
50
|
-
try {
|
|
51
|
-
const content = readFileSync(filePath, 'utf8');
|
|
52
|
-
const lines = content.split(/\r?\n/);
|
|
53
|
-
const firstHeading = lines.find((l) => /^#\s/.test(l))?.replace(/^#\s+/, '').trim();
|
|
54
|
-
const stateLine = lines.find((l) => /^\-\s*state:\s*/.test(l))?.trim();
|
|
55
|
-
const relPath = relative(sessionRoot, filePath).split(/[\\/]/).join('/');
|
|
56
|
-
const parts = [];
|
|
57
|
-
if (firstHeading)
|
|
58
|
-
parts.push(firstHeading);
|
|
59
|
-
if (stateLine)
|
|
60
|
-
parts.push(stateLine.replace(/^-\s*state:\s*/, ''));
|
|
61
|
-
if (parts.length === 0)
|
|
62
|
-
return `- \`${relPath}\``;
|
|
63
|
-
return `- \`${relPath}\` — ${parts.join(' | ')}`;
|
|
64
|
-
}
|
|
65
|
-
catch {
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
48
|
function extractOneLineSummary(sessionRoot) {
|
|
70
49
|
const artifacts = listMdFiles(sessionRoot, 4);
|
|
71
50
|
for (const artifact of artifacts.slice(0, 5)) {
|
|
@@ -88,27 +67,6 @@ function extractOneLineSummary(sessionRoot) {
|
|
|
88
67
|
}
|
|
89
68
|
return null;
|
|
90
69
|
}
|
|
91
|
-
function renderSessionBlock(meta, projectRoot) {
|
|
92
|
-
const title = meta.title ?? 'Untitled';
|
|
93
|
-
const date = meta.createdAt ? meta.createdAt.slice(0, 10) : '?';
|
|
94
|
-
const skill = meta.skill ?? '-';
|
|
95
|
-
const mode = meta.mode ?? '-';
|
|
96
|
-
let block = `### ${date} — ${title}\n`;
|
|
97
|
-
block += `- ${skill} (${mode})`;
|
|
98
|
-
const sessionRoot = join(projectRoot, '.peaks', meta.sessionId);
|
|
99
|
-
const summary = extractOneLineSummary(sessionRoot);
|
|
100
|
-
if (summary) {
|
|
101
|
-
block += ` — ${summary.slice(0, 120)}`;
|
|
102
|
-
}
|
|
103
|
-
block += '\n';
|
|
104
|
-
// Key artifact paths only
|
|
105
|
-
const artifacts = listMdFiles(sessionRoot, 3);
|
|
106
|
-
if (artifacts.length > 0) {
|
|
107
|
-
const paths = artifacts.slice(0, 8).map((f) => relative(sessionRoot, f).split(/[\\/]/).join('/'));
|
|
108
|
-
block += ` ${paths.join(' ')}\n`;
|
|
109
|
-
}
|
|
110
|
-
return block;
|
|
111
|
-
}
|
|
112
70
|
function buildSessionHistory(projectRoot) {
|
|
113
71
|
const metas = listSessionMetas(projectRoot);
|
|
114
72
|
if (metas.length === 0) {
|
|
@@ -139,263 +97,6 @@ function buildSessionHistory(projectRoot) {
|
|
|
139
97
|
body += `\n${MANAGED_BLOCK_END}`;
|
|
140
98
|
return body;
|
|
141
99
|
}
|
|
142
|
-
// --- Ontology engine ---
|
|
143
|
-
function emptyOntology(projectName) {
|
|
144
|
-
return {
|
|
145
|
-
version: 1,
|
|
146
|
-
updated: new Date().toISOString(),
|
|
147
|
-
project: projectName,
|
|
148
|
-
modules: [],
|
|
149
|
-
decisions: [],
|
|
150
|
-
conventions: []
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
function ontoPath(projectRoot) {
|
|
154
|
-
return join(projectRoot, ONTOLOGY_FILE);
|
|
155
|
-
}
|
|
156
|
-
export function loadOntology(projectRoot) {
|
|
157
|
-
const path = ontoPath(projectRoot);
|
|
158
|
-
if (!existsSync(path))
|
|
159
|
-
return null;
|
|
160
|
-
try {
|
|
161
|
-
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
162
|
-
if (raw?.version === 1 && Array.isArray(raw.modules)) {
|
|
163
|
-
return raw;
|
|
164
|
-
}
|
|
165
|
-
return null;
|
|
166
|
-
}
|
|
167
|
-
catch {
|
|
168
|
-
return null;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
function slugify(text) {
|
|
172
|
-
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'unknown';
|
|
173
|
-
}
|
|
174
|
-
function scanModulesFromArtifacts(sessionRoot, sessionId) {
|
|
175
|
-
const artifacts = listMdFiles(sessionRoot, 4);
|
|
176
|
-
const modules = [];
|
|
177
|
-
const seen = new Set();
|
|
178
|
-
for (const artifact of artifacts.slice(0, 10)) {
|
|
179
|
-
try {
|
|
180
|
-
const content = readFileSync(artifact, 'utf8');
|
|
181
|
-
// Extract file paths — non-capturing groups for extensions
|
|
182
|
-
const patterns = [
|
|
183
|
-
/\b(src\/[^\s)`\]}"]+\.(?:tsx?|jsx?|css|less|scss|vue|svelte))\b/g,
|
|
184
|
-
/\b(packages\/[^\s)`\]}"]+\.(?:tsx?|jsx?))\b/g
|
|
185
|
-
];
|
|
186
|
-
for (const pattern of patterns) {
|
|
187
|
-
let m;
|
|
188
|
-
while ((m = pattern.exec(content)) !== null) {
|
|
189
|
-
const filePath = m[1] ?? '';
|
|
190
|
-
if (!filePath || filePath.length > 120 || filePath.length < 5)
|
|
191
|
-
continue;
|
|
192
|
-
const id = slugify(filePath.replace(/\.[^.]+$/, '').replace(/[\/\\]/g, '-'));
|
|
193
|
-
if (seen.has(id))
|
|
194
|
-
continue;
|
|
195
|
-
seen.add(id);
|
|
196
|
-
modules.push({ id, path: filePath });
|
|
197
|
-
if (modules.length >= 30)
|
|
198
|
-
break;
|
|
199
|
-
}
|
|
200
|
-
if (modules.length >= 30)
|
|
201
|
-
break;
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
catch {
|
|
205
|
-
// skip unreadable
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
return modules;
|
|
209
|
-
}
|
|
210
|
-
function scanDecisionsFromArtifacts(sessionRoot, session) {
|
|
211
|
-
const artifacts = listMdFiles(sessionRoot, 4);
|
|
212
|
-
const decisions = [];
|
|
213
|
-
const date = session.createdAt ? session.createdAt.slice(0, 10) : new Date().toISOString().slice(0, 10);
|
|
214
|
-
for (const artifact of artifacts.slice(0, 10)) {
|
|
215
|
-
try {
|
|
216
|
-
const content = readFileSync(artifact, 'utf8');
|
|
217
|
-
// Look for decision markers: "- Decision: ..." or "Decision: ..." or "ADR: ..."
|
|
218
|
-
const decRegex = /^[\s-]*(?:decision|adr|决定|决策)\s*:\s*(.+?)$/gim;
|
|
219
|
-
let m;
|
|
220
|
-
while ((m = decRegex.exec(content)) !== null) {
|
|
221
|
-
const what = (m[1] ?? '').trim().slice(0, 200);
|
|
222
|
-
if (what.length < 5)
|
|
223
|
-
continue;
|
|
224
|
-
const id = slugify(what.slice(0, 40));
|
|
225
|
-
// Collect scope from surrounding context (modules mentioned within 3 lines before/after)
|
|
226
|
-
const scope = [];
|
|
227
|
-
const lineIdx = content.slice(0, m.index).split('\n').length;
|
|
228
|
-
const lines = content.split('\n');
|
|
229
|
-
for (let i = Math.max(0, lineIdx - 3); i < Math.min(lines.length, lineIdx + 3); i++) {
|
|
230
|
-
const line = lines[i] ?? '';
|
|
231
|
-
const pathMatch = /src\/[^\s)`\]}"]+\.(tsx?|jsx?)/.exec(line);
|
|
232
|
-
if (pathMatch?.[0])
|
|
233
|
-
scope.push(pathMatch[0]);
|
|
234
|
-
}
|
|
235
|
-
decisions.push({ id, what, scope: [...new Set(scope)].slice(0, 5), session: session.sessionId, date });
|
|
236
|
-
if (decisions.length >= 10)
|
|
237
|
-
break;
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
catch {
|
|
241
|
-
// skip unreadable
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
return decisions;
|
|
245
|
-
}
|
|
246
|
-
function scanConventionsFromArtifacts(sessionRoot, session) {
|
|
247
|
-
const artifacts = listMdFiles(sessionRoot, 4);
|
|
248
|
-
const conventions = [];
|
|
249
|
-
const date = session.createdAt ? session.createdAt.slice(0, 10) : new Date().toISOString().slice(0, 10);
|
|
250
|
-
for (const artifact of artifacts.slice(0, 10)) {
|
|
251
|
-
try {
|
|
252
|
-
const content = readFileSync(artifact, 'utf8');
|
|
253
|
-
const convRegex = /^[\s-]*(?:convention|约定|规范)\s*:\s*(.+?)$/gim;
|
|
254
|
-
let m;
|
|
255
|
-
while ((m = convRegex.exec(content)) !== null) {
|
|
256
|
-
const rule = (m[1] ?? '').trim().slice(0, 200);
|
|
257
|
-
if (rule.length < 5)
|
|
258
|
-
continue;
|
|
259
|
-
const id = slugify(rule.slice(0, 40));
|
|
260
|
-
// Infer category from keywords
|
|
261
|
-
let category = 'other';
|
|
262
|
-
if (/class|function|interface|type|hook|component/i.test(rule))
|
|
263
|
-
category = 'code-style';
|
|
264
|
-
else if (/service|layer|package|module|shared|extract/i.test(rule))
|
|
265
|
-
category = 'architecture';
|
|
266
|
-
else if (/naming|命名|文件名|prefix|suffix/i.test(rule))
|
|
267
|
-
category = 'naming';
|
|
268
|
-
else if (/tooling|lint|format|build|test/i.test(rule))
|
|
269
|
-
category = 'tooling';
|
|
270
|
-
conventions.push({ id, rule, category, source: session.sessionId, date });
|
|
271
|
-
if (conventions.length >= 10)
|
|
272
|
-
break;
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
catch {
|
|
276
|
-
// skip unreadable
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
return conventions;
|
|
280
|
-
}
|
|
281
|
-
function buildOntology(projectRoot) {
|
|
282
|
-
const name = projectName(projectRoot);
|
|
283
|
-
const existing = loadOntology(projectRoot);
|
|
284
|
-
const onto = existing ?? emptyOntology(name);
|
|
285
|
-
onto.updated = new Date().toISOString();
|
|
286
|
-
onto.project = name;
|
|
287
|
-
const metas = listSessionMetas(projectRoot);
|
|
288
|
-
const knownSessions = new Set(metas.map((m) => m.sessionId));
|
|
289
|
-
// Prune: remove modules/decisions/conventions from sessions that no longer exist
|
|
290
|
-
onto.modules = onto.modules.filter((m) => m.sessions.some((s) => knownSessions.has(s)));
|
|
291
|
-
onto.decisions = onto.decisions.filter((d) => knownSessions.has(d.session));
|
|
292
|
-
onto.conventions = onto.conventions.filter((c) => knownSessions.has(c.source));
|
|
293
|
-
// Merge: scan each session for new modules and decisions
|
|
294
|
-
const moduleMap = new Map();
|
|
295
|
-
for (const m of onto.modules)
|
|
296
|
-
moduleMap.set(m.id, m);
|
|
297
|
-
const decisionMap = new Map();
|
|
298
|
-
for (const d of onto.decisions)
|
|
299
|
-
decisionMap.set(d.id, d);
|
|
300
|
-
for (const meta of metas) {
|
|
301
|
-
const sessionRoot = join(projectRoot, '.peaks', meta.sessionId);
|
|
302
|
-
// Modules
|
|
303
|
-
const foundModules = scanModulesFromArtifacts(sessionRoot, meta.sessionId);
|
|
304
|
-
for (const fm of foundModules) {
|
|
305
|
-
if (moduleMap.has(fm.id)) {
|
|
306
|
-
const existing = moduleMap.get(fm.id);
|
|
307
|
-
if (!existing.sessions.includes(meta.sessionId)) {
|
|
308
|
-
existing.sessions.push(meta.sessionId);
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
else {
|
|
312
|
-
moduleMap.set(fm.id, {
|
|
313
|
-
id: fm.id,
|
|
314
|
-
path: fm.path,
|
|
315
|
-
sessions: [meta.sessionId]
|
|
316
|
-
});
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
// Decisions
|
|
320
|
-
const foundDecisions = scanDecisionsFromArtifacts(sessionRoot, meta);
|
|
321
|
-
for (const fd of foundDecisions) {
|
|
322
|
-
if (!decisionMap.has(fd.id)) {
|
|
323
|
-
decisionMap.set(fd.id, fd);
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
// Conventions
|
|
327
|
-
const foundConventions = scanConventionsFromArtifacts(sessionRoot, meta);
|
|
328
|
-
const convMap = new Map();
|
|
329
|
-
for (const c of onto.conventions)
|
|
330
|
-
convMap.set(c.id, c);
|
|
331
|
-
for (const fc of foundConventions) {
|
|
332
|
-
if (!convMap.has(fc.id)) {
|
|
333
|
-
convMap.set(fc.id, fc);
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
onto.conventions = [...convMap.values()].sort((a, b) => a.date.localeCompare(b.date));
|
|
337
|
-
}
|
|
338
|
-
// Dedup: remove shorter paths that are substring-matches of longer paths
|
|
339
|
-
const modules = [...moduleMap.values()];
|
|
340
|
-
const deduped = modules.filter((m) => {
|
|
341
|
-
return !modules.some((other) => other !== m && other.path.length > m.path.length && other.path.endsWith(m.path));
|
|
342
|
-
});
|
|
343
|
-
onto.modules = deduped.sort((a, b) => b.sessions.length - a.sessions.length);
|
|
344
|
-
onto.decisions = [...decisionMap.values()].sort((a, b) => b.date.localeCompare(a.date));
|
|
345
|
-
return onto;
|
|
346
|
-
}
|
|
347
|
-
export function saveOntology(projectRoot, onto) {
|
|
348
|
-
const peaksDir = join(projectRoot, '.peaks');
|
|
349
|
-
if (!existsSync(peaksDir))
|
|
350
|
-
mkdirSync(peaksDir, { recursive: true });
|
|
351
|
-
writeFileSync(ontoPath(projectRoot), JSON.stringify(onto, null, 2), 'utf8');
|
|
352
|
-
}
|
|
353
|
-
// Mutations for skills to call when they discover new facts
|
|
354
|
-
export function upsertModule(projectRoot, mod) {
|
|
355
|
-
const onto = buildOntology(projectRoot);
|
|
356
|
-
const existing = onto.modules.find((m) => m.id === mod.id);
|
|
357
|
-
if (existing) {
|
|
358
|
-
if (!existing.sessions.includes(mod.session))
|
|
359
|
-
existing.sessions.push(mod.session);
|
|
360
|
-
if (mod.risk)
|
|
361
|
-
existing.risk = mod.risk;
|
|
362
|
-
if (mod.summary)
|
|
363
|
-
existing.summary = mod.summary;
|
|
364
|
-
}
|
|
365
|
-
else {
|
|
366
|
-
onto.modules.push({ ...mod, sessions: [mod.session] });
|
|
367
|
-
}
|
|
368
|
-
onto.updated = new Date().toISOString();
|
|
369
|
-
saveOntology(projectRoot, onto);
|
|
370
|
-
return onto;
|
|
371
|
-
}
|
|
372
|
-
export function upsertDecision(projectRoot, dec) {
|
|
373
|
-
const onto = buildOntology(projectRoot);
|
|
374
|
-
const idx = onto.decisions.findIndex((d) => d.id === dec.id);
|
|
375
|
-
if (idx >= 0) {
|
|
376
|
-
onto.decisions[idx] = dec;
|
|
377
|
-
}
|
|
378
|
-
else {
|
|
379
|
-
onto.decisions.push(dec);
|
|
380
|
-
}
|
|
381
|
-
onto.updated = new Date().toISOString();
|
|
382
|
-
saveOntology(projectRoot, onto);
|
|
383
|
-
return onto;
|
|
384
|
-
}
|
|
385
|
-
export function upsertConvention(projectRoot, conv) {
|
|
386
|
-
const onto = buildOntology(projectRoot);
|
|
387
|
-
const idx = onto.conventions.findIndex((c) => c.id === conv.id);
|
|
388
|
-
if (idx >= 0) {
|
|
389
|
-
onto.conventions[idx] = conv;
|
|
390
|
-
}
|
|
391
|
-
else {
|
|
392
|
-
onto.conventions.push(conv);
|
|
393
|
-
}
|
|
394
|
-
onto.updated = new Date().toISOString();
|
|
395
|
-
saveOntology(projectRoot, onto);
|
|
396
|
-
return onto;
|
|
397
|
-
}
|
|
398
|
-
// --- Context generator (unified: PROJECT.md + ontology.json) ---
|
|
399
100
|
export function generateProjectContext(projectRoot) {
|
|
400
101
|
const peaksDir = join(projectRoot, '.peaks');
|
|
401
102
|
if (!existsSync(peaksDir)) {
|
|
@@ -428,10 +129,7 @@ export function generateProjectContext(projectRoot) {
|
|
|
428
129
|
content = header + '\n' + sessionHistory + '\n';
|
|
429
130
|
}
|
|
430
131
|
writeFileSync(contextPath, content, 'utf8');
|
|
431
|
-
|
|
432
|
-
const ontology = buildOntology(projectRoot);
|
|
433
|
-
saveOntology(projectRoot, ontology);
|
|
434
|
-
return { path: contextPath, content, sessionCount: listSessionMetas(projectRoot).length, ontology };
|
|
132
|
+
return { path: contextPath, content, sessionCount: listSessionMetas(projectRoot).length };
|
|
435
133
|
}
|
|
436
134
|
export function readProjectContext(projectRoot) {
|
|
437
135
|
const contextPath = join(projectRoot, PROJECT_CONTEXT_FILE);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type ProjectMemoryKind = 'project' | 'rule' | 'decision' | 'reference' | 'feedback';
|
|
1
|
+
export type ProjectMemoryKind = 'project' | 'rule' | 'decision' | 'reference' | 'feedback' | 'convention' | 'module';
|
|
2
2
|
export type ExtractedProjectMemory = {
|
|
3
3
|
title: string;
|
|
4
4
|
kind: ProjectMemoryKind;
|
|
@@ -59,6 +59,21 @@ export type ProjectMemoryBackupPlan = {
|
|
|
59
59
|
export type ProjectMemoryBackupResult = ProjectMemoryBackupPlan & {
|
|
60
60
|
copiedFiles: string[];
|
|
61
61
|
};
|
|
62
|
+
export type StoredProjectMemory = {
|
|
63
|
+
name: string;
|
|
64
|
+
title: string;
|
|
65
|
+
kind: ProjectMemoryKind;
|
|
66
|
+
sourceArtifact: string | null;
|
|
67
|
+
body: string;
|
|
68
|
+
filePath: string;
|
|
69
|
+
};
|
|
70
|
+
export type ProjectMemoryReadResult = {
|
|
71
|
+
projectRoot: string;
|
|
72
|
+
memoryDir: string;
|
|
73
|
+
total: number;
|
|
74
|
+
byKind: Record<ProjectMemoryKind, StoredProjectMemory[]>;
|
|
75
|
+
memories: StoredProjectMemory[];
|
|
76
|
+
};
|
|
62
77
|
type ExtractPlanOptions = {
|
|
63
78
|
projectRoot: string;
|
|
64
79
|
artifactPaths: string[];
|
|
@@ -76,4 +91,5 @@ export declare function createProjectMemoryBackupPlan(options: BackupPlanOptions
|
|
|
76
91
|
export declare function executeProjectMemoryBackup(options: BackupPlanOptions): ProjectMemoryBackupResult;
|
|
77
92
|
export declare function summarizeProjectMemoryExtractResult(result: ProjectMemoryExtractResult): ProjectMemoryExtractSummary;
|
|
78
93
|
export declare function summarizeProjectMemoryBackupResult(result: ProjectMemoryBackupResult): ProjectMemoryBackupSummary;
|
|
94
|
+
export declare function readProjectMemories(projectRoot: string): ProjectMemoryReadResult;
|
|
79
95
|
export {};
|
|
@@ -4,7 +4,7 @@ import { isInsidePath, isWindowsAbsolutePath, normalizePath, resolveInputPath, s
|
|
|
4
4
|
import { containsSensitiveConfigValue, isSensitiveConfigPath } from '../config/config-service.js';
|
|
5
5
|
const START_MARKER = '<!-- peaks-memory:start -->';
|
|
6
6
|
const END_MARKER = '<!-- peaks-memory:end -->';
|
|
7
|
-
const VALID_MEMORY_KINDS = new Set(['project', 'rule', 'decision', 'reference', 'feedback']);
|
|
7
|
+
const VALID_MEMORY_KINDS = new Set(['project', 'rule', 'decision', 'reference', 'feedback', 'convention', 'module']);
|
|
8
8
|
function normalizeRoot(path) {
|
|
9
9
|
return resolveInputPath(path);
|
|
10
10
|
}
|
|
@@ -42,11 +42,11 @@ function assertInsideProject(path, projectRoot) {
|
|
|
42
42
|
function assertSafeProjectMemoryDir(projectRoot) {
|
|
43
43
|
const resolvedRoot = normalizeRoot(projectRoot);
|
|
44
44
|
const realRoot = normalizeRealRoot(projectRoot);
|
|
45
|
-
const
|
|
46
|
-
if (existsSync(
|
|
45
|
+
const peaksDir = join(resolvedRoot, '.peaks');
|
|
46
|
+
if (existsSync(peaksDir) && lstatSync(peaksDir).isSymbolicLink()) {
|
|
47
47
|
throw new Error('Project memory directory must stay inside the project root');
|
|
48
48
|
}
|
|
49
|
-
const memoryDir = join(
|
|
49
|
+
const memoryDir = join(peaksDir, 'memory');
|
|
50
50
|
if (existsSync(memoryDir)) {
|
|
51
51
|
if (lstatSync(memoryDir).isSymbolicLink()) {
|
|
52
52
|
throw new Error('Project memory directory must stay inside the project root');
|
|
@@ -136,6 +136,41 @@ function renderMemoryFile(memory) {
|
|
|
136
136
|
''
|
|
137
137
|
].join('\n');
|
|
138
138
|
}
|
|
139
|
+
function parseStoredMemoryFile(content, filePath) {
|
|
140
|
+
const normalized = content.replace(/\r\n/g, '\n');
|
|
141
|
+
if (!normalized.startsWith('---\n'))
|
|
142
|
+
return null;
|
|
143
|
+
const endIndex = normalized.indexOf('\n---\n', 4);
|
|
144
|
+
if (endIndex < 0)
|
|
145
|
+
return null;
|
|
146
|
+
const frontmatter = normalized.slice(4, endIndex);
|
|
147
|
+
const body = normalized.slice(endIndex + '\n---\n'.length).trim();
|
|
148
|
+
let name;
|
|
149
|
+
let description;
|
|
150
|
+
let kind;
|
|
151
|
+
let sourceArtifact;
|
|
152
|
+
for (const rawLine of frontmatter.split('\n')) {
|
|
153
|
+
const line = rawLine.trim();
|
|
154
|
+
if (line.startsWith('name:'))
|
|
155
|
+
name = line.slice('name:'.length).trim();
|
|
156
|
+
else if (line.startsWith('description:'))
|
|
157
|
+
description = line.slice('description:'.length).trim();
|
|
158
|
+
else if (line.startsWith('type:'))
|
|
159
|
+
kind = line.slice('type:'.length).trim();
|
|
160
|
+
else if (line.startsWith('sourceArtifact:'))
|
|
161
|
+
sourceArtifact = line.slice('sourceArtifact:'.length).trim();
|
|
162
|
+
}
|
|
163
|
+
if (!name || !kind || !VALID_MEMORY_KINDS.has(kind) || body.length === 0)
|
|
164
|
+
return null;
|
|
165
|
+
return {
|
|
166
|
+
name,
|
|
167
|
+
title: description ?? name,
|
|
168
|
+
kind: kind,
|
|
169
|
+
sourceArtifact: sourceArtifact && sourceArtifact !== 'undefined' ? sourceArtifact : null,
|
|
170
|
+
body,
|
|
171
|
+
filePath
|
|
172
|
+
};
|
|
173
|
+
}
|
|
139
174
|
function summarizeExtractResult(result) {
|
|
140
175
|
return {
|
|
141
176
|
apply: result.apply,
|
|
@@ -304,3 +339,36 @@ export function summarizeProjectMemoryExtractResult(result) {
|
|
|
304
339
|
export function summarizeProjectMemoryBackupResult(result) {
|
|
305
340
|
return summarizeBackupResult(result);
|
|
306
341
|
}
|
|
342
|
+
function emptyByKind() {
|
|
343
|
+
return {
|
|
344
|
+
project: [],
|
|
345
|
+
rule: [],
|
|
346
|
+
decision: [],
|
|
347
|
+
reference: [],
|
|
348
|
+
feedback: [],
|
|
349
|
+
convention: [],
|
|
350
|
+
module: []
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
export function readProjectMemories(projectRoot) {
|
|
354
|
+
const normalizedRoot = normalizeRoot(projectRoot);
|
|
355
|
+
const memoryDir = assertSafeProjectMemoryDir(normalizedRoot);
|
|
356
|
+
const memories = [];
|
|
357
|
+
for (const filePath of listMarkdownFiles(memoryDir)) {
|
|
358
|
+
const parsed = parseStoredMemoryFile(readFileSync(filePath, 'utf8'), filePath);
|
|
359
|
+
if (parsed)
|
|
360
|
+
memories.push(parsed);
|
|
361
|
+
}
|
|
362
|
+
memories.sort((left, right) => left.name.localeCompare(right.name));
|
|
363
|
+
const byKind = emptyByKind();
|
|
364
|
+
for (const memory of memories) {
|
|
365
|
+
byKind[memory.kind].push(memory);
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
projectRoot: normalizedRoot,
|
|
369
|
+
memoryDir,
|
|
370
|
+
total: memories.length,
|
|
371
|
+
byKind,
|
|
372
|
+
memories
|
|
373
|
+
};
|
|
374
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { StatusLineModel } from './skill-statusline-service.js';
|
|
2
|
+
/**
|
|
3
|
+
* Render the status line. The output is plain text with simple status glyphs so
|
|
4
|
+
* it stays readable in any terminal; Claude Code applies its own styling.
|
|
5
|
+
*/
|
|
6
|
+
export declare function renderStatusLine(model: StatusLineModel): string;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
/**
|
|
3
|
+
* Pure formatting layer for the Peaks statusLine. Takes the read-only status
|
|
4
|
+
* model and produces the single line Claude Code paints at the bottom of the
|
|
5
|
+
* terminal. Kept separate from the reader so formatting can be tested without
|
|
6
|
+
* touching the filesystem.
|
|
7
|
+
*/
|
|
8
|
+
const BRAND = '⛰ Peaks';
|
|
9
|
+
function formatAge(ageMs) {
|
|
10
|
+
if (ageMs === null)
|
|
11
|
+
return '';
|
|
12
|
+
const hours = Math.round(ageMs / (60 * 60 * 1000));
|
|
13
|
+
if (hours >= 1)
|
|
14
|
+
return `stale ${hours}h`;
|
|
15
|
+
const minutes = Math.max(1, Math.round(ageMs / (60 * 1000)));
|
|
16
|
+
return `stale ${minutes}m`;
|
|
17
|
+
}
|
|
18
|
+
function rootLabel(projectRoot) {
|
|
19
|
+
if (!projectRoot)
|
|
20
|
+
return '';
|
|
21
|
+
return basename(projectRoot);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Render the status line. The output is plain text with simple status glyphs so
|
|
25
|
+
* it stays readable in any terminal; Claude Code applies its own styling.
|
|
26
|
+
*/
|
|
27
|
+
export function renderStatusLine(model) {
|
|
28
|
+
const root = rootLabel(model.projectRoot);
|
|
29
|
+
const rootSuffix = root ? ` · ${root}` : '';
|
|
30
|
+
switch (model.state) {
|
|
31
|
+
case 'active': {
|
|
32
|
+
const presence = model.presence;
|
|
33
|
+
if (!presence)
|
|
34
|
+
return `${BRAND} ○ idle${rootSuffix}`;
|
|
35
|
+
const parts = [presence.skill];
|
|
36
|
+
if (presence.mode)
|
|
37
|
+
parts.push(presence.mode);
|
|
38
|
+
if (presence.gate)
|
|
39
|
+
parts.push(`gate:${presence.gate}`);
|
|
40
|
+
return `${BRAND} ● ${parts.join(' · ')}${rootSuffix}`;
|
|
41
|
+
}
|
|
42
|
+
case 'stale': {
|
|
43
|
+
const presence = model.presence;
|
|
44
|
+
const skill = presence?.skill ?? 'unknown';
|
|
45
|
+
const age = formatAge(model.ageMs);
|
|
46
|
+
const ageSuffix = age ? ` · ${age}` : '';
|
|
47
|
+
return `${BRAND} ⚠ ${skill}${ageSuffix}${rootSuffix}`;
|
|
48
|
+
}
|
|
49
|
+
case 'invalid-presence':
|
|
50
|
+
return `${BRAND} ⚠ presence file unreadable${rootSuffix}`;
|
|
51
|
+
case 'idle':
|
|
52
|
+
default:
|
|
53
|
+
return `${BRAND} ○ idle${rootSuffix}`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type StatusLineStdin = {
|
|
2
|
+
workspace?: {
|
|
3
|
+
current_dir?: string;
|
|
4
|
+
project_dir?: string;
|
|
5
|
+
};
|
|
6
|
+
cwd?: string;
|
|
7
|
+
};
|
|
8
|
+
export type StatusLineState = 'active' | 'idle' | 'stale' | 'invalid-presence';
|
|
9
|
+
export type StatusLinePresence = {
|
|
10
|
+
skill: string;
|
|
11
|
+
mode?: string;
|
|
12
|
+
gate?: string;
|
|
13
|
+
setAt?: string;
|
|
14
|
+
};
|
|
15
|
+
export type StatusLineModel = {
|
|
16
|
+
state: StatusLineState;
|
|
17
|
+
projectRoot: string | null;
|
|
18
|
+
presence: StatusLinePresence | null;
|
|
19
|
+
ageMs: number | null;
|
|
20
|
+
};
|
|
21
|
+
export declare function parseStatusLineStdin(raw: string): StatusLineStdin | null;
|
|
22
|
+
export declare function buildStatusLineModel(stdin: StatusLineStdin | null, nowMs: number): StatusLineModel;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { findProjectRoot } from '../config/config-safety.js';
|
|
4
|
+
/**
|
|
5
|
+
* Out-of-band Peaks skill status renderer for the Claude Code statusLine.
|
|
6
|
+
*
|
|
7
|
+
* Claude Code invokes the configured statusLine command on every turn and pipes
|
|
8
|
+
* a JSON session payload on stdin. This renderer reads the durable presence file
|
|
9
|
+
* (.peaks/.active-skill.json) and prints a single line that Claude Code paints at
|
|
10
|
+
* the bottom of the terminal. Because it is rendered by the harness — not emitted
|
|
11
|
+
* as LLM tokens — the signal cannot be forgotten by the model, cannot be confused
|
|
12
|
+
* with normal output, and survives context compaction.
|
|
13
|
+
*
|
|
14
|
+
* This module is intentionally READ-ONLY. Unlike getSkillPresence in
|
|
15
|
+
* skill-presence-service.ts, it never deletes or rewrites the presence file:
|
|
16
|
+
* the statusLine runs on every turn and must have zero side effects.
|
|
17
|
+
*/
|
|
18
|
+
const PRESENCE_FILE = '.peaks/.active-skill.json';
|
|
19
|
+
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
|
|
20
|
+
function resolveCwdFromStdin(stdin) {
|
|
21
|
+
const fromWorkspace = stdin?.workspace?.current_dir ?? stdin?.workspace?.project_dir;
|
|
22
|
+
if (typeof fromWorkspace === 'string' && fromWorkspace.length > 0) {
|
|
23
|
+
return resolve(fromWorkspace);
|
|
24
|
+
}
|
|
25
|
+
if (typeof stdin?.cwd === 'string' && stdin.cwd.length > 0) {
|
|
26
|
+
return resolve(stdin.cwd);
|
|
27
|
+
}
|
|
28
|
+
return process.cwd();
|
|
29
|
+
}
|
|
30
|
+
export function parseStatusLineStdin(raw) {
|
|
31
|
+
const trimmed = raw.trim();
|
|
32
|
+
if (trimmed.length === 0)
|
|
33
|
+
return null;
|
|
34
|
+
try {
|
|
35
|
+
const parsed = JSON.parse(trimmed);
|
|
36
|
+
if (parsed && typeof parsed === 'object') {
|
|
37
|
+
return parsed;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Read the presence file without any side effects. Returns null when the file is
|
|
47
|
+
* absent (idle) and a sentinel object for malformed content (invalid-presence).
|
|
48
|
+
*/
|
|
49
|
+
function readPresenceReadOnly(projectRoot) {
|
|
50
|
+
const presencePath = resolve(projectRoot, PRESENCE_FILE);
|
|
51
|
+
if (!existsSync(presencePath)) {
|
|
52
|
+
return { presence: null, invalid: false };
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(readFileSync(presencePath, 'utf8'));
|
|
56
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
57
|
+
return { presence: null, invalid: true };
|
|
58
|
+
}
|
|
59
|
+
const candidate = parsed;
|
|
60
|
+
if (typeof candidate.skill !== 'string' || candidate.skill.length === 0) {
|
|
61
|
+
return { presence: null, invalid: true };
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
presence: {
|
|
65
|
+
skill: candidate.skill,
|
|
66
|
+
...(typeof candidate.mode === 'string' ? { mode: candidate.mode } : {}),
|
|
67
|
+
...(typeof candidate.gate === 'string' ? { gate: candidate.gate } : {}),
|
|
68
|
+
...(typeof candidate.setAt === 'string' ? { setAt: candidate.setAt } : {})
|
|
69
|
+
},
|
|
70
|
+
invalid: false
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return { presence: null, invalid: true };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function buildStatusLineModel(stdin, nowMs) {
|
|
78
|
+
const cwd = resolveCwdFromStdin(stdin);
|
|
79
|
+
const projectRoot = findProjectRoot(cwd);
|
|
80
|
+
if (projectRoot === null) {
|
|
81
|
+
return { state: 'idle', projectRoot: null, presence: null, ageMs: null };
|
|
82
|
+
}
|
|
83
|
+
const { presence, invalid } = readPresenceReadOnly(projectRoot);
|
|
84
|
+
if (invalid) {
|
|
85
|
+
return { state: 'invalid-presence', projectRoot, presence: null, ageMs: null };
|
|
86
|
+
}
|
|
87
|
+
if (presence === null) {
|
|
88
|
+
return { state: 'idle', projectRoot, presence: null, ageMs: null };
|
|
89
|
+
}
|
|
90
|
+
const setAtMs = presence.setAt ? Date.parse(presence.setAt) : Number.NaN;
|
|
91
|
+
const ageMs = Number.isNaN(setAtMs) ? null : nowMs - setAtMs;
|
|
92
|
+
const state = ageMs !== null && ageMs > STALE_THRESHOLD_MS ? 'stale' : 'active';
|
|
93
|
+
return { state, projectRoot, presence, ageMs };
|
|
94
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installs (and removes) the Peaks statusLine entry in a Claude Code
|
|
3
|
+
* settings.json. The statusLine renders `peaks statusline` on every turn, giving
|
|
4
|
+
* users an out-of-band, harness-painted signal of which Peaks skill is active —
|
|
5
|
+
* independent of LLM tokens and immune to context compaction.
|
|
6
|
+
*
|
|
7
|
+
* Writes preserve all other settings keys, reject symlinked targets, and use an
|
|
8
|
+
* atomic rename so a partial write can never corrupt an existing settings file.
|
|
9
|
+
*/
|
|
10
|
+
export type StatusLineScope = 'project' | 'global';
|
|
11
|
+
export type StatusLineSettingsPlan = {
|
|
12
|
+
scope: StatusLineScope;
|
|
13
|
+
settingsPath: string;
|
|
14
|
+
exists: boolean;
|
|
15
|
+
alreadyInstalled: boolean;
|
|
16
|
+
conflict: boolean;
|
|
17
|
+
conflictCommand: string | null;
|
|
18
|
+
desiredCommand: string;
|
|
19
|
+
};
|
|
20
|
+
export type StatusLineSettingsResult = StatusLineSettingsPlan & {
|
|
21
|
+
applied: boolean;
|
|
22
|
+
};
|
|
23
|
+
export declare const STATUSLINE_COMMAND = "peaks statusline";
|
|
24
|
+
export declare function planStatusLineInstall(scope: StatusLineScope, projectRoot?: string): StatusLineSettingsPlan;
|
|
25
|
+
export declare function applyStatusLineInstall(scope: StatusLineScope, projectRoot?: string, options?: {
|
|
26
|
+
force?: boolean;
|
|
27
|
+
}): StatusLineSettingsResult;
|
|
28
|
+
export declare function removeStatusLineInstall(scope: StatusLineScope, projectRoot?: string): {
|
|
29
|
+
scope: StatusLineScope;
|
|
30
|
+
settingsPath: string;
|
|
31
|
+
removed: boolean;
|
|
32
|
+
};
|