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.
@@ -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 → review-fanout ' +
14
- 'gate-verify-pipeline. Each stage reports pass / fail / skipped. ' +
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('--skip-tests', 'skip the unit-test stage (e.g. docs-only slices)', false)
20
- .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)', false)).action(async (options) => {
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 { ensureSession } from '../../services/session/session-manager.js';
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
- sessionId = await ensureSession(projectRoot);
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
- * The field is informational only — `setSkillPresence` does not
26
- * roll a new session on its own. peaks-solo's Step 0 reads the
27
- * field off the presence file and turns it into an
28
- * AskUserQuestion: "Start a new peaks session / Keep this one".
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. We do NOT auto-
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
- * Reads from `.peaks/_runtime/active-skill.json` first; falls back to
138
- * the legacy `.peaks/.active-skill.json` for one minor release.
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
- const result = runCommand('npx', ['vitest', 'run', '--reporter=default', '--coverage=false'], projectRoot, 600_000);
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: 'npx vitest run (full test suite, coverage off)',
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 vitest
212
- if (!options.skipTests) {
213
- const unitTests = await runUnitTests(options.projectRoot);
214
- // Opt-in override: if --allow-pre-existing-failures is set AND the
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: 'npx vitest run (overridden via --allow-pre-existing-failures)',
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 (`npx vitest run`)
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, skip the unit-test stage. Useful when a slice has no unit
58
- * tests (e.g. a docs-only or config-only slice).
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. Does NOT affect
66
- * the other 3 stages (typecheck / review-fanout / gate-verify-pipeline).
67
- * Default: false. The service treats `undefined` the same as `false`.
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 (`npx vitest run`)
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.6";
1
+ export declare const CLI_VERSION = "1.3.7";
@@ -1 +1 @@
1
- export const CLI_VERSION = "1.3.6";
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.6",
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",
@@ -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
- npx vitest run --reporter=verbose 2>&1 | tail -30
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
 
@@ -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`(全 suite
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