peaks-cli 1.3.6 → 1.3.7
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/dist/src/cli/commands/slice-commands.js +9 -5
- package/dist/src/cli/commands/workspace-commands.js +46 -2
- package/dist/src/services/session/session-manager.d.ts +55 -0
- package/dist/src/services/session/session-manager.js +68 -0
- package/dist/src/services/skills/skill-presence-service.d.ts +10 -4
- package/dist/src/services/skills/skill-presence-service.js +16 -11
- package/dist/src/services/slice/slice-check-service.js +36 -18
- package/dist/src/services/slice/slice-check-types.d.ts +40 -6
- package/dist/src/services/slice/slice-check-types.js +11 -1
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +1 -1
- package/skills/peaks-qa/SKILL.md +5 -2
- package/skills/peaks-rd/SKILL.md +9 -4
- package/skills/peaks-solo/references/micro-cycle.md +4 -2
|
@@ -5,25 +5,29 @@ import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
|
|
|
5
5
|
export function registerSliceCommands(program, io) {
|
|
6
6
|
const slice = program.command('slice').description('Run slice-level checks (TDD micro-cycle boundary, see ' +
|
|
7
7
|
'skills/peaks-solo/references/micro-cycle.md). `peaks slice check` bundles ' +
|
|
8
|
-
'tsc + vitest + 3-way review fan-out + gate verify-pipeline. ' +
|
|
8
|
+
'tsc + vitest (changed-only by default) + 3-way review fan-out + gate verify-pipeline. ' +
|
|
9
9
|
'Boundaries only; do NOT run inside a micro-cycle.');
|
|
10
10
|
addJsonOption(slice
|
|
11
11
|
.command('check')
|
|
12
12
|
.description('Boundary check for a slice (post-micro-cycle, pre-peaks-qa). ' +
|
|
13
|
-
'Runs 4 stages in order: typecheck → unit-tests
|
|
14
|
-
'
|
|
13
|
+
'Runs 4 stages in order: typecheck → unit-tests (changed-only by default; ' +
|
|
14
|
+
'use --run-tests for the full suite, or --skip-tests to opt out) → ' +
|
|
15
|
+
'review-fanout → gate-verify-pipeline. ' +
|
|
16
|
+
'Each stage reports pass / fail / skipped. ' +
|
|
15
17
|
'Exit 0 only if every stage passes or is skipped.')
|
|
16
18
|
.option('--project <path>', 'target project root', '.')
|
|
17
19
|
.option('--rid <rid>', 'request id; defaults to the active current-change binding')
|
|
18
20
|
.option('--refresh-fanout', 're-run the 3-way review fan-out (peaks-rd) even if the review files already exist', false)
|
|
19
|
-
.option('--
|
|
20
|
-
.option('--
|
|
21
|
+
.option('--run-tests', 'opt in to the FULL test suite at the boundary (default is the changed-only suite via `vitest run --changed`); use the peaks-solo-test skill to run the full suite standalone', false)
|
|
22
|
+
.option('--skip-tests', 'skip the unit-test stage entirely (e.g. docs-only slices); use the peaks-solo-test skill to run the full suite manually if you want a separate check', false)
|
|
23
|
+
.option('--allow-pre-existing-failures', 'opt-in: if the unit-test stage fails, report it as `skipped` with a reason naming the failure count (useful when the repo has unrelated pre-existing failures; the long-term fix is to .skip or coverage.exclude those tests). Only meaningful with --run-tests or the default changed-only mode.', false)).action(async (options) => {
|
|
21
24
|
try {
|
|
22
25
|
const projectRoot = resolveCanonicalProjectRoot(options.project);
|
|
23
26
|
const result = await sliceCheck({
|
|
24
27
|
projectRoot,
|
|
25
28
|
...(options.rid ? { rid: options.rid } : {}),
|
|
26
29
|
refreshFanout: options.refreshFanout === true,
|
|
30
|
+
runTests: options.runTests === true,
|
|
27
31
|
skipTests: options.skipTests === true,
|
|
28
32
|
allowPreExistingFailures: options.allowPreExistingFailures === true
|
|
29
33
|
});
|
|
@@ -4,7 +4,7 @@ import { createInterface } from 'node:readline';
|
|
|
4
4
|
import { initWorkspace, InvalidSessionIdError, ConflictingSessionError } from '../../services/workspace/workspace-service.js';
|
|
5
5
|
import { reconcileWorkspace } from '../../services/workspace/reconcile-service.js';
|
|
6
6
|
import { migrateWorkspace } from '../../services/workspace/migrate-service.js';
|
|
7
|
-
import {
|
|
7
|
+
import { ensureSessionWithRotation } from '../../services/session/session-manager.js';
|
|
8
8
|
import { resolveCanonicalProjectRoot } from '../../services/config/config-service.js';
|
|
9
9
|
import { applyHookInstall, readHookStatus } from '../../services/skills/hooks-settings-service.js';
|
|
10
10
|
import { fail, ok } from '../../shared/result.js';
|
|
@@ -93,6 +93,7 @@ export function registerWorkspaceCommands(program, io) {
|
|
|
93
93
|
.requiredOption('--project <path>', 'target project root')
|
|
94
94
|
.option('--session-id <id>', 'optional session id in YYYY-MM-DD-<kebab-slug> format. When omitted, the CLI is the single source of truth: an existing binding is reused, otherwise a fresh id is auto-generated.')
|
|
95
95
|
.option('--allow-session-rebind', 'overwrite an existing session binding when the requested session id differs from the project current one', false)
|
|
96
|
+
.option('--no-rotate-on-outer-mismatch', 'suppress the auto-rotation of the project session binding when the outer (Claude / harness) session id has changed. Default rotates on mismatch.')
|
|
96
97
|
.option('--change-id <id>', 'bind the change-id for reviewable artifacts (writes route to .peaks/<change-id>/<role>/, tracked in git). When omitted, the change-id binding is left unchanged.', (value) => {
|
|
97
98
|
if (value.length === 0) {
|
|
98
99
|
throw new Error('--change-id must not be empty');
|
|
@@ -124,12 +125,41 @@ export function registerWorkspaceCommands(program, io) {
|
|
|
124
125
|
// the 5/27-5/29 sessions). When startPath is not inside any
|
|
125
126
|
// git repo, the helper falls through to the cwd verbatim.
|
|
126
127
|
const projectRoot = resolveCanonicalProjectRoot(options.project);
|
|
128
|
+
// Slice 018: outer-session-mismatch auto-rotation. When the
|
|
129
|
+
// user did NOT pass --session-id explicitly, run
|
|
130
|
+
// `ensureSessionWithRotation` so the binding is rotated on
|
|
131
|
+
// outer-mismatch before `initWorkspace` is called. The
|
|
132
|
+
// rotation result is surfaced in the JSON envelope via
|
|
133
|
+
// `data.rotation`. When --session-id IS passed, the user has
|
|
134
|
+
// explicitly told us which session to bind — we honor that
|
|
135
|
+
// verbatim and do NOT rotate (rotation only fires for the
|
|
136
|
+
// auto-detect path).
|
|
127
137
|
let sessionId;
|
|
138
|
+
let rotation = {
|
|
139
|
+
previousSessionId: null,
|
|
140
|
+
reason: null
|
|
141
|
+
};
|
|
128
142
|
if (options.sessionId !== undefined && options.sessionId.length > 0) {
|
|
129
143
|
sessionId = options.sessionId;
|
|
130
144
|
}
|
|
131
145
|
else {
|
|
132
|
-
|
|
146
|
+
const result = await ensureSessionWithRotation(projectRoot, {
|
|
147
|
+
// Commander translates `--no-rotate-on-outer-mismatch` into
|
|
148
|
+
// `options.rotateOnOuterMismatch = false` (the `--no-` prefix
|
|
149
|
+
// is consumed and the remainder becomes the JS property name,
|
|
150
|
+
// with the boolean value flipped). The pre-slice-014 anti-
|
|
151
|
+
// pattern (reading `options.<flag-with-no-prefix> === true`)
|
|
152
|
+
// is NOT used here. The default (no flag) leaves
|
|
153
|
+
// `options.rotateOnOuterMismatch` undefined, which is not
|
|
154
|
+
// equal to `false`, so the default is "rotate on mismatch"
|
|
155
|
+
// (the new auto-roll).
|
|
156
|
+
skipRotateOnOuterMismatch: options.rotateOnOuterMismatch === false
|
|
157
|
+
});
|
|
158
|
+
sessionId = result.sessionId;
|
|
159
|
+
rotation = {
|
|
160
|
+
previousSessionId: result.previousSessionId,
|
|
161
|
+
reason: result.rotationReason
|
|
162
|
+
};
|
|
133
163
|
}
|
|
134
164
|
const report = await initWorkspace({
|
|
135
165
|
projectRoot,
|
|
@@ -141,6 +171,14 @@ export function registerWorkspaceCommands(program, io) {
|
|
|
141
171
|
if (report.previousSessionId !== null && report.bound) {
|
|
142
172
|
nextActions.push(`Replaced prior session binding "${report.previousSessionId}" with "${report.sessionId}".`);
|
|
143
173
|
}
|
|
174
|
+
if (rotation.previousSessionId !== null && rotation.reason === 'outer-session-mismatch') {
|
|
175
|
+
// Outer-session-mismatch rotation: the previous Claude / harness
|
|
176
|
+
// session is no longer the LLM driver. The new binding is fresh,
|
|
177
|
+
// the old session dir is preserved on disk.
|
|
178
|
+
nextActions.push(`Auto-rotated session binding: outer session id changed (was "${rotation.previousSessionId}"). ` +
|
|
179
|
+
`New binding is "${sessionId}". The previous session dir is preserved at .peaks/_runtime/${rotation.previousSessionId}/. ` +
|
|
180
|
+
`Re-run with --no-rotate-on-outer-mismatch to suppress this rotation.`);
|
|
181
|
+
}
|
|
144
182
|
if (report.created.length === 0) {
|
|
145
183
|
nextActions.push('Workspace already initialized — proceed to project scan.');
|
|
146
184
|
}
|
|
@@ -168,6 +206,12 @@ export function registerWorkspaceCommands(program, io) {
|
|
|
168
206
|
}
|
|
169
207
|
printResult(io, ok('workspace.init', {
|
|
170
208
|
...report,
|
|
209
|
+
// Slice 018: surface outer-session-mismatch rotation in the
|
|
210
|
+
// JSON envelope so the LLM and the human both see the swap.
|
|
211
|
+
// Field is omitted (not null) when no rotation fired.
|
|
212
|
+
...(rotation.previousSessionId !== null && rotation.reason !== null
|
|
213
|
+
? { rotation: { previousSessionId: rotation.previousSessionId, reason: rotation.reason } }
|
|
214
|
+
: {}),
|
|
171
215
|
hooksInstall: {
|
|
172
216
|
decision: hooksOutcome.decision,
|
|
173
217
|
action: hooksOutcome.action,
|
|
@@ -89,7 +89,62 @@ export declare function setSessionTitle(projectRoot: string, sessionId: string,
|
|
|
89
89
|
* release) but is not authoritative.
|
|
90
90
|
*/
|
|
91
91
|
export declare function listSessionMetas(projectRoot: string): SessionMeta[];
|
|
92
|
+
export type EnsureSessionOptions = {
|
|
93
|
+
/**
|
|
94
|
+
* When `true`, suppress the outer-session-mismatch auto-rotation.
|
|
95
|
+
* The caller wants today's "stamp the field, do not rotate" behaviour
|
|
96
|
+
* even when the outer session id has changed. Used by
|
|
97
|
+
* `peaks workspace init --no-rotate-on-outer-mismatch`.
|
|
98
|
+
*/
|
|
99
|
+
skipRotateOnOuterMismatch?: boolean;
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* Result of `ensureSessionWithRotation`. When the bound session was
|
|
103
|
+
* rotated because the outer session id had changed, `previousSessionId`
|
|
104
|
+
* is the id of the unbound session and `rotationReason` is the structured
|
|
105
|
+
* reason code the CLI surfaces in its JSON envelope.
|
|
106
|
+
*/
|
|
107
|
+
export type EnsureSessionResult = {
|
|
108
|
+
sessionId: string;
|
|
109
|
+
previousSessionId: string | null;
|
|
110
|
+
rotationReason: 'outer-session-mismatch' | null;
|
|
111
|
+
};
|
|
92
112
|
export declare function ensureSession(projectRoot: string): Promise<string>;
|
|
113
|
+
/**
|
|
114
|
+
* Outer-session-aware wrapper around `ensureSession`.
|
|
115
|
+
*
|
|
116
|
+
* Slice 018 (auto-roll on outer-mismatch). When the current outer
|
|
117
|
+
* session id (sourced from `PEAKS_OUTER_SESSION_ID` with
|
|
118
|
+
* `CLAUDE_CODE_SESSION_ID` as the Claude-Code fallback) differs from
|
|
119
|
+
* the outer session id recorded on the *bound* peaks session's
|
|
120
|
+
* `.peaks/_runtime/<sid>/session.json`, the project-level session
|
|
121
|
+
* binding is rotated before `ensureSession` is called. The old
|
|
122
|
+
* session dir is preserved on disk (data is never wiped) — only the
|
|
123
|
+
* binding changes — and the rotation is surfaced in the return value
|
|
124
|
+
* so the CLI can include it in the JSON envelope.
|
|
125
|
+
*
|
|
126
|
+
* Rotation is suppressed in three cases (all false-positive guards):
|
|
127
|
+
*
|
|
128
|
+
* 1. The current outer session id is undefined (no env var set) —
|
|
129
|
+
* there is no signal to compare against, defaulting to "do not
|
|
130
|
+
* rotate" avoids orphaning the session.
|
|
131
|
+
* 2. The bound session has no recorded `outerSessionId` (legacy
|
|
132
|
+
* session predating the outer-session contract) — there is no
|
|
133
|
+
* signal on the other side either.
|
|
134
|
+
* 3. The bound session's recorded outer session id matches the
|
|
135
|
+
* current one (reconnect within the same Claude session) — this
|
|
136
|
+
* is the common case, not a swap.
|
|
137
|
+
*
|
|
138
|
+
* When `options.skipRotateOnOuterMismatch === true`, the rotation
|
|
139
|
+
* check is short-circuited and the binding is preserved (opt-out for
|
|
140
|
+
* `peaks workspace init --no-rotate-on-outer-mismatch`). The wrapper
|
|
141
|
+
* still delegates to `ensureSession` so the caller gets the existing
|
|
142
|
+
* binding on a reconnect and a fresh id on a first run.
|
|
143
|
+
*
|
|
144
|
+
* Existing public surface is preserved: `ensureSession` is unchanged.
|
|
145
|
+
* This wrapper is the new entry point the CLI uses.
|
|
146
|
+
*/
|
|
147
|
+
export declare function ensureSessionWithRotation(projectRoot: string, options?: EnsureSessionOptions): Promise<EnsureSessionResult>;
|
|
93
148
|
/**
|
|
94
149
|
* Get the current session ID without creating a new one.
|
|
95
150
|
* Returns null if no session exists.
|
|
@@ -421,6 +421,74 @@ export async function ensureSession(projectRoot) {
|
|
|
421
421
|
});
|
|
422
422
|
return sessionId;
|
|
423
423
|
}
|
|
424
|
+
/**
|
|
425
|
+
* Outer-session-aware wrapper around `ensureSession`.
|
|
426
|
+
*
|
|
427
|
+
* Slice 018 (auto-roll on outer-mismatch). When the current outer
|
|
428
|
+
* session id (sourced from `PEAKS_OUTER_SESSION_ID` with
|
|
429
|
+
* `CLAUDE_CODE_SESSION_ID` as the Claude-Code fallback) differs from
|
|
430
|
+
* the outer session id recorded on the *bound* peaks session's
|
|
431
|
+
* `.peaks/_runtime/<sid>/session.json`, the project-level session
|
|
432
|
+
* binding is rotated before `ensureSession` is called. The old
|
|
433
|
+
* session dir is preserved on disk (data is never wiped) — only the
|
|
434
|
+
* binding changes — and the rotation is surfaced in the return value
|
|
435
|
+
* so the CLI can include it in the JSON envelope.
|
|
436
|
+
*
|
|
437
|
+
* Rotation is suppressed in three cases (all false-positive guards):
|
|
438
|
+
*
|
|
439
|
+
* 1. The current outer session id is undefined (no env var set) —
|
|
440
|
+
* there is no signal to compare against, defaulting to "do not
|
|
441
|
+
* rotate" avoids orphaning the session.
|
|
442
|
+
* 2. The bound session has no recorded `outerSessionId` (legacy
|
|
443
|
+
* session predating the outer-session contract) — there is no
|
|
444
|
+
* signal on the other side either.
|
|
445
|
+
* 3. The bound session's recorded outer session id matches the
|
|
446
|
+
* current one (reconnect within the same Claude session) — this
|
|
447
|
+
* is the common case, not a swap.
|
|
448
|
+
*
|
|
449
|
+
* When `options.skipRotateOnOuterMismatch === true`, the rotation
|
|
450
|
+
* check is short-circuited and the binding is preserved (opt-out for
|
|
451
|
+
* `peaks workspace init --no-rotate-on-outer-mismatch`). The wrapper
|
|
452
|
+
* still delegates to `ensureSession` so the caller gets the existing
|
|
453
|
+
* binding on a reconnect and a fresh id on a first run.
|
|
454
|
+
*
|
|
455
|
+
* Existing public surface is preserved: `ensureSession` is unchanged.
|
|
456
|
+
* This wrapper is the new entry point the CLI uses.
|
|
457
|
+
*/
|
|
458
|
+
export async function ensureSessionWithRotation(projectRoot, options) {
|
|
459
|
+
const skipRotate = options?.skipRotateOnOuterMismatch === true;
|
|
460
|
+
const currentOuterSessionId = getCurrentOuterSessionId();
|
|
461
|
+
// Compute the rotation decision up front. We only rotate when ALL
|
|
462
|
+
// three pre-conditions hold: (a) the current outer session id is
|
|
463
|
+
// defined, (b) the bound session has a recorded outer session id,
|
|
464
|
+
// and (c) the two differ. The bound session id is the *first*
|
|
465
|
+
// read so we can use it both for the comparison and for the
|
|
466
|
+
// rotation result.
|
|
467
|
+
const boundSessionId = getSessionId(projectRoot);
|
|
468
|
+
let rotated = null;
|
|
469
|
+
let rotationReason = null;
|
|
470
|
+
if (boundSessionId !== null && currentOuterSessionId !== undefined) {
|
|
471
|
+
const boundMeta = getSessionMeta(projectRoot, boundSessionId);
|
|
472
|
+
const boundOuter = boundMeta?.outerSessionId;
|
|
473
|
+
if (typeof boundOuter === 'string' &&
|
|
474
|
+
boundOuter.length > 0 &&
|
|
475
|
+
boundOuter !== currentOuterSessionId &&
|
|
476
|
+
!skipRotate) {
|
|
477
|
+
rotated = rotateSessionBinding(projectRoot);
|
|
478
|
+
rotationReason = 'outer-session-mismatch';
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
// After the rotation, `ensureSession` will either reuse the
|
|
482
|
+
// canonical-fallback binding (when one still exists, e.g. a sibling
|
|
483
|
+
// projectRoot form) or auto-generate a fresh id. We pass through.
|
|
484
|
+
void rotated; // rotated is the *previous* session id; preserved for the caller via the return value
|
|
485
|
+
const sessionId = await ensureSession(projectRoot);
|
|
486
|
+
return {
|
|
487
|
+
sessionId,
|
|
488
|
+
previousSessionId: rotated,
|
|
489
|
+
rotationReason
|
|
490
|
+
};
|
|
491
|
+
}
|
|
424
492
|
/**
|
|
425
493
|
* Get the current session ID without creating a new one.
|
|
426
494
|
* Returns null if no session exists.
|
|
@@ -22,10 +22,16 @@ export type SkillPresence = {
|
|
|
22
22
|
* Set by `setSkillPresence` when the outer session id changed
|
|
23
23
|
* between the last presence write and this one AND the bound
|
|
24
24
|
* peaks session has a different (or no) recorded outer session id.
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
25
|
+
*
|
|
26
|
+
* As of slice 018 (auto-roll on outer-mismatch), the field is
|
|
27
|
+
* informational only — it tells the statusline and any log /
|
|
28
|
+
* observability consumer that an outer-session swap was observed
|
|
29
|
+
* on the previous heartbeat. The actual binding rotation is
|
|
30
|
+
* performed by `ensureSessionWithRotation` (slice 018), not by
|
|
31
|
+
* `setSkillPresence`. `peaks-solo`'s Step 0 used to read this
|
|
32
|
+
* field and turn it into an AskUserQuestion; that ask is no
|
|
33
|
+
* longer needed because the rotation already happened by the time
|
|
34
|
+
* the skill is invoked.
|
|
29
35
|
*/
|
|
30
36
|
outerSessionMismatch?: {
|
|
31
37
|
previous?: string;
|
|
@@ -124,18 +124,23 @@ function getBoundOuterSessionId(projectRootOverride) {
|
|
|
124
124
|
* Used to detect "the LLM just opened a fresh outer session" — if
|
|
125
125
|
* the previously-recorded outer session id differs from the one we
|
|
126
126
|
* are about to stamp, the user probably closed the previous outer
|
|
127
|
-
* session and is now driving peaks from a new one.
|
|
128
|
-
* roll a new peaks session (that is destructive — it would leave
|
|
129
|
-
* the in-flight session with no LLM watching it). Instead we emit
|
|
130
|
-
* a structured `outerSessionMismatch` field on the presence
|
|
131
|
-
* envelope, and peaks-solo's Step 0 turns that into an
|
|
132
|
-
* AskUserQuestion. The user can opt to keep the current session
|
|
133
|
-
* (most common when the swap is a no-op reconnect) or to roll a
|
|
134
|
-
* fresh session (when the new outer session is genuinely a new
|
|
135
|
-
* task).
|
|
127
|
+
* session and is now driving peaks from a new one.
|
|
136
128
|
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
129
|
+
* As of slice 018 (auto-roll on outer-mismatch), the actual rotation
|
|
130
|
+
* is `ensureSessionWithRotation`'s job, not this one. The presence
|
|
131
|
+
* service still emits the structured `outerSessionMismatch` field on
|
|
132
|
+
* the presence envelope (useful for the statusline to render a stale
|
|
133
|
+
* marker and for the QA / log consumers to know an outer-session swap
|
|
134
|
+
* happened), but it no longer carries the implicit "ask the user"
|
|
135
|
+
* promise — `peaks-solo`'s Step 0 no longer needs to surface an
|
|
136
|
+
* AskUserQuestion, because the rotation already fired by the time the
|
|
137
|
+
* skill is invoked.
|
|
138
|
+
*
|
|
139
|
+
* `getPreviousOuterSessionId` keeps its read-side role: it powers the
|
|
140
|
+
* informational `outerSessionMismatch` field below and the legacy
|
|
141
|
+
* `claudeSessionId` back-compat. Reads from
|
|
142
|
+
* `.peaks/_runtime/active-skill.json` first; falls back to the
|
|
143
|
+
* legacy `.peaks/.active-skill.json` for one minor release.
|
|
139
144
|
*/
|
|
140
145
|
function getPreviousOuterSessionId(projectRootOverride) {
|
|
141
146
|
const result = readSkillPresenceBackCompat(projectRootOverride);
|
|
@@ -69,16 +69,27 @@ function parseVitestSummary(stdout, fallbackDuration) {
|
|
|
69
69
|
durationMs: durationMatch ? Math.round(parseFloat(durationMatch[1]) * 1000) : fallbackDuration
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
|
-
async function runUnitTests(projectRoot) {
|
|
72
|
+
async function runUnitTests(projectRoot, runTests) {
|
|
73
73
|
const start = Date.now();
|
|
74
|
-
|
|
74
|
+
// Default: changed-only suite (`vitest run --changed`) — runs only tests
|
|
75
|
+
// related to git-changed files. Cost drops from 30s+ to ~1-3s in steady
|
|
76
|
+
// state. Opt-in to the full suite via `runTests: true` (CLI flag
|
|
77
|
+
// `--run-tests`). See `references/runbook.md` for the rationale and
|
|
78
|
+
// `tests/unit/slice-check-service.test.ts` for the regression net.
|
|
79
|
+
const args = runTests
|
|
80
|
+
? ['vitest', 'run', '--reporter=default', '--coverage=false']
|
|
81
|
+
: ['vitest', 'run', '--changed', '--reporter=default', '--coverage=false'];
|
|
82
|
+
const description = runTests
|
|
83
|
+
? 'npx vitest run (full test suite, coverage off)'
|
|
84
|
+
: 'npx vitest run --changed (tests for git-changed files only, coverage off)';
|
|
85
|
+
const result = runCommand('npx', args, projectRoot, 600_000);
|
|
75
86
|
const summary = parseVitestSummary(result.stdout, result.durationMs);
|
|
76
87
|
// Vitest doesn't always print the per-bucket counts cleanly; infer "passed"
|
|
77
88
|
// as total - failed - skipped when failed/skipped buckets are present.
|
|
78
89
|
const passed = Math.max(summary.tests - summary.failed - summary.skipped, 0);
|
|
79
90
|
return {
|
|
80
91
|
name: 'unit-tests',
|
|
81
|
-
description
|
|
92
|
+
description,
|
|
82
93
|
status: result.status,
|
|
83
94
|
durationMs: result.durationMs,
|
|
84
95
|
detail: result.status === 'pass'
|
|
@@ -89,6 +100,7 @@ async function runUnitTests(projectRoot) {
|
|
|
89
100
|
passed,
|
|
90
101
|
failed: summary.failed,
|
|
91
102
|
skipped: summary.skipped,
|
|
103
|
+
mode: runTests ? 'full' : 'changed',
|
|
92
104
|
exitCode: result.exitCode
|
|
93
105
|
}
|
|
94
106
|
};
|
|
@@ -206,40 +218,45 @@ export async function sliceCheck(options) {
|
|
|
206
218
|
}
|
|
207
219
|
const totalStart = Date.now();
|
|
208
220
|
const stages = [];
|
|
221
|
+
let unitTestsRunMode = 'skipped';
|
|
209
222
|
// Stage 1: typecheck
|
|
210
223
|
stages.push(await runTypecheck(options.projectRoot));
|
|
211
|
-
// Stage 2: full
|
|
212
|
-
if (
|
|
213
|
-
|
|
214
|
-
|
|
224
|
+
// Stage 2: unit-tests — by default changed-only suite, opt-in to full
|
|
225
|
+
if (options.skipTests) {
|
|
226
|
+
stages.push({
|
|
227
|
+
name: 'unit-tests',
|
|
228
|
+
description: 'npx vitest run (skipped per --skip-tests)',
|
|
229
|
+
status: 'skipped',
|
|
230
|
+
durationMs: 0,
|
|
231
|
+
detail: 'Skipped: --skip-tests was set. Use the peaks-solo-test skill to run the full suite manually.'
|
|
232
|
+
});
|
|
233
|
+
unitTestsRunMode = 'skipped';
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
const unitTests = await runUnitTests(options.projectRoot, options.runTests === true);
|
|
215
237
|
// unit-test stage failed, downgrade `failed` to `skipped` with a
|
|
216
238
|
// reason that names the failure count and points to the long-term
|
|
217
|
-
// fix. Does NOT affect the other 3 stages.
|
|
239
|
+
// fix. Does NOT affect the other 3 stages. Only meaningful when
|
|
240
|
+
// the stage actually runs (skipped-tests bypass short-circuits
|
|
241
|
+
// above).
|
|
218
242
|
if (options.allowPreExistingFailures === true &&
|
|
219
243
|
unitTests.status === 'fail') {
|
|
220
244
|
const failureCount = unitTests.data?.failed ?? 0;
|
|
221
245
|
stages.push({
|
|
222
246
|
name: 'unit-tests',
|
|
223
|
-
description:
|
|
247
|
+
description: `npx vitest run ${options.runTests === true ? '' : '--changed '} (overridden via --allow-pre-existing-failures)`.trim(),
|
|
224
248
|
status: 'skipped',
|
|
225
249
|
durationMs: unitTests.durationMs,
|
|
226
250
|
detail: `pre-existing failures: ${failureCount} failing test(s) under coverage.exclude or unrelated to this slice; user opted in via --allow-pre-existing-failures. For the long-term fix, mark these tests .skip or move to coverage.exclude (see dogfood-2-f1-f4.md F17c).`,
|
|
227
251
|
data: { ...(unitTests.data ?? {}), overriddenFrom: 'fail', failureCount }
|
|
228
252
|
});
|
|
253
|
+
unitTestsRunMode = 'overridden';
|
|
229
254
|
}
|
|
230
255
|
else {
|
|
231
256
|
stages.push(unitTests);
|
|
257
|
+
unitTestsRunMode = options.runTests === true ? 'full' : 'changed';
|
|
232
258
|
}
|
|
233
259
|
}
|
|
234
|
-
else {
|
|
235
|
-
stages.push({
|
|
236
|
-
name: 'unit-tests',
|
|
237
|
-
description: 'npx vitest run (skipped per --skip-tests)',
|
|
238
|
-
status: 'skipped',
|
|
239
|
-
durationMs: 0,
|
|
240
|
-
detail: 'Skipped: --skip-tests was set.'
|
|
241
|
-
});
|
|
242
|
-
}
|
|
243
260
|
// Stage 3: 3-way review fanout check
|
|
244
261
|
stages.push(await runReviewFanout(options.projectRoot, rid, options.refreshFanout));
|
|
245
262
|
// Stage 4: gate verify-pipeline
|
|
@@ -260,6 +277,7 @@ export async function sliceCheck(options) {
|
|
|
260
277
|
projectRoot: options.projectRoot,
|
|
261
278
|
rid,
|
|
262
279
|
stages,
|
|
280
|
+
unitTestsRunMode,
|
|
263
281
|
boundaryReady,
|
|
264
282
|
totalDurationMs: Date.now() - totalStart,
|
|
265
283
|
nextActions
|
|
@@ -7,13 +7,23 @@
|
|
|
7
7
|
* off to peaks-qa:
|
|
8
8
|
*
|
|
9
9
|
* 1. typecheck (`npx tsc --noEmit`)
|
|
10
|
-
* 2. unit tests
|
|
10
|
+
* 2. unit tests — by default the **changed-only** suite
|
|
11
|
+
* (`npx vitest run --changed`). Pass `--run-tests` to opt in to the
|
|
12
|
+
* full suite (`npx vitest run`); pass `--skip-tests` to skip
|
|
13
|
+
* entirely (e.g. docs-only or config-only slices).
|
|
11
14
|
* 3. 3-way review fan-out (code-review + security-review + perf-baseline)
|
|
12
15
|
* 4. gate machinery (`peaks workflow verify-pipeline --rid <rid>`)
|
|
13
16
|
*
|
|
14
17
|
* The micro-cycle itself (per-bug TDD) runs OUTSIDE slice check — only
|
|
15
18
|
* single-test runs (`vitest -t "<name>"`) are allowed in micro-cycles.
|
|
16
19
|
* This command is for the BOUNDARY, not the inner loop.
|
|
20
|
+
*
|
|
21
|
+
* The unit-test stage emits a `unitTestsRunMode` field on the result
|
|
22
|
+
* envelope so downstream tooling and the QA test-report can record
|
|
23
|
+
* which mode actually ran: `"changed"` (default), `"full"` (with
|
|
24
|
+
* `--run-tests`), `"skipped"` (with `--skip-tests`), or `"overridden"`
|
|
25
|
+
* (with `--allow-pre-existing-failures` when the run failed and the
|
|
26
|
+
* stage was downgraded to `skipped` with a reason).
|
|
17
27
|
*/
|
|
18
28
|
export type SliceCheckStageStatus = 'pass' | 'fail' | 'skipped';
|
|
19
29
|
export type SliceCheckStage = {
|
|
@@ -36,6 +46,15 @@ export type SliceCheckResult = {
|
|
|
36
46
|
rid: string | null;
|
|
37
47
|
/** All stages in execution order. */
|
|
38
48
|
stages: SliceCheckStage[];
|
|
49
|
+
/**
|
|
50
|
+
* Which unit-test mode actually ran. One of:
|
|
51
|
+
* - `"changed"` — default: `npx vitest run --changed` (tests for git-changed files only)
|
|
52
|
+
* - `"full"` — opt-in via `--run-tests`: `npx vitest run` (full suite)
|
|
53
|
+
* - `"skipped"` — opt-in via `--skip-tests` (stage not executed)
|
|
54
|
+
* - `"overridden"` — full mode + `--allow-pre-existing-failures` and the run failed;
|
|
55
|
+
* stage downgraded to `skipped` with the pre-existing-failure reason
|
|
56
|
+
*/
|
|
57
|
+
unitTestsRunMode: 'changed' | 'full' | 'skipped' | 'overridden';
|
|
39
58
|
/** True iff every stage passed (or was skipped) and the boundary is OK to hand off. */
|
|
40
59
|
boundaryReady: boolean;
|
|
41
60
|
/** Total wall-clock duration in ms. */
|
|
@@ -54,17 +73,32 @@ export type SliceCheckOptions = {
|
|
|
54
73
|
*/
|
|
55
74
|
refreshFanout: boolean;
|
|
56
75
|
/**
|
|
57
|
-
* When true,
|
|
58
|
-
*
|
|
76
|
+
* When true, run the **full** `npx vitest run` suite at the boundary.
|
|
77
|
+
* When false (the default), run the **changed-only** suite
|
|
78
|
+
* (`npx vitest run --changed`) which only exercises tests related to
|
|
79
|
+
* git-changed files. The changed-only mode is the new default as of
|
|
80
|
+
* run 017 — full suite costs 30s+ on this repo; the changed-only
|
|
81
|
+
* mode costs ~1-3s in steady state and is what catches the
|
|
82
|
+
* regressions that actually matter. The service treats `undefined`
|
|
83
|
+
* the same as `false`.
|
|
84
|
+
*/
|
|
85
|
+
runTests?: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* When true, skip the unit-test stage entirely. Useful when a slice
|
|
88
|
+
* has no test surface (e.g. a docs-only or config-only slice), or
|
|
89
|
+
* when the user wants a "typecheck + review + gate" boundary check
|
|
90
|
+
* without any test execution.
|
|
59
91
|
*/
|
|
60
92
|
skipTests: boolean;
|
|
61
93
|
/**
|
|
62
94
|
* When true, an `unit-tests` stage that fails is reported as `skipped`
|
|
63
95
|
* (with a `reason` naming the pre-existing failure count) instead of
|
|
64
96
|
* `failed`. Used to opt in to bypassing the 28 pre-existing Windows
|
|
65
|
-
* test failures documented in dogfood-2-f1-f4.md F17.
|
|
66
|
-
* the
|
|
67
|
-
*
|
|
97
|
+
* test failures documented in dogfood-2-f1-f4.md F17. Only meaningful
|
|
98
|
+
* when the unit-test stage actually runs (i.e. not when `skipTests`
|
|
99
|
+
* is true). Does NOT affect the other 3 stages (typecheck /
|
|
100
|
+
* review-fanout / gate-verify-pipeline). Default: false. The service
|
|
101
|
+
* treats `undefined` the same as `false`.
|
|
68
102
|
*/
|
|
69
103
|
allowPreExistingFailures?: boolean;
|
|
70
104
|
};
|
|
@@ -7,12 +7,22 @@
|
|
|
7
7
|
* off to peaks-qa:
|
|
8
8
|
*
|
|
9
9
|
* 1. typecheck (`npx tsc --noEmit`)
|
|
10
|
-
* 2. unit tests
|
|
10
|
+
* 2. unit tests — by default the **changed-only** suite
|
|
11
|
+
* (`npx vitest run --changed`). Pass `--run-tests` to opt in to the
|
|
12
|
+
* full suite (`npx vitest run`); pass `--skip-tests` to skip
|
|
13
|
+
* entirely (e.g. docs-only or config-only slices).
|
|
11
14
|
* 3. 3-way review fan-out (code-review + security-review + perf-baseline)
|
|
12
15
|
* 4. gate machinery (`peaks workflow verify-pipeline --rid <rid>`)
|
|
13
16
|
*
|
|
14
17
|
* The micro-cycle itself (per-bug TDD) runs OUTSIDE slice check — only
|
|
15
18
|
* single-test runs (`vitest -t "<name>"`) are allowed in micro-cycles.
|
|
16
19
|
* This command is for the BOUNDARY, not the inner loop.
|
|
20
|
+
*
|
|
21
|
+
* The unit-test stage emits a `unitTestsRunMode` field on the result
|
|
22
|
+
* envelope so downstream tooling and the QA test-report can record
|
|
23
|
+
* which mode actually ran: `"changed"` (default), `"full"` (with
|
|
24
|
+
* `--run-tests`), `"skipped"` (with `--skip-tests`), or `"overridden"`
|
|
25
|
+
* (with `--allow-pre-existing-failures` when the run failed and the
|
|
26
|
+
* stage was downgraded to `skipped` with a reason).
|
|
17
27
|
*/
|
|
18
28
|
export {};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const CLI_VERSION = "1.3.
|
|
1
|
+
export declare const CLI_VERSION = "1.3.7";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "1.3.
|
|
1
|
+
export const CLI_VERSION = "1.3.7";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "peaks-cli",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.7",
|
|
4
4
|
"description": "Cross-AI-IDE workflow-gating CLI + skill family (Claude Code shipped, Trae in progress; Codex / Cursor / Qoder / Tongyi Lingma on the roadmap).",
|
|
5
5
|
"author": "SquabbyZ",
|
|
6
6
|
"license": "MIT",
|
package/skills/peaks-qa/SKILL.md
CHANGED
|
@@ -356,7 +356,10 @@ ls .peaks/<changeId>/qa/test-cases/<rid>.md
|
|
|
356
356
|
```bash
|
|
357
357
|
# Run the project's test command. Do NOT skip this. Writing test cases is not enough.
|
|
358
358
|
# Example (adapt to project):
|
|
359
|
-
|
|
359
|
+
# QA validation defaults to the CHANGED-ONLY suite (matches `peaks slice check` default as of run 017).
|
|
360
|
+
# Use the full suite only when the slice is structurally significant or when the user explicitly asks
|
|
361
|
+
# for it (e.g. via /peaks-solo-test or `peaks slice check --run-tests`).
|
|
362
|
+
npx vitest run --changed --reporter=verbose 2>&1 | tail -30
|
|
360
363
|
# Expected: exit code 0, actual test output with pass/fail counts
|
|
361
364
|
# "0 tests executed" or "no test files found" → BLOCKED. Tests were written but not run.
|
|
362
365
|
# Record the raw test output and link it in the test report.
|
|
@@ -504,7 +507,7 @@ QA must generate test cases, not merely inspect existing ones. Every QA invocati
|
|
|
504
507
|
|
|
505
508
|
**Acceptance linkage (MANDATORY)** — every test case MUST have an `**Acceptance:**` field that references one or more acceptance items from the PRD by their position-based IDs (A1 = first bullet, A2 = second, …). The `peaks scan acceptance-coverage --rid <rid> --project <repo>` command parses both the PRD and this file, builds the coverage map, and fails the QA `verdict-issued` gate if any acceptance item has zero linked test cases. Test cases that genuinely have no acceptance owner (e.g. defense-in-depth regressions) should still include `- **Acceptance:** —` and explain in the **Evidence** field; the coverage report flags these as `unlinkedTestCases` for review without auto-blocking.
|
|
506
509
|
|
|
507
|
-
**Test-case execution**: Run the project's test command and record results against each generated test case. If the project uses Jest, run `npx jest --coverage` and link the coverage report. If the project uses Vitest, run `npx vitest run --coverage`. Record the coverage percentage for changed files in the test report.
|
|
510
|
+
**Test-case execution**: Run the project's test command and record results against each generated test case. If the project uses Jest, run `npx jest --coverage` and link the coverage report. If the project uses Vitest, run `npx vitest run --changed --coverage` by default (matches the new `peaks slice check` default as of run 017); use the full suite `npx vitest run --coverage` only when the slice warrants a deeper regression check, or when invoked via /peaks-solo-test or `peaks slice check --run-tests`. Record the coverage percentage for changed files in the test report.
|
|
508
511
|
|
|
509
512
|
## Mandatory test-report output
|
|
510
513
|
|
package/skills/peaks-rd/SKILL.md
CHANGED
|
@@ -266,7 +266,7 @@ peaks codegraph affected --project <repo> <changed-files...> --json
|
|
|
266
266
|
# Without project rules, security review and code review triggers won't fire.
|
|
267
267
|
|
|
268
268
|
# 7. AFTER implementation, BEFORE QA handoff — RUN THESE GATES:
|
|
269
|
-
# Peaks-Cli Gate B2: unit tests exist and pass → npx vitest run (or project equivalent)
|
|
269
|
+
# Peaks-Cli Gate B2: unit tests exist and pass for the changed surface → npx vitest run --changed (or project equivalent; the changed-only mode is the peaks slice check default as of run 017; use --run-tests for the full suite, or invoke /peaks-solo-test to run the full suite standalone)
|
|
270
270
|
# Peaks-Cli Gate B3: code review evidence → .peaks/<changeId>/rd/code-review.md
|
|
271
271
|
# Peaks-Cli Gate B4: security review evidence → .peaks/<changeId>/rd/security-review.md
|
|
272
272
|
# Peaks-Cli Gate B5 (NEW): RD artifact body has no unfilled placeholders.
|
|
@@ -341,13 +341,18 @@ ls .peaks/<changeId>/rd/requests/<rid>.md \
|
|
|
341
341
|
# Both must exist. Missing either → BLOCKED, do not hand off to QA
|
|
342
342
|
```
|
|
343
343
|
|
|
344
|
-
**Peaks-Cli Gate B2 — Before QA handoff: unit tests exist and pass:**
|
|
344
|
+
**Peaks-Cli Gate B2 — Before QA handoff: unit tests exist and pass for the changed surface:**
|
|
345
345
|
```bash
|
|
346
346
|
# Run the project's test command against changed files. Record the output.
|
|
347
347
|
# Example (adapt to project test runner):
|
|
348
|
-
npx vitest run --reporter=verbose 2>&1 | tail -20
|
|
349
|
-
# Expected: exit code 0, all tests passing, coverage for new/changed code recorded
|
|
348
|
+
npx vitest run --changed --reporter=verbose 2>&1 | tail -20
|
|
349
|
+
# Expected: exit code 0, all changed-surface tests passing, coverage for new/changed code recorded
|
|
350
350
|
# Any failing test or zero tests for new code → BLOCKED. Write tests, then re-run.
|
|
351
|
+
#
|
|
352
|
+
# To run the FULL suite (slower; not the default for `peaks slice check`),
|
|
353
|
+
# drop `--changed` or use `npx vitest run --reporter=verbose`. The peaks-solo-test
|
|
354
|
+
# skill is the user-facing wrapper for the full suite; the slice check's
|
|
355
|
+
# `--run-tests` flag is the CLI opt-in.
|
|
351
356
|
```
|
|
352
357
|
|
|
353
358
|
**Peaks-Cli Gate B3 — Before QA handoff: code review evidence exists:**
|
|
@@ -89,12 +89,14 @@ peaks slice check [--rid <rid>] [--project <path>] [--json]
|
|
|
89
89
|
|
|
90
90
|
这个命令编排:
|
|
91
91
|
1. `npx tsc --noEmit`(typecheck)
|
|
92
|
-
2. `npx vitest run
|
|
92
|
+
2. `npx vitest run --changed`(默认;changed-only suite,只跑 git 改动相关的 test,~1-3s)。要全量请加 `--run-tests`;要彻底跳过请加 `--skip-tests`。
|
|
93
93
|
3. 3-way fan-out(code-review + security-review + perf-baseline)
|
|
94
94
|
4. `peaks workflow verify-pipeline --rid <rid> --project <path>`
|
|
95
95
|
|
|
96
96
|
4 个 check 全绿 + verify-pipeline pass → 才进 `peaks request transition --state qa-handoff`,让 peaks-qa 接管。
|
|
97
97
|
|
|
98
|
+
> **新增 run 017(2026-06-09)**:边界默认走 changed-only suite,原来的全 suite 行为移到 `--run-tests` opt-in。`peaks-solo-test` skill 仍然是手动跑全量的入口。rationale: 全量 30s+ 严重拖慢 workflow;changed-only 命中 99% 真正回归。详见 PRD `.peaks/_runtime/2026-06-07-session-84feb7/prd/requests/002-017-2026-06-09-remove-auto-full-vitest-from-slice-check.md`。
|
|
99
|
+
|
|
98
100
|
## Micro-cycle → 边界 check → QA 的串联
|
|
99
101
|
|
|
100
102
|
```
|
|
@@ -139,7 +141,7 @@ verdict=return-to-rd → RD 修 (new slice 内部走 micro-cycle)
|
|
|
139
141
|
## 为什么这套比当前 peaks-solo 的设计合理
|
|
140
142
|
|
|
141
143
|
- **快**:micro-cycle ~100ms(vs 30s 全 suite),改 10 个 bug 从 5 分钟降到 30 秒
|
|
142
|
-
- **稳**:边界 check 不省,4 项检查(tsc + vitest + 3-way + verify-pipeline
|
|
144
|
+
- **稳**:边界 check 不省,4 项检查(tsc + vitest run --changed + 3-way + verify-pipeline)一次全跑;changed-only 模式 1-3s 内出结果,全量用 `--run-tests` opt-in
|
|
143
145
|
- **清晰**:LLM 看到一个 explicit "禁止" 列表 + 强制 sequence,比"建议"更不容易越界
|
|
144
146
|
- **可观测**:micro-cycle 走单测 → 边界跑 verify-pipeline,每步都有 JSON envelope 验证
|
|
145
147
|
|