peaks-cli 1.1.0 → 1.1.2

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 CHANGED
File without changes
@@ -126,6 +126,18 @@ export function registerRequestCommands(program, io) {
126
126
  try {
127
127
  const role = options.role;
128
128
  const newState = parseStateForRole(role, options.state);
129
+ // Resolve the artifact's real session up front. Falling back to a literal
130
+ // 'default' (the previous behavior) points the bypass counter at a
131
+ // non-existent .peaks/default/ dir and crashes with ENOENT, so when
132
+ // --session-id is omitted we look the artifact up to find its session.
133
+ let resolvedSessionId = options.sessionId;
134
+ if (resolvedSessionId === undefined) {
135
+ const { showRequestArtifact: showForSession } = await import('../../services/artifacts/request-artifact-service.js');
136
+ const located = await showForSession({ projectRoot: options.project, role, requestId });
137
+ if (located !== null) {
138
+ resolvedSessionId = located.sessionId;
139
+ }
140
+ }
129
141
  if (options.allowIncomplete === true && (options.reason === undefined || options.reason.trim().length === 0)) {
130
142
  printResult(io, fail('request.transition', 'BYPASS_REASON_REQUIRED', '--allow-incomplete requires --reason explaining why prerequisites are skipped', { role, requestId }, ['Add --reason "<short justification>" or remove --allow-incomplete and produce the missing artifacts']), options.json);
131
143
  process.exitCode = 1;
@@ -142,7 +154,7 @@ export function registerRequestCommands(program, io) {
142
154
  return;
143
155
  }
144
156
  // Check bypass count
145
- const sessionRoot = (await import('node:path')).join(options.project, '.peaks', options.sessionId ?? 'default');
157
+ const sessionRoot = (await import('node:path')).join(options.project, '.peaks', resolvedSessionId ?? 'default');
146
158
  if (isBypassLimitReached(sessionRoot)) {
147
159
  printResult(io, fail('request.transition', 'BYPASS_LIMIT_REACHED', `--allow-incomplete limit reached (${MAX_BYPASSES_PER_SESSION} per session)`, { role, requestId, limit: MAX_BYPASSES_PER_SESSION }, ['Produce the missing artifacts instead of bypassing.']), options.json);
148
160
  process.exitCode = 1;
@@ -0,0 +1,3 @@
1
+ import { Command } from 'commander';
2
+ import { type ProgramIO } from '../cli-helpers.js';
3
+ export declare function registerStatusLineCommands(program: Command, io: ProgramIO): void;
@@ -0,0 +1,111 @@
1
+ import { fail, ok } from '../../shared/result.js';
2
+ import { addJsonOption, printResult, getErrorMessage } from '../cli-helpers.js';
3
+ import { findProjectRoot } from '../../services/config/config-safety.js';
4
+ import { buildStatusLineModel, parseStatusLineStdin } from '../../services/skills/skill-statusline-service.js';
5
+ import { renderStatusLine } from '../../services/skills/skill-statusline-renderer.js';
6
+ import { applyStatusLineInstall, planStatusLineInstall, removeStatusLineInstall } from '../../services/skills/statusline-settings-service.js';
7
+ const STDIN_READ_TIMEOUT_MS = 250;
8
+ /** Read piped stdin if present; resolve quickly with '' when attached to a TTY. */
9
+ function readStdin() {
10
+ return new Promise((resolve) => {
11
+ if (process.stdin.isTTY) {
12
+ resolve('');
13
+ return;
14
+ }
15
+ let data = '';
16
+ let settled = false;
17
+ const finish = () => {
18
+ if (settled)
19
+ return;
20
+ settled = true;
21
+ resolve(data);
22
+ };
23
+ const timer = setTimeout(finish, STDIN_READ_TIMEOUT_MS);
24
+ timer.unref?.();
25
+ process.stdin.setEncoding('utf8');
26
+ process.stdin.on('data', (chunk) => {
27
+ data += chunk;
28
+ });
29
+ process.stdin.on('end', () => {
30
+ clearTimeout(timer);
31
+ finish();
32
+ });
33
+ process.stdin.on('error', () => {
34
+ clearTimeout(timer);
35
+ finish();
36
+ });
37
+ });
38
+ }
39
+ function resolveScope(options) {
40
+ return options.global ? 'global' : 'project';
41
+ }
42
+ export function registerStatusLineCommands(program, io) {
43
+ const statusline = program
44
+ .command('statusline')
45
+ .description('Render the Peaks skill status line for Claude Code (reads session JSON on stdin)')
46
+ .option('--project <path>', 'project root path (auto-detected from cwd when omitted)')
47
+ .action(async (options) => {
48
+ const raw = await readStdin();
49
+ const stdin = parseStatusLineStdin(raw);
50
+ // When a project override is passed (or no stdin), seed cwd so detection works.
51
+ const seeded = options.project
52
+ ? { ...(stdin ?? {}), workspace: { current_dir: options.project } }
53
+ : stdin;
54
+ const model = buildStatusLineModel(seeded, Date.now());
55
+ io.stdout(renderStatusLine(model));
56
+ });
57
+ addJsonOption(statusline
58
+ .command('install')
59
+ .description('Install the Peaks status line into .claude/settings.json (project scope by default)')
60
+ .option('--global', 'install into the user-level ~/.claude/settings.json instead of the project')
61
+ .option('--project <path>', 'project root path (auto-detected from cwd when omitted)')
62
+ .option('--force', 'overwrite an existing non-Peaks statusLine entry')
63
+ .option('--dry-run', 'show what would change without writing')).action((options) => {
64
+ const scope = resolveScope(options);
65
+ const projectRoot = scope === 'project'
66
+ ? (options.project ?? findProjectRoot(process.cwd()) ?? process.cwd())
67
+ : undefined;
68
+ try {
69
+ if (options.dryRun) {
70
+ const plan = planStatusLineInstall(scope, projectRoot);
71
+ const warnings = plan.conflict
72
+ ? [`An existing statusLine command is set: ${plan.conflictCommand}. Rerun with --force to overwrite.`]
73
+ : [];
74
+ printResult(io, ok('statusline.install', { ...plan, applied: false, dryRun: true }, warnings), options.json);
75
+ return;
76
+ }
77
+ const result = applyStatusLineInstall(scope, projectRoot, options.force ? { force: true } : {});
78
+ const warnings = result.conflict && !result.applied
79
+ ? [`An existing statusLine command is set: ${result.conflictCommand}. Rerun with --force to overwrite.`]
80
+ : [];
81
+ const nextActions = result.applied
82
+ ? ['Restart Claude Code (or reload the window) so the status line takes effect']
83
+ : [];
84
+ printResult(io, ok('statusline.install', { ...result, dryRun: false }, warnings, nextActions), options.json);
85
+ }
86
+ catch (error) {
87
+ const message = getErrorMessage(error);
88
+ printResult(io, fail('statusline.install', 'STATUSLINE_INSTALL_FAILED', message, { scope, applied: false }, [message]), options.json);
89
+ process.exitCode = 1;
90
+ }
91
+ });
92
+ addJsonOption(statusline
93
+ .command('uninstall')
94
+ .description('Remove the Peaks status line from .claude/settings.json')
95
+ .option('--global', 'remove from the user-level ~/.claude/settings.json instead of the project')
96
+ .option('--project <path>', 'project root path (auto-detected from cwd when omitted)')).action((options) => {
97
+ const scope = resolveScope(options);
98
+ const projectRoot = scope === 'project'
99
+ ? (options.project ?? findProjectRoot(process.cwd()) ?? process.cwd())
100
+ : undefined;
101
+ try {
102
+ const result = removeStatusLineInstall(scope, projectRoot);
103
+ printResult(io, ok('statusline.uninstall', result), options.json);
104
+ }
105
+ catch (error) {
106
+ const message = getErrorMessage(error);
107
+ printResult(io, fail('statusline.uninstall', 'STATUSLINE_UNINSTALL_FAILED', message, { scope, removed: false }, [message]), options.json);
108
+ process.exitCode = 1;
109
+ }
110
+ });
111
+ }
@@ -10,6 +10,7 @@ import { registerProjectCommands } from './commands/project-commands.js';
10
10
  import { registerRequestCommands } from './commands/request-commands.js';
11
11
  import { registerScanCommands } from './commands/scan-commands.js';
12
12
  import { registerShadcnCommands } from './commands/shadcn-commands.js';
13
+ import { registerStatusLineCommands } from './commands/statusline-commands.js';
13
14
  import { registerUnderstandCommands } from './commands/understand-commands.js';
14
15
  import { registerWorkspaceCommands } from './commands/workspace-commands.js';
15
16
  export { printResult } from './cli-helpers.js';
@@ -40,6 +41,7 @@ export function createProgram(io = { stdout: (text) => console.log(text), stderr
40
41
  registerRequestCommands(program, io);
41
42
  registerScanCommands(program, io);
42
43
  registerShadcnCommands(program, io);
44
+ registerStatusLineCommands(program, io);
43
45
  registerUnderstandCommands(program, io);
44
46
  registerWorkspaceCommands(program, io);
45
47
  return program;
@@ -35,6 +35,15 @@ const ALLOWLIST_PATTERNS = [
35
35
  function isAllowlisted(line) {
36
36
  return ALLOWLIST_PATTERNS.some((pattern) => pattern.test(line));
37
37
  }
38
+ /**
39
+ * Remove inline code spans (`...`) before applying placeholder rules. Content
40
+ * inside backticks is literal example text — e.g. a documented command syntax
41
+ * `peaks sop init <id>` — not an unfilled prose placeholder. Lint checks prose,
42
+ * not code, so a `<...>` token only counts when it appears outside code spans.
43
+ */
44
+ function stripInlineCode(line) {
45
+ return line.replace(/`[^`]*`/g, '');
46
+ }
38
47
  export async function lintRequestArtifact(options) {
39
48
  const showOptions = {
40
49
  projectRoot: options.projectRoot,
@@ -50,14 +59,24 @@ export async function lintRequestArtifact(options) {
50
59
  }
51
60
  const lines = artifact.content.split(/\r?\n/);
52
61
  const findings = [];
62
+ let insideFence = false;
53
63
  for (let index = 0; index < lines.length; index += 1) {
54
64
  const rawLine = lines[index];
55
65
  if (rawLine === undefined)
56
66
  continue;
67
+ // Fenced code blocks hold literal examples, not prose to fill; skip their
68
+ // contents entirely (the fence delimiters themselves toggle the state).
69
+ if (/^\s*```/.test(rawLine)) {
70
+ insideFence = !insideFence;
71
+ continue;
72
+ }
73
+ if (insideFence)
74
+ continue;
57
75
  if (isAllowlisted(rawLine))
58
76
  continue;
77
+ const testLine = stripInlineCode(rawLine);
59
78
  for (const rule of RULES) {
60
- if (rule.test(rawLine)) {
79
+ if (rule.test(testLine)) {
61
80
  findings.push({
62
81
  line: index + 1,
63
82
  text: rawLine.trim(),
@@ -1,5 +1,5 @@
1
- import { join } from 'node:path';
2
- import { readFile } from 'node:fs/promises';
1
+ import { join, dirname, basename } from 'node:path';
2
+ import { readFile, readdir } from 'node:fs/promises';
3
3
  import { pathExists } from '../../shared/fs.js';
4
4
  export const VALID_REQUEST_TYPES = [
5
5
  'feature',
@@ -111,6 +111,36 @@ export function getPrerequisitesFor(role, newState, requestType = DEFAULT_REQUES
111
111
  function resolvePrerequisitePath(prerequisite, requestId) {
112
112
  return prerequisite.relativePath.replace('<rid>', requestId);
113
113
  }
114
+ /**
115
+ * Resolve a prerequisite to an on-disk path, tolerating the numbered filename
116
+ * prefix that `request init` writes (e.g. `001-<rid>.md`). When the prerequisite
117
+ * path contains `<rid>`, we accept either the legacy bare `<rid>.md` form or any
118
+ * `NNN-<rid>.md` numbered form — mirroring the matcher in request-artifact-service.
119
+ * Returns the matched absolute path, or null when nothing matches.
120
+ */
121
+ async function resolvePrerequisiteAbsolutePath(sessionRoot, prerequisite, requestId) {
122
+ const relative = resolvePrerequisitePath(prerequisite, requestId);
123
+ const exact = join(sessionRoot, relative);
124
+ if (await pathExists(exact)) {
125
+ return exact;
126
+ }
127
+ // Only `<rid>`-templated prerequisites can carry a numbered prefix; fixed paths
128
+ // (e.g. rd/tech-doc.md) are matched exactly above.
129
+ if (!prerequisite.relativePath.includes('<rid>')) {
130
+ return null;
131
+ }
132
+ const dir = dirname(exact);
133
+ const targetSuffix = `-${basename(exact)}`;
134
+ let entries;
135
+ try {
136
+ entries = await readdir(dir);
137
+ }
138
+ catch {
139
+ return null;
140
+ }
141
+ const match = entries.find((name) => /^\d+-/.test(name) && name.endsWith(targetSuffix));
142
+ return match ? join(dir, match) : null;
143
+ }
114
144
  export async function checkPrerequisites(options) {
115
145
  const requirements = getPrerequisitesFor(options.role, options.newState, options.requestType);
116
146
  if (requirements.length === 0) {
@@ -120,8 +150,8 @@ export async function checkPrerequisites(options) {
120
150
  const missing = [];
121
151
  for (const prerequisite of requirements) {
122
152
  const relative = resolvePrerequisitePath(prerequisite, options.requestId);
123
- const absolute = join(sessionRoot, relative);
124
- if (!(await pathExists(absolute))) {
153
+ const absolute = await resolvePrerequisiteAbsolutePath(sessionRoot, prerequisite, options.requestId);
154
+ if (absolute === null) {
125
155
  missing.push({ path: relative, description: prerequisite.description });
126
156
  continue;
127
157
  }
@@ -24,5 +24,6 @@ export type DoctorOptions = {
24
24
  codegraphProbe?: () => CodegraphCapabilityProbe;
25
25
  skillPresenceProbe?: () => SkillPresence | null;
26
26
  skillPresenceFreshnessThresholdMs?: number;
27
+ statusLineInstalledProbe?: () => boolean;
27
28
  };
28
29
  export declare function runDoctor(options?: DoctorOptions): Promise<DoctorReport>;
@@ -8,6 +8,8 @@ import { requiredSchemaFiles, requiredSkillNames, schemasDir } from '../../share
8
8
  import { getErrorMessage } from '../../shared/result.js';
9
9
  import { loadSkillRegistry } from '../skills/skill-registry.js';
10
10
  import { getSkillPresence } from '../skills/skill-presence-service.js';
11
+ import { planStatusLineInstall } from '../skills/statusline-settings-service.js';
12
+ import { findProjectRoot } from '../config/config-safety.js';
11
13
  const CODEGRAPH_EXPECTED_VERSION = '0.7.10';
12
14
  const SKILL_PRESENCE_FRESHNESS_THRESHOLD_MS = 24 * 60 * 60 * 1000;
13
15
  function defaultCodegraphProbe() {
@@ -22,6 +24,17 @@ function defaultCodegraphProbe() {
22
24
  binaryExists: existsSync(binaryPath)
23
25
  };
24
26
  }
27
+ function defaultStatusLineInstalledProbe() {
28
+ const projectRoot = findProjectRoot(process.cwd());
29
+ if (projectRoot === null)
30
+ return false;
31
+ try {
32
+ return planStatusLineInstall('project', projectRoot).alreadyInstalled;
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
25
38
  const DESTRUCTIVE_APPLY_PATTERNS = [
26
39
  /peaks\s+memory\s+sync[^\n]*--apply/,
27
40
  /peaks\s+memory\s+extract[^\n]*--apply/,
@@ -190,6 +203,33 @@ export async function runDoctor(options = {}) {
190
203
  }
191
204
  }
192
205
  }
206
+ // Discoverability nudge: when a skill is actively orchestrating but the
207
+ // out-of-band statusLine isn't installed, the user has no terminal-level
208
+ // signal that Peaks is in control. Suggest installing it (non-failing).
209
+ const statusLineProbe = options.statusLineInstalledProbe ?? defaultStatusLineInstalledProbe;
210
+ let statusLineInstalled = false;
211
+ try {
212
+ statusLineInstalled = statusLineProbe();
213
+ }
214
+ catch {
215
+ statusLineInstalled = false;
216
+ }
217
+ if (presence !== null && !statusLineInstalled) {
218
+ checks.push({
219
+ id: 'statusline:install',
220
+ ok: true,
221
+ message: 'A Peaks skill is active but the statusLine is not installed; run `peaks statusline install` so the active skill shows in the terminal status bar'
222
+ });
223
+ }
224
+ else {
225
+ checks.push({
226
+ id: 'statusline:install',
227
+ ok: true,
228
+ message: statusLineInstalled
229
+ ? 'Peaks statusLine is installed'
230
+ : 'Peaks statusLine not installed (no active skill; install optional)'
231
+ });
232
+ }
193
233
  const probe = options.codegraphProbe ?? defaultCodegraphProbe;
194
234
  try {
195
235
  const result = probe();
@@ -25,6 +25,16 @@ function classifyFile(filePath) {
25
25
  return 'source';
26
26
  return 'unknown';
27
27
  }
28
+ /**
29
+ * Peaks' own artifact workspace. Changes here (PRD/RD/QA markdown, session
30
+ * state) are never the "code change" a request type describes, so they must be
31
+ * excluded from the diff — otherwise a PRD-planning-phase handoff that only
32
+ * wrote `.peaks/**` markdown would be misclassified as a docs change.
33
+ */
34
+ function isArtifactWorkspaceFile(filePath) {
35
+ const normalized = filePath.replace(/\\/g, '/');
36
+ return normalized === '.peaks' || normalized.startsWith('.peaks/');
37
+ }
28
38
  function tryGitDiffFiles(projectRoot, baseRef) {
29
39
  try {
30
40
  // Combine: tracked changes vs baseRef + untracked files. Use porcelain status for untracked too.
@@ -32,7 +42,7 @@ function tryGitDiffFiles(projectRoot, baseRef) {
32
42
  const tracked = trackedRaw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
33
43
  const untrackedRaw = execFileSync('git', ['-C', projectRoot, 'ls-files', '--others', '--exclude-standard'], { encoding: 'utf8' });
34
44
  const untracked = untrackedRaw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
35
- const merged = Array.from(new Set([...tracked, ...untracked]));
45
+ const merged = Array.from(new Set([...tracked, ...untracked])).filter((file) => !isArtifactWorkspaceFile(file));
36
46
  return { ok: true, files: merged };
37
47
  }
38
48
  catch {
@@ -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
+ };
@@ -0,0 +1,144 @@
1
+ import { closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ export const STATUSLINE_COMMAND = 'peaks statusline';
6
+ function isInsidePath(childPath, parentPath) {
7
+ const rel = relative(parentPath, childPath);
8
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
9
+ }
10
+ function resolveSettingsRoot(scope, projectRoot) {
11
+ if (scope === 'global')
12
+ return resolve(homedir());
13
+ if (!projectRoot) {
14
+ throw new Error('Project scope requires a project root');
15
+ }
16
+ return resolve(projectRoot);
17
+ }
18
+ function resolveSettingsPath(scope, projectRoot) {
19
+ const root = resolveSettingsRoot(scope, projectRoot);
20
+ return join(root, '.claude', 'settings.json');
21
+ }
22
+ /** Reject symlinked .claude dir or settings file to prevent escape. */
23
+ function assertSafeSettingsPath(scope, root, settingsPath) {
24
+ const claudeDir = join(root, '.claude');
25
+ if (existsSync(claudeDir) && lstatSync(claudeDir).isSymbolicLink()) {
26
+ throw new Error('.claude directory must not be a symlink');
27
+ }
28
+ if (existsSync(settingsPath)) {
29
+ if (lstatSync(settingsPath).isSymbolicLink()) {
30
+ throw new Error('settings.json must not be a symlink');
31
+ }
32
+ const realRoot = realpathSync(root);
33
+ if (!isInsidePath(realpathSync(settingsPath), realRoot)) {
34
+ throw new Error(`settings.json must stay inside the ${scope} root`);
35
+ }
36
+ }
37
+ }
38
+ function readSettings(settingsPath) {
39
+ if (!existsSync(settingsPath))
40
+ return {};
41
+ const fd = openSync(settingsPath, constants.O_RDONLY | constants.O_NOFOLLOW);
42
+ try {
43
+ const raw = readFileSync(fd, 'utf8').trim();
44
+ if (raw.length === 0)
45
+ return {};
46
+ const parsed = JSON.parse(raw);
47
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
48
+ throw new Error('settings.json must contain a JSON object');
49
+ }
50
+ return parsed;
51
+ }
52
+ finally {
53
+ closeSync(fd);
54
+ }
55
+ }
56
+ function extractExistingCommand(settings) {
57
+ const statusLine = settings.statusLine;
58
+ if (statusLine && typeof statusLine === 'object' && !Array.isArray(statusLine)) {
59
+ const command = statusLine.command;
60
+ if (typeof command === 'string')
61
+ return command;
62
+ }
63
+ return null;
64
+ }
65
+ function buildPlan(scope, settingsPath, settings, exists) {
66
+ const existingCommand = extractExistingCommand(settings);
67
+ const alreadyInstalled = existingCommand !== null && existingCommand.includes(STATUSLINE_COMMAND);
68
+ const conflict = existingCommand !== null && !alreadyInstalled;
69
+ return {
70
+ scope,
71
+ settingsPath,
72
+ exists,
73
+ alreadyInstalled,
74
+ conflict,
75
+ conflictCommand: conflict ? existingCommand : null,
76
+ desiredCommand: STATUSLINE_COMMAND
77
+ };
78
+ }
79
+ export function planStatusLineInstall(scope, projectRoot) {
80
+ const root = resolveSettingsRoot(scope, projectRoot);
81
+ const settingsPath = resolveSettingsPath(scope, projectRoot);
82
+ assertSafeSettingsPath(scope, root, settingsPath);
83
+ const exists = existsSync(settingsPath);
84
+ const settings = readSettings(settingsPath);
85
+ return buildPlan(scope, settingsPath, settings, exists);
86
+ }
87
+ function atomicWriteJson(settingsPath, settings) {
88
+ const dir = dirname(settingsPath);
89
+ mkdirSync(dir, { recursive: true });
90
+ const tempPath = join(dir, `.settings.${randomUUID()}.tmp`);
91
+ const fd = openSync(tempPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
92
+ try {
93
+ writeFileSync(fd, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
94
+ }
95
+ finally {
96
+ closeSync(fd);
97
+ }
98
+ try {
99
+ renameSync(tempPath, settingsPath);
100
+ }
101
+ catch (error) {
102
+ try {
103
+ unlinkSync(tempPath);
104
+ }
105
+ catch {
106
+ // best effort cleanup
107
+ }
108
+ throw error;
109
+ }
110
+ }
111
+ export function applyStatusLineInstall(scope, projectRoot, options = {}) {
112
+ const root = resolveSettingsRoot(scope, projectRoot);
113
+ const settingsPath = resolveSettingsPath(scope, projectRoot);
114
+ assertSafeSettingsPath(scope, root, settingsPath);
115
+ const exists = existsSync(settingsPath);
116
+ const settings = readSettings(settingsPath);
117
+ const plan = buildPlan(scope, settingsPath, settings, exists);
118
+ if (plan.alreadyInstalled) {
119
+ return { ...plan, applied: false };
120
+ }
121
+ if (plan.conflict && !options.force) {
122
+ return { ...plan, applied: false };
123
+ }
124
+ const entry = { type: 'command', command: STATUSLINE_COMMAND, padding: 0 };
125
+ const nextSettings = { ...settings, statusLine: entry };
126
+ atomicWriteJson(settingsPath, nextSettings);
127
+ return { ...plan, applied: true };
128
+ }
129
+ export function removeStatusLineInstall(scope, projectRoot) {
130
+ const root = resolveSettingsRoot(scope, projectRoot);
131
+ const settingsPath = resolveSettingsPath(scope, projectRoot);
132
+ assertSafeSettingsPath(scope, root, settingsPath);
133
+ if (!existsSync(settingsPath)) {
134
+ return { scope, settingsPath, removed: false };
135
+ }
136
+ const settings = readSettings(settingsPath);
137
+ const existingCommand = extractExistingCommand(settings);
138
+ if (existingCommand === null || !existingCommand.includes(STATUSLINE_COMMAND)) {
139
+ return { scope, settingsPath, removed: false };
140
+ }
141
+ const { statusLine: _removed, ...rest } = settings;
142
+ atomicWriteJson(settingsPath, rest);
143
+ return { scope, settingsPath, removed: true };
144
+ }
@@ -1 +1 @@
1
- export declare const CLI_VERSION = "1.1.0";
1
+ export declare const CLI_VERSION = "1.1.2";
@@ -1 +1 @@
1
- export const CLI_VERSION = "1.1.0";
1
+ export const CLI_VERSION = "1.1.2";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-cli",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Peaks CLI and short skill family for Claude Code automation.",
5
5
  "author": "SquabbyZ",
6
6
  "license": "MIT",
@@ -13,8 +13,8 @@
13
13
  "properties": {
14
14
  "id": {
15
15
  "type": "string",
16
- "pattern": "^(skill|skill-name|skill-parse|skill-runbook|skill-apply-note|skill-presence|schema|config|doctor-self|capability):[A-Za-z0-9][A-Za-z0-9._-]*$",
17
- "description": "Stable check id. Known prefixes: skill:<name> (required skill present), skill-name:<dir> (directory matches declared name), skill-parse:<dir> (skill metadata parsed), skill-runbook:<name> (Default runbook section exists), skill-apply-note:<name> (destructive --apply lines carry an authorization/--dry-run note), skill-presence:<topic> (status of .peaks/.active-skill.json — current/freshness), schema:<file> (schema file exists and is valid JSON), config:<scope> (optional config locations), doctor-self:<topic> (doctor validates its own output against this schema), capability:<name> (third-party capability is resolvable at the pinned version)."
16
+ "pattern": "^(skill|skill-name|skill-parse|skill-runbook|skill-apply-note|skill-presence|statusline|schema|config|doctor-self|capability):[A-Za-z0-9][A-Za-z0-9._-]*$",
17
+ "description": "Stable check id. Known prefixes: skill:<name> (required skill present), skill-name:<dir> (directory matches declared name), skill-parse:<dir> (skill metadata parsed), skill-runbook:<name> (Default runbook section exists), skill-apply-note:<name> (destructive --apply lines carry an authorization/--dry-run note), skill-presence:<topic> (status of .peaks/.active-skill.json — current/freshness), statusline:<topic> (whether the out-of-band Claude Code statusLine is installed), schema:<file> (schema file exists and is valid JSON), config:<scope> (optional config locations), doctor-self:<topic> (doctor validates its own output against this schema), capability:<name> (third-party capability is resolvable at the pinned version)."
18
18
  },
19
19
  "ok": { "type": "boolean" },
20
20
  "message": { "type": "string", "minLength": 1 }
@@ -14,6 +14,13 @@ Before any analysis or tool call, immediately run:
14
14
  ```bash
15
15
  peaks skill presence:set peaks-prd --project <repo> --mode <mode> --gate startup
16
16
  ```
17
+
18
+ On the first presence:set in a project, ensure the out-of-band status bar is installed so the user can see at a glance that Peaks is orchestrating — it renders the active skill in Claude Code's terminal status line, independent of model output:
19
+
20
+ ```bash
21
+ peaks statusline install --project <repo> # idempotent; skips if already installed
22
+ ```
23
+
17
24
  Read persistent project memory via CLI (durable, LLM-authored memories):
18
25
 
19
26
  ```bash
@@ -14,6 +14,13 @@ Before any analysis or tool call, immediately run:
14
14
  ```bash
15
15
  peaks skill presence:set peaks-qa --project <repo> --mode <mode> --gate startup
16
16
  ```
17
+
18
+ On the first presence:set in a project, ensure the out-of-band status bar is installed so the user can see at a glance that Peaks is orchestrating — it renders the active skill in Claude Code's terminal status line, independent of model output:
19
+
20
+ ```bash
21
+ peaks statusline install --project <repo> # idempotent; skips if already installed
22
+ ```
23
+
17
24
  Read persistent project memory via CLI (durable, LLM-authored memories):
18
25
 
19
26
  ```bash
@@ -14,6 +14,13 @@ Before any analysis or tool call, immediately run:
14
14
  ```bash
15
15
  peaks skill presence:set peaks-rd --project <repo> --mode <mode> --gate startup
16
16
  ```
17
+
18
+ On the first presence:set in a project, ensure the out-of-band status bar is installed so the user can see at a glance that Peaks is orchestrating — it renders the active skill in Claude Code's terminal status line, independent of model output:
19
+
20
+ ```bash
21
+ peaks statusline install --project <repo> # idempotent; skips if already installed
22
+ ```
23
+
17
24
  Read persistent project memory via CLI (durable, LLM-authored memories):
18
25
 
19
26
  ```bash
@@ -14,6 +14,13 @@ Before any analysis or tool call, immediately run:
14
14
  ```bash
15
15
  peaks skill presence:set peaks-sc --project <repo> --mode <mode> --gate startup
16
16
  ```
17
+
18
+ On the first presence:set in a project, ensure the out-of-band status bar is installed so the user can see at a glance that Peaks is orchestrating — it renders the active skill in Claude Code's terminal status line, independent of model output:
19
+
20
+ ```bash
21
+ peaks statusline install --project <repo> # idempotent; skips if already installed
22
+ ```
23
+
17
24
  Read persistent project memory via CLI (durable, LLM-authored memories):
18
25
 
19
26
  ```bash
@@ -74,6 +74,12 @@ Only after the mode is known (user selected or explicitly named), run:
74
74
  peaks skill presence:set peaks-solo --project <repo> --mode <mode-value> --gate startup
75
75
  ```
76
76
 
77
+ On the first presence:set in a project, ensure the out-of-band status bar is installed so the user can see at a glance that Peaks is orchestrating — it renders the active skill in Claude Code's terminal status line, independent of model output:
78
+
79
+ ```bash
80
+ peaks statusline install --project <repo> # idempotent; skips if already installed
81
+ ```
82
+
77
83
  Then display the compact status header: `Peaks-Cli Skill: peaks-solo | Peaks-Cli Gate: startup | Next: <one short action>`. Display this header on EVERY turn while the skill is active.
78
84
 
79
85
  Update with `peaks skill presence:set peaks-solo --project <repo> --mode <mode> --gate <gate>` when gates change. The presence file persists across the full workflow lifecycle — do NOT clear it at workflow end.
@@ -14,6 +14,13 @@ Before any analysis or tool call, immediately run:
14
14
  ```bash
15
15
  peaks skill presence:set peaks-txt --project <repo> --mode <mode> --gate startup
16
16
  ```
17
+
18
+ On the first presence:set in a project, ensure the out-of-band status bar is installed so the user can see at a glance that Peaks is orchestrating — it renders the active skill in Claude Code's terminal status line, independent of model output:
19
+
20
+ ```bash
21
+ peaks statusline install --project <repo> # idempotent; skips if already installed
22
+ ```
23
+
17
24
  Read persistent project memory via CLI (durable, LLM-authored memories):
18
25
 
19
26
  ```bash
@@ -14,6 +14,13 @@ Before any analysis or tool call, immediately run:
14
14
  ```bash
15
15
  peaks skill presence:set peaks-ui --project <repo> --mode <mode> --gate startup
16
16
  ```
17
+
18
+ On the first presence:set in a project, ensure the out-of-band status bar is installed so the user can see at a glance that Peaks is orchestrating — it renders the active skill in Claude Code's terminal status line, independent of model output:
19
+
20
+ ```bash
21
+ peaks statusline install --project <repo> # idempotent; skips if already installed
22
+ ```
23
+
17
24
  Read persistent project memory via CLI (durable, LLM-authored memories):
18
25
 
19
26
  ```bash