peaks-cli 1.0.24 → 1.0.26

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.
@@ -8,6 +8,7 @@ import { runDoctor } from '../../services/doctor/doctor-service.js';
8
8
  import { listSkills } from '../../services/skills/skill-registry.js';
9
9
  import { inspectSkillRunbook } from '../../services/skills/skill-runbook-service.js';
10
10
  import { setSkillPresence, clearSkillPresence, getSkillPresence, isSkillPresenceMode, touchSkillHeartbeat } from '../../services/skills/skill-presence-service.js';
11
+ import { ensureSession, getSessionMeta, setSessionMeta, setSessionTitle, listSessionMetas } from '../../services/session/session-manager.js';
11
12
  import { fail, ok } from '../../shared/result.js';
12
13
  import { addJsonOption, failUnsupportedNonDryRun, getErrorMessage, isArtifactProvider, isArtifactSetupStep, printResult } from '../cli-helpers.js';
13
14
  export function registerCoreAndArtifactCommands(program, io) {
@@ -71,13 +72,21 @@ export function registerCoreAndArtifactCommands(program, io) {
71
72
  .command('presence:set <name>')
72
73
  .description('Set the currently active Peaks skill for session-wide visibility')
73
74
  .option('--mode <mode>', 'execution mode')
74
- .option('--gate <gate>', 'current gate')).action((name, options) => {
75
+ .option('--gate <gate>', 'current gate')).action(async (name, options) => {
75
76
  if (options.mode !== undefined && !isSkillPresenceMode(options.mode)) {
76
77
  printResult(io, fail('skill.presence:set', 'INVALID_MODE', `Invalid mode: ${options.mode} (expected one of: full-auto, assisted, swarm, strict)`, { name, mode: options.mode }, ['Use a valid mode: full-auto, assisted, swarm, or strict']), options.json);
77
78
  process.exitCode = 1;
78
79
  return;
79
80
  }
80
81
  const presence = setSkillPresence(name, options.mode, options.gate);
82
+ // Also update session metadata so session dirs self-document
83
+ const projectRoot = process.cwd();
84
+ const sessionId = await ensureSession(projectRoot);
85
+ setSessionMeta(projectRoot, sessionId, {
86
+ skill: name,
87
+ ...(options.mode ? { mode: options.mode } : {}),
88
+ ...(options.gate ? { gate: options.gate } : {})
89
+ });
81
90
  printResult(io, ok('skill.presence:set', { active: true, ...presence }), options.json);
82
91
  });
83
92
  addJsonOption(skill
@@ -116,6 +125,39 @@ export function registerCoreAndArtifactCommands(program, io) {
116
125
  lastHeartbeat: updated.lastHeartbeat
117
126
  }), options.json);
118
127
  });
128
+ const session = program.command('session').description('Manage Peaks session directories');
129
+ addJsonOption(session
130
+ .command('list')
131
+ .description('List all session directories with titles and metadata')).action((options) => {
132
+ const projectRoot = process.cwd();
133
+ const metas = listSessionMetas(projectRoot);
134
+ printResult(io, ok('session.list', { sessions: metas, total: metas.length }), options.json);
135
+ });
136
+ addJsonOption(session
137
+ .command('info <sessionId>')
138
+ .description('Show full metadata for a session directory')).action((sessionId, options) => {
139
+ const projectRoot = process.cwd();
140
+ const meta = getSessionMeta(projectRoot, sessionId);
141
+ if (meta === null) {
142
+ printResult(io, fail('session.info', 'SESSION_NOT_FOUND', `Session "${sessionId}" not found or has no metadata`, { sessionId }, ['Use `peaks session list` to see available sessions']), options.json);
143
+ process.exitCode = 1;
144
+ return;
145
+ }
146
+ printResult(io, ok('session.info', meta), options.json);
147
+ });
148
+ addJsonOption(session
149
+ .command('title <sessionId> <title>')
150
+ .description('Set a human-readable title for a session directory')).action((sessionId, title, options) => {
151
+ const projectRoot = process.cwd();
152
+ try {
153
+ const meta = setSessionTitle(projectRoot, sessionId, title);
154
+ printResult(io, ok('session.title', meta), options.json);
155
+ }
156
+ catch (error) {
157
+ printResult(io, fail('session.title', 'SESSION_TITLE_FAILED', getErrorMessage(error), { sessionId }, ['Verify the sessionId exists under .peaks/']), options.json);
158
+ process.exitCode = 1;
159
+ }
160
+ });
119
161
  const profile = program.command('profile').description('Manage runtime profiles');
120
162
  addJsonOption(profile.command('list').description('List available profiles')).action((options) => {
121
163
  printResult(io, ok('profile.list', { profiles: listProfiles() }), options.json);
@@ -1 +1 @@
1
- export { ensureSession, getSessionId, getCurrentSessionDir, listSessions, getProjectScanPath, hasProjectScan, type SessionInfo } from './session-manager.js';
1
+ export { ensureSession, getSessionId, getCurrentSessionDir, listSessions, getSessionMeta, setSessionMeta, setSessionTitle, listSessionMetas, getProjectScanPath, hasProjectScan, type SessionInfo, type SessionMeta } from './session-manager.js';
@@ -1 +1 @@
1
- export { ensureSession, getSessionId, getCurrentSessionDir, listSessions, getProjectScanPath, hasProjectScan } from './session-manager.js';
1
+ export { ensureSession, getSessionId, getCurrentSessionDir, listSessions, getSessionMeta, setSessionMeta, setSessionTitle, listSessionMetas, getProjectScanPath, hasProjectScan } from './session-manager.js';
@@ -10,6 +10,35 @@ export type SessionInfo = {
10
10
  createdAt: string;
11
11
  projectRoot: string;
12
12
  };
13
+ export type SessionMeta = {
14
+ sessionId: string;
15
+ title?: string;
16
+ skill?: string;
17
+ mode?: string;
18
+ gate?: string;
19
+ createdAt: string;
20
+ lastActivity?: string;
21
+ projectRoot: string;
22
+ };
23
+ /**
24
+ * Read metadata for a specific session directory.
25
+ * Returns null if the session directory or its session.json does not exist.
26
+ */
27
+ export declare function getSessionMeta(projectRoot: string, sessionId: string): SessionMeta | null;
28
+ /**
29
+ * Write or update metadata for a session. Fields besides sessionId and createdAt
30
+ * are merged on top of the current meta (partial update).
31
+ */
32
+ export declare function setSessionMeta(projectRoot: string, sessionId: string, partial: Partial<Omit<SessionMeta, 'sessionId' | 'createdAt' | 'projectRoot'>>): SessionMeta;
33
+ /**
34
+ * Set the display title for a session directory.
35
+ */
36
+ export declare function setSessionTitle(projectRoot: string, sessionId: string, title: string): SessionMeta;
37
+ /**
38
+ * List all session directories under .peaks with their metadata.
39
+ * Returns sessions sorted by sessionId descending (most recent first).
40
+ */
41
+ export declare function listSessionMetas(projectRoot: string): SessionMeta[];
13
42
  /**
14
43
  * Get or create the current session for a project.
15
44
  * If a valid session already exists, returns it.
@@ -5,11 +5,12 @@
5
5
  * Sessions are automatically created when any skill is invoked.
6
6
  * Each session gets a unique directory under .peaks/ with incrementing numbered files.
7
7
  */
8
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
9
9
  import { join } from 'node:path';
10
10
  import { randomBytes } from 'node:crypto';
11
11
  import { initWorkspace } from '../workspace/workspace-service.js';
12
12
  const SESSION_FILE = '.session.json';
13
+ const META_FILE = 'session.json';
13
14
  /**
14
15
  * Generate a new session ID.
15
16
  * Format: YYYY-MM-DD-session-<6位hex>
@@ -60,6 +61,86 @@ function writeSessionFile(projectRoot, info) {
60
61
  }
61
62
  writeFileSync(sessionFile, JSON.stringify(info, null, 2), 'utf8');
62
63
  }
64
+ function getMetaFilePath(projectRoot, sessionId) {
65
+ return join(projectRoot, '.peaks', sessionId, META_FILE);
66
+ }
67
+ function readSessionMeta(projectRoot, sessionId) {
68
+ const metaPath = getMetaFilePath(projectRoot, sessionId);
69
+ if (!existsSync(metaPath))
70
+ return null;
71
+ try {
72
+ const raw = readFileSync(metaPath, 'utf8');
73
+ const parsed = JSON.parse(raw);
74
+ if (typeof parsed?.sessionId !== 'string' || parsed.sessionId.length === 0) {
75
+ return null;
76
+ }
77
+ return parsed;
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+ function writeSessionMeta(projectRoot, sessionId, meta) {
84
+ const metaPath = getMetaFilePath(projectRoot, sessionId);
85
+ const metaDir = join(projectRoot, '.peaks', sessionId);
86
+ if (!existsSync(metaDir)) {
87
+ mkdirSync(metaDir, { recursive: true });
88
+ }
89
+ writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf8');
90
+ }
91
+ /**
92
+ * Read metadata for a specific session directory.
93
+ * Returns null if the session directory or its session.json does not exist.
94
+ */
95
+ export function getSessionMeta(projectRoot, sessionId) {
96
+ return readSessionMeta(projectRoot, sessionId);
97
+ }
98
+ /**
99
+ * Write or update metadata for a session. Fields besides sessionId and createdAt
100
+ * are merged on top of the current meta (partial update).
101
+ */
102
+ export function setSessionMeta(projectRoot, sessionId, partial) {
103
+ const existing = readSessionMeta(projectRoot, sessionId);
104
+ const now = new Date().toISOString();
105
+ const meta = existing
106
+ ? { ...existing, ...partial, lastActivity: now }
107
+ : {
108
+ sessionId,
109
+ projectRoot,
110
+ createdAt: now,
111
+ ...partial,
112
+ lastActivity: now
113
+ };
114
+ writeSessionMeta(projectRoot, sessionId, meta);
115
+ return meta;
116
+ }
117
+ /**
118
+ * Set the display title for a session directory.
119
+ */
120
+ export function setSessionTitle(projectRoot, sessionId, title) {
121
+ return setSessionMeta(projectRoot, sessionId, { title });
122
+ }
123
+ /**
124
+ * List all session directories under .peaks with their metadata.
125
+ * Returns sessions sorted by sessionId descending (most recent first).
126
+ */
127
+ export function listSessionMetas(projectRoot) {
128
+ const peaksRoot = join(projectRoot, '.peaks');
129
+ if (!existsSync(peaksRoot))
130
+ return [];
131
+ const entries = readdirSync(peaksRoot, { withFileTypes: true });
132
+ return entries
133
+ .filter((entry) => entry.isDirectory() && /^\d{4}-\d{2}-\d{2}-session-[a-f0-9]+$/.test(entry.name))
134
+ .map((entry) => {
135
+ const meta = readSessionMeta(projectRoot, entry.name);
136
+ return meta ?? {
137
+ sessionId: entry.name,
138
+ projectRoot,
139
+ createdAt: ''
140
+ };
141
+ })
142
+ .sort((a, b) => b.sessionId.localeCompare(a.sessionId));
143
+ }
63
144
  /**
64
145
  * Get or create the current session for a project.
65
146
  * If a valid session already exists, returns it.
@@ -74,13 +155,20 @@ export async function ensureSession(projectRoot) {
74
155
  return existing.sessionId;
75
156
  }
76
157
  const sessionId = generateSessionId();
158
+ const now = new Date().toISOString();
77
159
  const info = {
78
160
  sessionId,
79
- createdAt: new Date().toISOString(),
161
+ createdAt: now,
80
162
  projectRoot
81
163
  };
82
164
  writeSessionFile(projectRoot, info);
83
165
  await initWorkspace({ projectRoot, sessionId });
166
+ // Initialize session metadata inside the session directory
167
+ writeSessionMeta(projectRoot, sessionId, {
168
+ sessionId,
169
+ projectRoot,
170
+ createdAt: now
171
+ });
84
172
  return sessionId;
85
173
  }
86
174
  /**
@@ -5,6 +5,7 @@ export type SkillPresence = {
5
5
  skill: string;
6
6
  mode?: SkillPresenceMode;
7
7
  gate?: string;
8
+ sessionId?: string;
8
9
  setAt: string;
9
10
  lastHeartbeat?: string;
10
11
  };
@@ -10,19 +10,36 @@ export function isSkillPresenceMode(value) {
10
10
  return VALID_SKILL_PRESENCE_MODES.includes(value);
11
11
  }
12
12
  const PRESENCE_FILE = '.peaks/.active-skill.json';
13
+ const SESSION_FILE = '.peaks/.session.json';
13
14
  function resolvePresencePath() {
14
15
  return resolve(process.cwd(), PRESENCE_FILE);
15
16
  }
17
+ function getCurrentSessionId() {
18
+ const sessionPath = resolve(process.cwd(), SESSION_FILE);
19
+ if (!existsSync(sessionPath))
20
+ return null;
21
+ try {
22
+ const data = JSON.parse(readFileSync(sessionPath, 'utf8'));
23
+ return typeof data.sessionId === 'string' && data.sessionId.length > 0
24
+ ? data.sessionId
25
+ : null;
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
16
31
  export function exportSkillPresence() {
17
32
  return resolvePresencePath();
18
33
  }
19
34
  export function setSkillPresence(skill, mode, gate) {
20
35
  const validatedMode = mode && isSkillPresenceMode(mode) ? mode : undefined;
36
+ const sessionId = getCurrentSessionId();
21
37
  const now = new Date().toISOString();
22
38
  const presence = {
23
39
  skill,
24
40
  ...(validatedMode ? { mode: validatedMode } : {}),
25
41
  ...(gate ? { gate } : {}),
42
+ ...(sessionId ? { sessionId } : {}),
26
43
  setAt: now,
27
44
  lastHeartbeat: now
28
45
  };
@@ -45,6 +62,13 @@ export function getSkillPresence() {
45
62
  if (typeof parsed?.skill !== 'string' || parsed.skill.length === 0) {
46
63
  return null;
47
64
  }
65
+ if (typeof parsed.sessionId === 'string' && parsed.sessionId.length > 0) {
66
+ const currentSessionId = getCurrentSessionId();
67
+ if (currentSessionId && parsed.sessionId !== currentSessionId) {
68
+ unlinkSync(presencePath);
69
+ return null;
70
+ }
71
+ }
48
72
  return parsed;
49
73
  }
50
74
  catch {
@@ -62,6 +86,13 @@ export function touchSkillHeartbeat() {
62
86
  if (typeof parsed?.skill !== 'string' || parsed.skill.length === 0) {
63
87
  return null;
64
88
  }
89
+ if (typeof parsed.sessionId === 'string' && parsed.sessionId.length > 0) {
90
+ const currentSessionId = getCurrentSessionId();
91
+ if (currentSessionId && parsed.sessionId !== currentSessionId) {
92
+ unlinkSync(presencePath);
93
+ return null;
94
+ }
95
+ }
65
96
  parsed.lastHeartbeat = new Date().toISOString();
66
97
  writeFileSync(presencePath, JSON.stringify(parsed, null, 2), 'utf8');
67
98
  return parsed;
@@ -1 +1 @@
1
- export declare const CLI_VERSION = "1.0.24";
1
+ export declare const CLI_VERSION = "1.0.26";
@@ -1 +1 @@
1
- export const CLI_VERSION = "1.0.24";
1
+ export const CLI_VERSION = "1.0.26";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-cli",
3
- "version": "1.0.24",
3
+ "version": "1.0.26",
4
4
  "description": "Peaks CLI and short skill family for Claude Code automation.",
5
5
  "author": "SquabbyZ",
6
6
  "license": "MIT",
@@ -78,6 +78,16 @@ Then display the compact status header: `Peaks-Cli Skill: peaks-solo | Peaks-Cli
78
78
 
79
79
  Update with `peaks skill presence:set peaks-solo --mode <mode> --gate <gate>` when gates change. The presence file persists across the full workflow lifecycle — do NOT clear it at workflow end.
80
80
 
81
+ ### Peaks-Cli Step 2.5: Set session title
82
+
83
+ Extract a short (8-20 Chinese characters, or 4-10 English words) descriptive title from the user's first request. The title should capture the core task — e.g. "修复登录页OAuth回调异常", "添加暗色模式开关", "搭建项目基础架构". Then run:
84
+
85
+ ```bash
86
+ peaks session title $(cat .peaks/.session.json | python3 -c "import sys,json; print(json.load(sys.stdin)['sessionId'])") "<title>"
87
+ ```
88
+
89
+ If the session directory already has a title (check via `peaks session list --json`), skip this step — the title is already set.
90
+
81
91
  ## Boundaries
82
92
 
83
93
  Peaks-Cli Solo may:
@@ -566,6 +576,14 @@ After `peaks-rd` finishes any implementation, repair, or code-output slice, Peak
566
576
 
567
577
  Solo is itself a skill running in the current session. To "invoke peaks-rd" or "peaks-qa", Solo MUST use the `Skill` tool with the role's name (e.g. `Skill(skill="peaks-rd")` or `Skill(skill="peaks-qa")`), passing the `<request-id>` and `<session-id>` as arguments so the role reads the same artifacts Solo wrote. Do NOT re-implement the role's logic inline in Solo. Do NOT use the `Agent` tool with a sub-agent — role skills are skills, not agents. After the role skill returns, Solo reads the artifacts the role wrote (via the request artifact path or `peaks request show <rid> --role <role>`) to decide the next step.
568
578
 
579
+ **Presence restoration after role skill returns (MANDATORY):** Role skills (peaks-rd, peaks-qa, peaks-ui) call `peaks skill presence:set <role>` internally, which overwrites `.peaks/.active-skill.json`. After EVERY role skill returns — whether success, repair-needed, or failure — Solo MUST immediately restore the orchestrator presence by re-running the same presence command from Step 2:
580
+
581
+ ```bash
582
+ peaks skill presence:set peaks-solo --mode <mode> --gate <current-gate>
583
+ ```
584
+
585
+ This keeps the CLAUDE.md status header accurate (`Peaks-Cli Skill: peaks-solo`) instead of showing a stale role name. Use the current mode and gate values; the gate may have advanced since startup. Skipping this step causes the header to display the last role skill name permanently.
586
+
569
587
  **Full-auto auto-proceed rule**: In the `full-auto` profile, when RD transitions to `qa-handoff`, Solo immediately invokes `peaks-qa` via the Skill tool with the same `<request-id>`. Do not pause, do not ask the user, do not summarize RD results as if they were final. The only valid reason to skip QA is when `--type` is `docs` or `chore` (no acceptance surface).
570
588
 
571
589
  A QA report with any failing, blocked, missing, or unverified acceptance item is not a pass.
@@ -586,12 +604,16 @@ When `peaks-qa` returns `verdict=return-to-rd`, Solo does NOT manually rewrite R
586
604
  4. peaks-rd fixes the reported issues only (red-line scope: do not modify unrelated surfaces), regenerates code-review and security-review evidence if changes touched reviewed surfaces, then transitions `rd → implemented → qa-handoff` again.
587
605
  5. Solo invokes `peaks-qa` again with the same `<request-id>` (the same Skill call as before). QA re-runs gates against the new diff.
588
606
  6. Repeat steps 1-5 until QA returns `verdict=pass`, or the cap below fires.
607
+ **After each repair iteration** (after peaks-rd and peaks-qa both return), Solo MUST restore presence:
608
+ ```bash
609
+ peaks skill presence:set peaks-solo --mode <mode> --gate repair-cycle-<N>
610
+ ```
589
611
 
590
612
  **Repair cycle cap**: After 3 repair cycles without a passing QA verdict, emit a blocked TXT handoff regardless of remaining issues. Do not loop indefinitely. If a specific issue cannot be resolved within 3 cycles, mark it as a known blocker in the TXT handoff and proceed to the SC phase.
591
613
 
592
614
  In full-auto mode, treat the RD↔QA repair loop as a built-in controller objective: loop through RD→QA until all acceptance items pass (max 3 cycles). Do not exit the loop on a non-passing QA verdict unless the TXT handoff marks the workflow as blocked.
593
615
 
594
- ## Peaks-Cli Default runbook
616
+ ## Default runbook
595
617
 
596
618
  > **Maintenance**: The numbered workflow list above (steps 0-11) is the canonical phase sequence. This runbook is the executable CLI transcription. When updating this skill, keep both in lockstep — a change to one must be reflected in the other.
597
619
 
@@ -765,7 +787,7 @@ Do NOT call `peaks skill presence:clear` at workflow end. The presence file and
765
787
 
766
788
  **Codegraph**: Optional project-analysis before RD handoff. Use `peaks codegraph affected --project <path> <changed-files...> --json` for regression-surface hints. Output as untrusted supporting evidence only; never commit `.codegraph/` artifacts.
767
789
 
768
- ## Peaks-Cli Codegraph orchestration context
790
+ ## Codegraph orchestration context
769
791
 
770
792
  Solo treats `peaks codegraph affected --project <path> <changed-files...> --json` as an optional project-analysis enhancement that informs the role handoff between PRD, RD, and QA. The output is untrusted supporting evidence — Solo must not treat codegraph output as approval for scope, design, or QA verdict.
771
793