peaks-cli 1.2.7 → 1.2.9
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/README.md +12 -0
- package/dist/src/cli/commands/core-artifact-commands.js +36 -1
- package/dist/src/cli/commands/perf-commands.d.ts +3 -0
- package/dist/src/cli/commands/perf-commands.js +41 -0
- package/dist/src/cli/commands/progress-close-kill.d.ts +51 -0
- package/dist/src/cli/commands/progress-close-kill.js +152 -0
- package/dist/src/cli/commands/progress-commands.d.ts +3 -0
- package/dist/src/cli/commands/progress-commands.js +348 -0
- package/dist/src/cli/commands/progress-start-spawn.d.ts +59 -0
- package/dist/src/cli/commands/progress-start-spawn.js +114 -0
- package/dist/src/cli/commands/progress-watch-render.d.ts +80 -0
- package/dist/src/cli/commands/progress-watch-render.js +308 -0
- package/dist/src/cli/commands/project-commands.js +1 -1
- package/dist/src/cli/commands/scan-commands.js +22 -0
- package/dist/src/cli/program.js +4 -0
- package/dist/src/services/config/config-types.d.ts +20 -0
- package/dist/src/services/config/config-types.js +5 -1
- package/dist/src/services/memory/project-memory-service.d.ts +1 -1
- package/dist/src/services/memory/project-memory-service.js +52 -23
- package/dist/src/services/perf/perf-baseline-service.d.ts +70 -0
- package/dist/src/services/perf/perf-baseline-service.js +213 -0
- package/dist/src/services/progress/progress-service.d.ts +179 -0
- package/dist/src/services/progress/progress-service.js +276 -0
- package/dist/src/services/scan/libraries-service.d.ts +24 -0
- package/dist/src/services/scan/libraries-service.js +419 -0
- package/dist/src/services/scan/libraries-types.d.ts +59 -0
- package/dist/src/services/scan/libraries-types.js +9 -0
- package/dist/src/services/session/index.d.ts +1 -1
- package/dist/src/services/session/index.js +1 -1
- package/dist/src/services/session/session-manager.d.ts +53 -8
- package/dist/src/services/session/session-manager.js +150 -3
- package/dist/src/services/skills/skill-presence-service.d.ts +27 -1
- package/dist/src/services/skills/skill-presence-service.js +112 -9
- package/dist/src/services/skills/skill-runbook-service.js +34 -1
- package/dist/src/services/workflow/autonomous-resume-writer.js +7 -7
- package/dist/src/shared/change-id.d.ts +30 -0
- package/dist/src/shared/change-id.js +40 -6
- package/dist/src/shared/paths.d.ts +1 -1
- package/dist/src/shared/paths.js +2 -1
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +6 -2
- package/schemas/library-breaking-changes.data.json +141 -0
- package/schemas/library-breaking-changes.meta.json +6 -0
- package/schemas/library-breaking-changes.schema.json +50 -0
- package/skills/peaks-qa/SKILL.md +25 -0
- package/skills/peaks-rd/SKILL.md +221 -2
- package/skills/peaks-solo/SKILL.md +76 -316
- package/skills/peaks-solo/references/runbook.md +166 -0
- package/skills/peaks-solo/references/workflow-gates-and-types.md +177 -0
- package/skills/peaks-solo-resume/SKILL.md +81 -0
- package/skills/peaks-solo-status/SKILL.md +120 -0
- package/skills/peaks-solo-test/SKILL.md +84 -0
- package/skills/peaks-txt/SKILL.md +8 -5
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Performance baseline scaffolding for the RD stage.
|
|
3
|
+
*
|
|
4
|
+
* peaks-solo's RD stage runs before the QA stage. The user-facing pain is
|
|
5
|
+
* that performance tests (lighthouse / k6 / project-local benches / etc.)
|
|
6
|
+
* have historically only been run at QA Gate A4 — too late in the loop,
|
|
7
|
+
* because a slow regression discovered at QA triggers a return-to-rd
|
|
8
|
+
* cycle, the RD ships another "fix", QA re-runs, and the same cycle
|
|
9
|
+
* repeats up to 3 times before the slice ships.
|
|
10
|
+
*
|
|
11
|
+
* `peaks perf baseline` is the user-visible artifact of a deliberate
|
|
12
|
+
* compromise: keep the heavy performance measurement as something the
|
|
13
|
+
* RD runs themselves (lighthouse is project-shape dependent and we
|
|
14
|
+
* don't want to bake a lighthouse dependency into the CLI), but capture
|
|
15
|
+
* the result in a stable, scaffolded file under
|
|
16
|
+
* `.peaks/<sid>/rd/perf-baseline.md` so QA Gate A4 has a known-good
|
|
17
|
+
* reference to diff against. The CLI itself only writes the scaffold
|
|
18
|
+
* and records the path; the actual measurement is a project-local
|
|
19
|
+
* concern that lives in the README, not in peaks-cli.
|
|
20
|
+
*
|
|
21
|
+
* The four-grounds check (per the skill-primary-CLI-auxiliary dev
|
|
22
|
+
* preference):
|
|
23
|
+
* 1. hook/script/CI invokability — yes, a hook can call this CLI
|
|
24
|
+
* to scaffold the file on session
|
|
25
|
+
* init, similar to session bootstrap.
|
|
26
|
+
* 2. JSON envelope that gates a downstream decision — yes,
|
|
27
|
+
* peaks-rd reads the result and
|
|
28
|
+
* attaches it to the handoff.
|
|
29
|
+
* 3. Destructive --apply side effect — yes, default dry-run.
|
|
30
|
+
* 4. Machine-enforced gate that prose cannot enforce — no, the
|
|
31
|
+
* measurement still lives in
|
|
32
|
+
* the LLM / project tools. We do
|
|
33
|
+
* NOT add a lint gate here.
|
|
34
|
+
*
|
|
35
|
+
* Net: CLI is justified. The destructive --apply default is dry-run,
|
|
36
|
+
* matching the rest of peaks-cli's scaffolding pattern.
|
|
37
|
+
*/
|
|
38
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
39
|
+
import { existsSync } from 'node:fs';
|
|
40
|
+
import { join } from 'node:path';
|
|
41
|
+
import { getSessionId } from '../session/session-manager.js';
|
|
42
|
+
import { findProjectRoot } from '../config/config-safety.js';
|
|
43
|
+
const README_BODY = `# Performance baseline
|
|
44
|
+
|
|
45
|
+
> Scaffolding for the RD-side performance baseline. Created by
|
|
46
|
+
> \`peaks perf baseline\`. The actual measurement is the RD's
|
|
47
|
+
> responsibility — see the "How to fill this in" section below.
|
|
48
|
+
|
|
49
|
+
## Why this exists
|
|
50
|
+
|
|
51
|
+
The QA stage's Gate A4 (performance check) compares the slice's
|
|
52
|
+
performance against the most recent baseline. Without an RD-side
|
|
53
|
+
baseline, the first time Gate A4 runs it has nothing to compare
|
|
54
|
+
against and any regression it finds is a blind-side surprise.
|
|
55
|
+
Capturing the baseline at the RD stage — right after the
|
|
56
|
+
implementation lands and before QA picks it up — closes that
|
|
57
|
+
gap and prevents the "QA returns 3 times for the same perf
|
|
58
|
+
regression" loop.
|
|
59
|
+
|
|
60
|
+
## What to capture
|
|
61
|
+
|
|
62
|
+
For each performance-sensitive code path in the slice, record:
|
|
63
|
+
|
|
64
|
+
- **Path / route** — which entry point (page, hook, API) the
|
|
65
|
+
measurement targets.
|
|
66
|
+
- **Workload** — what you did with it (cold load, hot loop, the
|
|
67
|
+
exact N of records the slice introduces).
|
|
68
|
+
- **Tool** — lighthouse / k6 / autocannon / project-local bench
|
|
69
|
+
script. Match the tool to the workload; do not introduce a new
|
|
70
|
+
one if the project already has a benchmark script.
|
|
71
|
+
- **Metrics** — at minimum LCP / FCP / TBT / CLS for frontend,
|
|
72
|
+
p50/p95/p99 latency + rps for backend, rss / heap growth
|
|
73
|
+
for long-running services.
|
|
74
|
+
- **Baseline value** — the number you measured, with units.
|
|
75
|
+
- **Threshold** — what the slice's PRD / acceptance criteria
|
|
76
|
+
consider acceptable. If the PRD does not specify, leave this
|
|
77
|
+
field as \`TBD (ask PM)\` and surface it in the RD handoff.
|
|
78
|
+
|
|
79
|
+
## How to fill this in
|
|
80
|
+
|
|
81
|
+
1. Run the project's chosen performance tool against the
|
|
82
|
+
implementation you just landed. If the project does not have
|
|
83
|
+
a tool yet, the lightest first step is the chrome devtools
|
|
84
|
+
performance tab on the touched route.
|
|
85
|
+
2. For each metric, copy the row from "What to capture" into
|
|
86
|
+
the "Results" table below and fill in the number.
|
|
87
|
+
3. The threshold is the bar QA Gate A4 will compare against.
|
|
88
|
+
Be conservative — if the threshold is tighter than what the
|
|
89
|
+
tool reports, Gate A4 will fail.
|
|
90
|
+
|
|
91
|
+
## Results
|
|
92
|
+
|
|
93
|
+
| Path / route | Workload | Tool | Metric | Baseline | Threshold |
|
|
94
|
+
|---|---|---|---|---|---|
|
|
95
|
+
| | | | | | |
|
|
96
|
+
|
|
97
|
+
## Notes
|
|
98
|
+
|
|
99
|
+
- If the slice is documentation-only or has no user-visible
|
|
100
|
+
performance surface, write \`N/A — no perf surface\` here and
|
|
101
|
+
surface that fact in the RD handoff.
|
|
102
|
+
- If the measurement exceeded the threshold on the first run,
|
|
103
|
+
do NOT loosen the threshold to make it pass. The right move
|
|
104
|
+
is to optimise the implementation and re-measure, or to
|
|
105
|
+
surface the trade-off to the PRD owner for a threshold bump.
|
|
106
|
+
|
|
107
|
+
## Handoff
|
|
108
|
+
|
|
109
|
+
- to peaks-qa: the \`Results\` table is the input to Gate A4.
|
|
110
|
+
Without it QA cannot establish a comparison baseline.
|
|
111
|
+
- to peaks-sc: any threshold bumps captured here belong in the
|
|
112
|
+
release notes if the threshold moved.
|
|
113
|
+
`;
|
|
114
|
+
function renderBaselineTemplate() {
|
|
115
|
+
return README_BODY;
|
|
116
|
+
}
|
|
117
|
+
function buildPlan(projectRoot, apply) {
|
|
118
|
+
const sessionId = getSessionId(projectRoot);
|
|
119
|
+
const sessionRoot = sessionId !== null
|
|
120
|
+
? join(projectRoot, '.peaks', sessionId)
|
|
121
|
+
: null;
|
|
122
|
+
const perfBaselinePath = sessionRoot !== null
|
|
123
|
+
? join(sessionRoot, 'rd', 'perf-baseline.md')
|
|
124
|
+
: null;
|
|
125
|
+
const plannedWrites = [];
|
|
126
|
+
if (sessionRoot !== null && perfBaselinePath !== null) {
|
|
127
|
+
plannedWrites.push({
|
|
128
|
+
path: join(sessionRoot, 'rd'),
|
|
129
|
+
kind: 'directory',
|
|
130
|
+
bytes: 0,
|
|
131
|
+
content: ''
|
|
132
|
+
});
|
|
133
|
+
plannedWrites.push({
|
|
134
|
+
path: perfBaselinePath,
|
|
135
|
+
kind: 'file',
|
|
136
|
+
bytes: 0,
|
|
137
|
+
content: renderBaselineTemplate()
|
|
138
|
+
});
|
|
139
|
+
for (const write of plannedWrites) {
|
|
140
|
+
if (write.kind === 'file') {
|
|
141
|
+
write.bytes = Buffer.byteLength(write.content, 'utf8');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
apply,
|
|
147
|
+
projectRoot,
|
|
148
|
+
sessionId,
|
|
149
|
+
perfBaselinePath,
|
|
150
|
+
plannedWrites,
|
|
151
|
+
alreadyInitialized: false,
|
|
152
|
+
existingFiles: []
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Idempotency: skip writes for the perf-baseline file when it
|
|
157
|
+
* already exists. Re-running `peaks perf baseline` on the same
|
|
158
|
+
* session is a normal RD retry pattern (re-measurement, threshold
|
|
159
|
+
* adjustment, etc.); we must not blow away hand-edited content.
|
|
160
|
+
*/
|
|
161
|
+
async function planPerfBaselineInit(options) {
|
|
162
|
+
const plan = buildPlan(options.projectRoot, options.apply ?? false);
|
|
163
|
+
if (plan.perfBaselinePath !== null && existsSync(plan.perfBaselinePath)) {
|
|
164
|
+
plan.alreadyInitialized = true;
|
|
165
|
+
plan.existingFiles = [plan.perfBaselinePath];
|
|
166
|
+
plan.plannedWrites = [];
|
|
167
|
+
}
|
|
168
|
+
return plan;
|
|
169
|
+
}
|
|
170
|
+
export async function executePerfBaselineInit(options) {
|
|
171
|
+
const plan = await planPerfBaselineInit(options);
|
|
172
|
+
const writtenFiles = [];
|
|
173
|
+
const createdDirectories = [];
|
|
174
|
+
if (plan.sessionId === null) {
|
|
175
|
+
return {
|
|
176
|
+
...plan,
|
|
177
|
+
...(options.reason !== undefined ? { reason: options.reason } : {}),
|
|
178
|
+
writtenFiles,
|
|
179
|
+
createdDirectories
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
if (plan.apply && !plan.alreadyInitialized) {
|
|
183
|
+
for (const write of plan.plannedWrites) {
|
|
184
|
+
if (write.kind === 'directory') {
|
|
185
|
+
if (!existsSync(write.path)) {
|
|
186
|
+
await mkdir(write.path, { recursive: true });
|
|
187
|
+
createdDirectories.push(write.path);
|
|
188
|
+
}
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (write.content.length === 0)
|
|
192
|
+
continue;
|
|
193
|
+
await writeFile(write.path, write.content, 'utf8');
|
|
194
|
+
writtenFiles.push(write.path);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
...plan,
|
|
199
|
+
...(options.reason !== undefined ? { reason: options.reason } : {}),
|
|
200
|
+
writtenFiles,
|
|
201
|
+
createdDirectories
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Re-exported so the CLI command can fall back to a project-root
|
|
206
|
+
* resolution when the caller did not pass --project. The CLI does
|
|
207
|
+
* the same findProjectRoot walk that `workspace init` does; this
|
|
208
|
+
* helper exists for the command layer to import without reaching
|
|
209
|
+
* into config-safety directly.
|
|
210
|
+
*/
|
|
211
|
+
export function resolveProjectRootFromCwd(cwd) {
|
|
212
|
+
return findProjectRoot(cwd) ?? cwd;
|
|
213
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-agent progress surfacing for the RD/QA sub-agents in
|
|
3
|
+
* `peaks-solo`'s Swarm phase. A sub-agent (or the LLM via the
|
|
4
|
+
* `peaks progress step` CLI) writes a stable JSON file at
|
|
5
|
+
* `.peaks/<sid>/system/subagent-progress.json`. The user-side
|
|
6
|
+
* `peaks progress watch` CLI polls this file in a separate
|
|
7
|
+
* terminal tab and renders elapsed / spinner / sub-step. The
|
|
8
|
+
* `peaks progress start` CLI auto-spawns the watch in a new
|
|
9
|
+
* terminal window so the user does not have to remember to do
|
|
10
|
+
* it.
|
|
11
|
+
*
|
|
12
|
+
* Token cost design (the binding constraint of this feature):
|
|
13
|
+
* - The LLM side (step CLI) writes the file at most once per
|
|
14
|
+
* phase transition. That is approximately one Bash call per
|
|
15
|
+
* RD/QA sub-step. In a typical 5-step sub-agent slice the
|
|
16
|
+
* cost is < 10 output tokens.
|
|
17
|
+
* - The watch side polls a local file, not the LLM. Zero token
|
|
18
|
+
* cost.
|
|
19
|
+
* - The auto-spawn side (start CLI) is invoked once per
|
|
20
|
+
* session by the LLM at the first phase transition. One
|
|
21
|
+
* Bash call. The user closes the new terminal at any time;
|
|
22
|
+
* no further side effects.
|
|
23
|
+
*
|
|
24
|
+
* Net: the LLM pays a one-time < 10 token cost per slice to
|
|
25
|
+
* give the user real-time progress visibility. The user pays
|
|
26
|
+
* zero manual setup.
|
|
27
|
+
*
|
|
28
|
+
* This module is pure filesystem. It does NOT import the LLM
|
|
29
|
+
* harness, does NOT spawn terminals, and does NOT talk to any
|
|
30
|
+
* IPC. Those are concerns of the CLI layer (../cli/commands/
|
|
31
|
+
* progress-commands.ts and hooks-settings-service.ts).
|
|
32
|
+
*/
|
|
33
|
+
export type SubAgentProgressPhase = 'starting' | 'running' | 'verifying' | 'completing' | 'finished' | 'failed' | 'idle';
|
|
34
|
+
export type SubAgentProgressStep = {
|
|
35
|
+
/** ISO-8601 timestamp at which the sub-agent started. */
|
|
36
|
+
startedAt: string;
|
|
37
|
+
/** ISO-8601 timestamp at which the sub-agent finished (or now, if still running). */
|
|
38
|
+
updatedAt: string;
|
|
39
|
+
/** Free-form human-readable sub-step label, e.g. "running test/ut". */
|
|
40
|
+
step: string;
|
|
41
|
+
/** Current phase bucket. `idle` is the pre-start sentinel. */
|
|
42
|
+
phase: SubAgentProgressPhase;
|
|
43
|
+
/** When set, the sub-agent is finished and reports the verdict here. */
|
|
44
|
+
verdict?: 'pass' | 'return-to-rd' | 'blocked';
|
|
45
|
+
/** Optional count of in-scope files touched, assertions run, etc. */
|
|
46
|
+
counts?: {
|
|
47
|
+
filesTouched?: number;
|
|
48
|
+
testsRun?: number;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
export type SubAgentProgress = {
|
|
52
|
+
version: 1;
|
|
53
|
+
sessionId: string;
|
|
54
|
+
outerSessionId?: string;
|
|
55
|
+
/** Outer-agent role that owns the slice (e.g. "rd", "qa"). */
|
|
56
|
+
role: string;
|
|
57
|
+
/** Per-slice identifier. */
|
|
58
|
+
requestId: string;
|
|
59
|
+
/** When the sub-agent entered the first non-idle state. */
|
|
60
|
+
startedAt: string;
|
|
61
|
+
/** When the sub-agent last touched the file. */
|
|
62
|
+
updatedAt: string;
|
|
63
|
+
/** Current step. */
|
|
64
|
+
current: SubAgentProgressStep;
|
|
65
|
+
/**
|
|
66
|
+
* History of completed steps. Length is unbounded; the watch
|
|
67
|
+
* tool renders the most recent N. Kept for after-the-fact
|
|
68
|
+
* forensics ("how long did step 3 take?").
|
|
69
|
+
*/
|
|
70
|
+
history: SubAgentProgressStep[];
|
|
71
|
+
};
|
|
72
|
+
export type ReadProgressOptions = {
|
|
73
|
+
projectRoot: string;
|
|
74
|
+
};
|
|
75
|
+
export type ReadProgressResult = {
|
|
76
|
+
ok: true;
|
|
77
|
+
data: SubAgentProgress;
|
|
78
|
+
path: string;
|
|
79
|
+
} | {
|
|
80
|
+
ok: false;
|
|
81
|
+
reason: 'no-binding' | 'no-progress-file' | 'invalid-json';
|
|
82
|
+
};
|
|
83
|
+
export type WriteProgressOptions = {
|
|
84
|
+
projectRoot: string;
|
|
85
|
+
requestId: string;
|
|
86
|
+
role: string;
|
|
87
|
+
step: string;
|
|
88
|
+
phase: SubAgentProgressPhase;
|
|
89
|
+
verdict?: 'pass' | 'return-to-rd' | 'blocked';
|
|
90
|
+
counts?: SubAgentProgressStep['counts'];
|
|
91
|
+
outerSessionId?: string;
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* Read the current progress file. Returns a tagged result so
|
|
95
|
+
* the CLI can map each failure mode to a distinct nextActions
|
|
96
|
+
* hint (no-binding → run peaks workspace init; no-progress-file
|
|
97
|
+
* → sub-agent has not started yet; invalid-json → the LLM wrote
|
|
98
|
+
* garbage; recover by writing a fresh file).
|
|
99
|
+
*/
|
|
100
|
+
export declare function readSubAgentProgress(options: ReadProgressOptions): ReadProgressResult;
|
|
101
|
+
/**
|
|
102
|
+
* Append-or-replace the current step. Idempotent on identical
|
|
103
|
+
* (step, phase) — the file is rewritten with the same payload
|
|
104
|
+
* (no new history entry) so heartbeats from the same phase do
|
|
105
|
+
* not pollute the history. Phase transitions append a new step
|
|
106
|
+
* to the history and replace `current`.
|
|
107
|
+
*/
|
|
108
|
+
export declare function writeSubAgentProgress(options: WriteProgressOptions): SubAgentProgress;
|
|
109
|
+
/**
|
|
110
|
+
* Resolve the project root for a CLI invocation: --project
|
|
111
|
+
* override wins, otherwise the canonical git-root promotion
|
|
112
|
+
* (so the sub-agent's writes land in the same `.peaks/<sid>/`
|
|
113
|
+
* the user's manual CLI would use). Re-exports the same
|
|
114
|
+
* helper peaks workspace init / session rotate already use
|
|
115
|
+
* for symmetry.
|
|
116
|
+
*/
|
|
117
|
+
export declare function resolveProgressProjectRoot(override: string | undefined, cwd: string): string;
|
|
118
|
+
/**
|
|
119
|
+
* Compute the absolute path to the progress file for a given
|
|
120
|
+
* project root, for callers that need to display / fs.watch it
|
|
121
|
+
* (the watch banner, the auto-spawn helper, the close command).
|
|
122
|
+
* This MUST agree with `progressPath` — the read/write helpers
|
|
123
|
+
* resolve through `progressPath` and use the session sub-directory,
|
|
124
|
+
* so the displayed path does too. Without this agreement the
|
|
125
|
+
* watch banner would point at a path the file is never written
|
|
126
|
+
* to, and the user would `cat` an empty file.
|
|
127
|
+
*/
|
|
128
|
+
export declare function subAgentProgressPath(projectRoot: string): string;
|
|
129
|
+
/**
|
|
130
|
+
* Compute the absolute path to the spawn record for a given
|
|
131
|
+
* project root. Exported so `peaks progress close` (and the
|
|
132
|
+
* start command's success payload) can advertise the on-disk
|
|
133
|
+
* location without re-deriving the session sub-directory.
|
|
134
|
+
*/
|
|
135
|
+
export declare function subAgentSpawnPath(projectRoot: string): string;
|
|
136
|
+
export type ProgressSpawnRecord = {
|
|
137
|
+
version: 1;
|
|
138
|
+
sessionId: string;
|
|
139
|
+
pid: number;
|
|
140
|
+
platform: NodeJS.Platform;
|
|
141
|
+
command: string;
|
|
142
|
+
args: string[];
|
|
143
|
+
spawnedAt: string;
|
|
144
|
+
reason?: string;
|
|
145
|
+
/** The title we asked the terminal emulator to set. */
|
|
146
|
+
windowTitle: string;
|
|
147
|
+
};
|
|
148
|
+
export type WriteSpawnRecordOptions = {
|
|
149
|
+
projectRoot: string;
|
|
150
|
+
pid: number;
|
|
151
|
+
platform: NodeJS.Platform;
|
|
152
|
+
command: string;
|
|
153
|
+
args: string[];
|
|
154
|
+
reason?: string;
|
|
155
|
+
windowTitle: string;
|
|
156
|
+
};
|
|
157
|
+
export declare function writeSpawnRecord(options: WriteSpawnRecordOptions): ProgressSpawnRecord | null;
|
|
158
|
+
export type ReadSpawnRecordResult = {
|
|
159
|
+
ok: true;
|
|
160
|
+
data: ProgressSpawnRecord;
|
|
161
|
+
path: string;
|
|
162
|
+
} | {
|
|
163
|
+
ok: false;
|
|
164
|
+
reason: 'no-binding' | 'no-spawn-record' | 'invalid-json';
|
|
165
|
+
};
|
|
166
|
+
export declare function readSpawnRecord(projectRoot: string): ReadSpawnRecordResult;
|
|
167
|
+
export declare function clearSpawnRecord(projectRoot: string): boolean;
|
|
168
|
+
export type PhaseClosingTrigger = 'finished' | 'failed';
|
|
169
|
+
/**
|
|
170
|
+
* True if a transition into the given phase should auto-close
|
|
171
|
+
* the spawned watch window. `finished` and `failed` both
|
|
172
|
+
* indicate the sub-agent is done; a `blocked` verdict on a
|
|
173
|
+
* `finished` step is intentionally NOT a close trigger
|
|
174
|
+
* because a blocked slice usually means the user needs to
|
|
175
|
+
* read the watch output before deciding what to do. The CLI
|
|
176
|
+
* layer reads `data.current.phase`, not the verdict, so this
|
|
177
|
+
* helper is the only close-decision source of truth.
|
|
178
|
+
*/
|
|
179
|
+
export declare function phaseAutoClosesSpawn(phase: SubAgentProgressPhase): boolean;
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-agent progress surfacing for the RD/QA sub-agents in
|
|
3
|
+
* `peaks-solo`'s Swarm phase. A sub-agent (or the LLM via the
|
|
4
|
+
* `peaks progress step` CLI) writes a stable JSON file at
|
|
5
|
+
* `.peaks/<sid>/system/subagent-progress.json`. The user-side
|
|
6
|
+
* `peaks progress watch` CLI polls this file in a separate
|
|
7
|
+
* terminal tab and renders elapsed / spinner / sub-step. The
|
|
8
|
+
* `peaks progress start` CLI auto-spawns the watch in a new
|
|
9
|
+
* terminal window so the user does not have to remember to do
|
|
10
|
+
* it.
|
|
11
|
+
*
|
|
12
|
+
* Token cost design (the binding constraint of this feature):
|
|
13
|
+
* - The LLM side (step CLI) writes the file at most once per
|
|
14
|
+
* phase transition. That is approximately one Bash call per
|
|
15
|
+
* RD/QA sub-step. In a typical 5-step sub-agent slice the
|
|
16
|
+
* cost is < 10 output tokens.
|
|
17
|
+
* - The watch side polls a local file, not the LLM. Zero token
|
|
18
|
+
* cost.
|
|
19
|
+
* - The auto-spawn side (start CLI) is invoked once per
|
|
20
|
+
* session by the LLM at the first phase transition. One
|
|
21
|
+
* Bash call. The user closes the new terminal at any time;
|
|
22
|
+
* no further side effects.
|
|
23
|
+
*
|
|
24
|
+
* Net: the LLM pays a one-time < 10 token cost per slice to
|
|
25
|
+
* give the user real-time progress visibility. The user pays
|
|
26
|
+
* zero manual setup.
|
|
27
|
+
*
|
|
28
|
+
* This module is pure filesystem. It does NOT import the LLM
|
|
29
|
+
* harness, does NOT spawn terminals, and does NOT talk to any
|
|
30
|
+
* IPC. Those are concerns of the CLI layer (../cli/commands/
|
|
31
|
+
* progress-commands.ts and hooks-settings-service.ts).
|
|
32
|
+
*/
|
|
33
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
34
|
+
import { dirname, join, resolve } from 'node:path';
|
|
35
|
+
import { getSessionIdCanonical } from '../session/session-manager.js';
|
|
36
|
+
import { findProjectRoot } from '../config/config-safety.js';
|
|
37
|
+
const PROGRESS_REL_PATH = 'system/subagent-progress.json';
|
|
38
|
+
const SPAWN_REL_PATH = 'system/progress-spawn.json';
|
|
39
|
+
function progressPath(projectRoot) {
|
|
40
|
+
// The progress file lives under the *session* directory, not
|
|
41
|
+
// directly under .peaks/. Every other per-slice artefact
|
|
42
|
+
// (rd/tech-doc.md, qa/test-cases/<rid>.md, prd/requests/<rid>.md,
|
|
43
|
+
// memory/, openspec/) lives under .peaks/<sid>/, so progress
|
|
44
|
+
// should too. Without the session prefix, a session rotation
|
|
45
|
+
// would orphan the file in the project root, and switching
|
|
46
|
+
// sessions would have the watch reading the wrong slice's
|
|
47
|
+
// progress.
|
|
48
|
+
const sessionId = getSessionIdCanonical(projectRoot);
|
|
49
|
+
const subDir = sessionId ?? 'unbound';
|
|
50
|
+
return join(projectRoot, '.peaks', subDir, PROGRESS_REL_PATH);
|
|
51
|
+
}
|
|
52
|
+
function ensureParentDir(path) {
|
|
53
|
+
const dir = dirname(path);
|
|
54
|
+
if (!existsSync(dir)) {
|
|
55
|
+
mkdirSync(dir, { recursive: true });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function nowIso() {
|
|
59
|
+
return new Date().toISOString();
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Read the current progress file. Returns a tagged result so
|
|
63
|
+
* the CLI can map each failure mode to a distinct nextActions
|
|
64
|
+
* hint (no-binding → run peaks workspace init; no-progress-file
|
|
65
|
+
* → sub-agent has not started yet; invalid-json → the LLM wrote
|
|
66
|
+
* garbage; recover by writing a fresh file).
|
|
67
|
+
*/
|
|
68
|
+
export function readSubAgentProgress(options) {
|
|
69
|
+
const sessionId = getSessionIdCanonical(options.projectRoot);
|
|
70
|
+
if (sessionId === null) {
|
|
71
|
+
return { ok: false, reason: 'no-binding' };
|
|
72
|
+
}
|
|
73
|
+
const path = progressPath(options.projectRoot);
|
|
74
|
+
if (!existsSync(path)) {
|
|
75
|
+
return { ok: false, reason: 'no-progress-file' };
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const data = JSON.parse(readFileSync(path, 'utf8'));
|
|
79
|
+
if (data.version !== 1 || typeof data.sessionId !== 'string') {
|
|
80
|
+
return { ok: false, reason: 'invalid-json' };
|
|
81
|
+
}
|
|
82
|
+
return { ok: true, data, path };
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return { ok: false, reason: 'invalid-json' };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Append-or-replace the current step. Idempotent on identical
|
|
90
|
+
* (step, phase) — the file is rewritten with the same payload
|
|
91
|
+
* (no new history entry) so heartbeats from the same phase do
|
|
92
|
+
* not pollute the history. Phase transitions append a new step
|
|
93
|
+
* to the history and replace `current`.
|
|
94
|
+
*/
|
|
95
|
+
export function writeSubAgentProgress(options) {
|
|
96
|
+
const existing = readSubAgentProgress({ projectRoot: options.projectRoot });
|
|
97
|
+
const now = nowIso();
|
|
98
|
+
const path = progressPath(options.projectRoot);
|
|
99
|
+
if (existing.ok) {
|
|
100
|
+
const prev = existing.data;
|
|
101
|
+
// Heartbeat on the same current step: just bump updatedAt, do
|
|
102
|
+
// NOT add a history entry. The shape of `current` is preserved.
|
|
103
|
+
if (prev.current.step === options.step && prev.current.phase === options.phase) {
|
|
104
|
+
const next = {
|
|
105
|
+
...prev,
|
|
106
|
+
updatedAt: now,
|
|
107
|
+
current: {
|
|
108
|
+
...prev.current,
|
|
109
|
+
updatedAt: now,
|
|
110
|
+
...(options.verdict !== undefined ? { verdict: options.verdict } : {}),
|
|
111
|
+
...(options.counts !== undefined ? { counts: options.counts } : {})
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
ensureParentDir(path);
|
|
115
|
+
writeFileSync(path, JSON.stringify(next, null, 2) + '\n', 'utf8');
|
|
116
|
+
return next;
|
|
117
|
+
}
|
|
118
|
+
// Real phase / step transition: archive the prior current
|
|
119
|
+
// into history, install the new current.
|
|
120
|
+
const archived = {
|
|
121
|
+
...prev.current,
|
|
122
|
+
updatedAt: now
|
|
123
|
+
};
|
|
124
|
+
const next = {
|
|
125
|
+
...prev,
|
|
126
|
+
updatedAt: now,
|
|
127
|
+
current: {
|
|
128
|
+
startedAt: now,
|
|
129
|
+
updatedAt: now,
|
|
130
|
+
step: options.step,
|
|
131
|
+
phase: options.phase,
|
|
132
|
+
...(options.verdict !== undefined ? { verdict: options.verdict } : {}),
|
|
133
|
+
...(options.counts !== undefined ? { counts: options.counts } : {})
|
|
134
|
+
},
|
|
135
|
+
history: [...prev.history, archived]
|
|
136
|
+
};
|
|
137
|
+
ensureParentDir(path);
|
|
138
|
+
writeFileSync(path, JSON.stringify(next, null, 2) + '\n', 'utf8');
|
|
139
|
+
return next;
|
|
140
|
+
}
|
|
141
|
+
// No prior file: this is the first write. Bootstrap a fresh
|
|
142
|
+
// progress doc. The sessionId is whatever the binding points
|
|
143
|
+
// at, so cross-session confusion is impossible.
|
|
144
|
+
const sessionId = getSessionIdCanonical(options.projectRoot) ?? 'unbound';
|
|
145
|
+
const fresh = {
|
|
146
|
+
version: 1,
|
|
147
|
+
sessionId,
|
|
148
|
+
...(options.outerSessionId !== undefined ? { outerSessionId: options.outerSessionId } : {}),
|
|
149
|
+
role: options.role,
|
|
150
|
+
requestId: options.requestId,
|
|
151
|
+
startedAt: now,
|
|
152
|
+
updatedAt: now,
|
|
153
|
+
current: {
|
|
154
|
+
startedAt: now,
|
|
155
|
+
updatedAt: now,
|
|
156
|
+
step: options.step,
|
|
157
|
+
phase: options.phase,
|
|
158
|
+
...(options.verdict !== undefined ? { verdict: options.verdict } : {}),
|
|
159
|
+
...(options.counts !== undefined ? { counts: options.counts } : {})
|
|
160
|
+
},
|
|
161
|
+
history: []
|
|
162
|
+
};
|
|
163
|
+
ensureParentDir(path);
|
|
164
|
+
writeFileSync(path, JSON.stringify(fresh, null, 2) + '\n', 'utf8');
|
|
165
|
+
return fresh;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Resolve the project root for a CLI invocation: --project
|
|
169
|
+
* override wins, otherwise the canonical git-root promotion
|
|
170
|
+
* (so the sub-agent's writes land in the same `.peaks/<sid>/`
|
|
171
|
+
* the user's manual CLI would use). Re-exports the same
|
|
172
|
+
* helper peaks workspace init / session rotate already use
|
|
173
|
+
* for symmetry.
|
|
174
|
+
*/
|
|
175
|
+
export function resolveProgressProjectRoot(override, cwd) {
|
|
176
|
+
if (override !== undefined)
|
|
177
|
+
return override;
|
|
178
|
+
return findProjectRoot(cwd) ?? cwd;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Compute the absolute path to the progress file for a given
|
|
182
|
+
* project root, for callers that need to display / fs.watch it
|
|
183
|
+
* (the watch banner, the auto-spawn helper, the close command).
|
|
184
|
+
* This MUST agree with `progressPath` — the read/write helpers
|
|
185
|
+
* resolve through `progressPath` and use the session sub-directory,
|
|
186
|
+
* so the displayed path does too. Without this agreement the
|
|
187
|
+
* watch banner would point at a path the file is never written
|
|
188
|
+
* to, and the user would `cat` an empty file.
|
|
189
|
+
*/
|
|
190
|
+
export function subAgentProgressPath(projectRoot) {
|
|
191
|
+
return progressPath(resolve(projectRoot));
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Compute the absolute path to the spawn record for a given
|
|
195
|
+
* project root. Exported so `peaks progress close` (and the
|
|
196
|
+
* start command's success payload) can advertise the on-disk
|
|
197
|
+
* location without re-deriving the session sub-directory.
|
|
198
|
+
*/
|
|
199
|
+
export function subAgentSpawnPath(projectRoot) {
|
|
200
|
+
const sessionId = getSessionIdCanonical(projectRoot);
|
|
201
|
+
const subDir = sessionId ?? 'unbound';
|
|
202
|
+
return join(projectRoot, '.peaks', subDir, SPAWN_REL_PATH);
|
|
203
|
+
}
|
|
204
|
+
function spawnRecordPath(projectRoot) {
|
|
205
|
+
const sessionId = getSessionIdCanonical(projectRoot);
|
|
206
|
+
const subDir = sessionId ?? 'unbound';
|
|
207
|
+
return join(projectRoot, '.peaks', subDir, SPAWN_REL_PATH);
|
|
208
|
+
}
|
|
209
|
+
export function writeSpawnRecord(options) {
|
|
210
|
+
const sessionId = getSessionIdCanonical(options.projectRoot);
|
|
211
|
+
if (sessionId === null)
|
|
212
|
+
return null;
|
|
213
|
+
const now = nowIso();
|
|
214
|
+
const record = {
|
|
215
|
+
version: 1,
|
|
216
|
+
sessionId,
|
|
217
|
+
pid: options.pid,
|
|
218
|
+
platform: options.platform,
|
|
219
|
+
command: options.command,
|
|
220
|
+
args: options.args,
|
|
221
|
+
spawnedAt: now,
|
|
222
|
+
...(options.reason !== undefined ? { reason: options.reason } : {}),
|
|
223
|
+
windowTitle: options.windowTitle
|
|
224
|
+
};
|
|
225
|
+
const path = spawnRecordPath(options.projectRoot);
|
|
226
|
+
ensureParentDir(path);
|
|
227
|
+
writeFileSync(path, JSON.stringify(record, null, 2) + '\n', 'utf8');
|
|
228
|
+
return record;
|
|
229
|
+
}
|
|
230
|
+
export function readSpawnRecord(projectRoot) {
|
|
231
|
+
const sessionId = getSessionIdCanonical(projectRoot);
|
|
232
|
+
if (sessionId === null)
|
|
233
|
+
return { ok: false, reason: 'no-binding' };
|
|
234
|
+
const path = spawnRecordPath(projectRoot);
|
|
235
|
+
if (!existsSync(path))
|
|
236
|
+
return { ok: false, reason: 'no-spawn-record' };
|
|
237
|
+
try {
|
|
238
|
+
const data = JSON.parse(readFileSync(path, 'utf8'));
|
|
239
|
+
if (data.version !== 1 || typeof data.pid !== 'number') {
|
|
240
|
+
return { ok: false, reason: 'invalid-json' };
|
|
241
|
+
}
|
|
242
|
+
return { ok: true, data, path };
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return { ok: false, reason: 'invalid-json' };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
export function clearSpawnRecord(projectRoot) {
|
|
249
|
+
const path = spawnRecordPath(projectRoot);
|
|
250
|
+
if (!existsSync(path))
|
|
251
|
+
return false;
|
|
252
|
+
try {
|
|
253
|
+
unlinkSync(path);
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const PHASES_THAT_AUTO_CLOSE = new Set([
|
|
261
|
+
'finished',
|
|
262
|
+
'failed'
|
|
263
|
+
]);
|
|
264
|
+
/**
|
|
265
|
+
* True if a transition into the given phase should auto-close
|
|
266
|
+
* the spawned watch window. `finished` and `failed` both
|
|
267
|
+
* indicate the sub-agent is done; a `blocked` verdict on a
|
|
268
|
+
* `finished` step is intentionally NOT a close trigger
|
|
269
|
+
* because a blocked slice usually means the user needs to
|
|
270
|
+
* read the watch output before deciding what to do. The CLI
|
|
271
|
+
* layer reads `data.current.phase`, not the verdict, so this
|
|
272
|
+
* helper is the only close-decision source of truth.
|
|
273
|
+
*/
|
|
274
|
+
export function phaseAutoClosesSpawn(phase) {
|
|
275
|
+
return PHASES_THAT_AUTO_CLOSE.has(phase);
|
|
276
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { LibraryReport } from './libraries-types.js';
|
|
2
|
+
export type ScanLibrariesOptions = {
|
|
3
|
+
projectRoot: string;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Parse the major version from a semver-ish spec.
|
|
7
|
+
*
|
|
8
|
+
* Handles the common shapes:
|
|
9
|
+
* "^5.18.0" → 5
|
|
10
|
+
* "~1.2.3" → 1
|
|
11
|
+
* "1.2.3" → 1
|
|
12
|
+
* ">=1.0.0" → 1
|
|
13
|
+
* "5" → 5
|
|
14
|
+
* "5.x" → 5
|
|
15
|
+
*
|
|
16
|
+
* Returns null for non-semver specs that the LLM should not assume a
|
|
17
|
+
* major for:
|
|
18
|
+
* "workspace:*" → null
|
|
19
|
+
* "file:../..." → null
|
|
20
|
+
* "git+https..." → null
|
|
21
|
+
* "npm:@scope/x@1" → 1 (alias spec, we extract what we can)
|
|
22
|
+
*/
|
|
23
|
+
export declare function parseMajorVersion(spec: string): number | null;
|
|
24
|
+
export declare function scanLibraries(options: ScanLibrariesOptions): Promise<LibraryReport>;
|