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.
Files changed (29) hide show
  1. package/bin/peaks.js +0 -0
  2. package/dist/src/cli/commands/core-artifact-commands.js +4 -4
  3. package/dist/src/cli/commands/project-commands.js +23 -101
  4. package/dist/src/cli/commands/statusline-commands.d.ts +3 -0
  5. package/dist/src/cli/commands/statusline-commands.js +111 -0
  6. package/dist/src/cli/program.js +2 -0
  7. package/dist/src/services/doctor/doctor-service.d.ts +1 -0
  8. package/dist/src/services/doctor/doctor-service.js +40 -0
  9. package/dist/src/services/memory/project-context-service.d.ts +0 -38
  10. package/dist/src/services/memory/project-context-service.js +2 -304
  11. package/dist/src/services/memory/project-memory-service.d.ts +17 -1
  12. package/dist/src/services/memory/project-memory-service.js +72 -4
  13. package/dist/src/services/skills/skill-statusline-renderer.d.ts +6 -0
  14. package/dist/src/services/skills/skill-statusline-renderer.js +55 -0
  15. package/dist/src/services/skills/skill-statusline-service.d.ts +22 -0
  16. package/dist/src/services/skills/skill-statusline-service.js +94 -0
  17. package/dist/src/services/skills/statusline-settings-service.d.ts +32 -0
  18. package/dist/src/services/skills/statusline-settings-service.js +144 -0
  19. package/dist/src/shared/version.d.ts +1 -1
  20. package/dist/src/shared/version.js +1 -1
  21. package/package.json +1 -1
  22. package/schemas/doctor-report.schema.json +2 -2
  23. package/skills/peaks-prd/SKILL.md +10 -3
  24. package/skills/peaks-qa/SKILL.md +10 -3
  25. package/skills/peaks-rd/SKILL.md +10 -3
  26. package/skills/peaks-sc/SKILL.md +11 -4
  27. package/skills/peaks-solo/SKILL.md +16 -9
  28. package/skills/peaks-txt/SKILL.md +12 -5
  29. 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, relative } from 'node:path';
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
- // Build and save ontology alongside PROJECT.md
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 claudeDir = join(resolvedRoot, '.claude');
46
- if (existsSync(claudeDir) && lstatSync(claudeDir).isSymbolicLink()) {
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(claudeDir, 'memory');
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
+ };