peaks-cli 1.3.5 → 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.
Files changed (53) hide show
  1. package/dist/src/cli/commands/slice-commands.js +9 -5
  2. package/dist/src/cli/commands/workspace-commands.js +46 -2
  3. package/dist/src/cli/program.js +0 -2
  4. package/dist/src/services/dashboard/project-dashboard-service.d.ts +0 -7
  5. package/dist/src/services/dashboard/project-dashboard-service.js +1 -8
  6. package/dist/src/services/ide/adapters/claude-code-adapter.js +0 -1
  7. package/dist/src/services/ide/adapters/trae-adapter.js +0 -13
  8. package/dist/src/services/ide/ide-types.d.ts +0 -2
  9. package/dist/src/services/session/session-manager.d.ts +55 -0
  10. package/dist/src/services/session/session-manager.js +68 -0
  11. package/dist/src/services/skills/skill-presence-service.d.ts +10 -4
  12. package/dist/src/services/skills/skill-presence-service.js +16 -11
  13. package/dist/src/services/slice/slice-check-service.js +36 -18
  14. package/dist/src/services/slice/slice-check-types.d.ts +40 -6
  15. package/dist/src/services/slice/slice-check-types.js +11 -1
  16. package/dist/src/shared/version.d.ts +1 -1
  17. package/dist/src/shared/version.js +1 -1
  18. package/package.json +1 -1
  19. package/skills/peaks-prd/SKILL.md +16 -16
  20. package/skills/peaks-prd/references/workflow.md +4 -4
  21. package/skills/peaks-qa/SKILL.md +30 -34
  22. package/skills/peaks-qa/references/regression-gates.md +1 -1
  23. package/skills/peaks-rd/SKILL.md +17 -10
  24. package/skills/peaks-rd/references/{openspec-mcp-cli.md → openspec-cli.md} +11 -14
  25. package/skills/peaks-solo/SKILL.md +1 -1
  26. package/skills/peaks-solo/references/a2a-artifact-mapping.md +1 -1
  27. package/skills/peaks-solo/references/browser-workflow.md +49 -38
  28. package/skills/peaks-solo/references/external-skill-invocation.md +9 -7
  29. package/skills/peaks-solo/references/micro-cycle.md +4 -2
  30. package/skills/peaks-solo/references/{openspec-mcp-workflow.md → openspec-workflow.md} +5 -20
  31. package/skills/peaks-solo/references/sub-agent-dispatch.md +16 -35
  32. package/skills/peaks-ui/SKILL.md +22 -24
  33. package/skills/peaks-ui/references/workflow.md +2 -2
  34. package/dist/src/cli/commands/mcp-commands.d.ts +0 -3
  35. package/dist/src/cli/commands/mcp-commands.js +0 -144
  36. package/dist/src/services/mcp/mcp-apply-service.d.ts +0 -31
  37. package/dist/src/services/mcp/mcp-apply-service.js +0 -112
  38. package/dist/src/services/mcp/mcp-call-service.d.ts +0 -17
  39. package/dist/src/services/mcp/mcp-call-service.js +0 -34
  40. package/dist/src/services/mcp/mcp-client-service.d.ts +0 -14
  41. package/dist/src/services/mcp/mcp-client-service.js +0 -49
  42. package/dist/src/services/mcp/mcp-install-registry.d.ts +0 -11
  43. package/dist/src/services/mcp/mcp-install-registry.js +0 -38
  44. package/dist/src/services/mcp/mcp-plan-service.d.ts +0 -29
  45. package/dist/src/services/mcp/mcp-plan-service.js +0 -109
  46. package/dist/src/services/mcp/mcp-protocol.d.ts +0 -24
  47. package/dist/src/services/mcp/mcp-protocol.js +0 -41
  48. package/dist/src/services/mcp/mcp-scan-service.d.ts +0 -8
  49. package/dist/src/services/mcp/mcp-scan-service.js +0 -214
  50. package/dist/src/services/mcp/mcp-stdio-transport.d.ts +0 -10
  51. package/dist/src/services/mcp/mcp-stdio-transport.js +0 -50
  52. package/dist/src/services/mcp/mcp-types.d.ts +0 -31
  53. package/dist/src/services/mcp/mcp-types.js +0 -1
@@ -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,
@@ -7,7 +7,6 @@ import { registerCoreAndArtifactCommands } from './commands/core-artifact-comman
7
7
  import { registerWorkflowCommands } from './commands/workflow-commands.js';
8
8
  import { registerCapabilityWorkerConfigAndSCCommands } from './commands/capability-worker-config-sc-commands.js';
9
9
  import { registerCodegraphCommands } from './commands/codegraph-commands.js';
10
- import { registerMcpCommands } from './commands/mcp-commands.js';
11
10
  import { registerOpenSpecCommands } from './commands/openspec-commands.js';
12
11
  import { registerPerfCommands } from './commands/perf-commands.js';
13
12
  // Slice #014: peaks progress * CLI surface deleted (replaced by sub-agent
@@ -87,7 +86,6 @@ Run peaks (no arguments) for a quickstart. You likely want one of:
87
86
  registerWorkflowCommands(program, io);
88
87
  registerCapabilityWorkerConfigAndSCCommands(program, io);
89
88
  registerCodegraphCommands(program, io);
90
- registerMcpCommands(program, io);
91
89
  registerOpenSpecCommands(program, io);
92
90
  registerPerfCommands(program, io);
93
91
  registerProjectCommands(program, io);
@@ -1,6 +1,5 @@
1
1
  import { type RequestArtifactRole, type RequestArtifactSummary } from '../artifacts/request-artifact-service.js';
2
2
  import type { OpenSpecChangeSummary } from '../openspec/openspec-types.js';
3
- import type { McpScanReport } from '../mcp/mcp-types.js';
4
3
  import type { CapabilityItem } from '../recommendations/recommendation-types.js';
5
4
  import { type SkillPresence } from '../skills/skill-presence-service.js';
6
5
  export type ProjectDashboardRequests = {
@@ -19,10 +18,6 @@ export type ProjectDashboardUnderstand = {
19
18
  graphPath: string;
20
19
  parseError?: string;
21
20
  };
22
- export type ProjectDashboardMcp = {
23
- servers: McpScanReport['servers'];
24
- scopes: McpScanReport['scopes'];
25
- };
26
21
  export type ProjectDashboardDoctor = {
27
22
  ok: boolean;
28
23
  passed: number;
@@ -57,7 +52,6 @@ export type ProjectDashboardRunbookHealth = {
57
52
  };
58
53
  export type ProjectDashboardCapabilities = {
59
54
  count: number;
60
- mcpCount: number;
61
55
  sample: Array<Pick<CapabilityItem, 'capabilityId' | 'name' | 'itemType' | 'category'>>;
62
56
  };
63
57
  export type ProjectDashboardSkillPresence = {
@@ -76,7 +70,6 @@ export type ProjectDashboard = {
76
70
  requests: ProjectDashboardRequests;
77
71
  openspec: ProjectDashboardOpenSpec;
78
72
  understand: ProjectDashboardUnderstand;
79
- mcp: ProjectDashboardMcp;
80
73
  doctor: ProjectDashboardDoctor;
81
74
  runbookHealth: ProjectDashboardRunbookHealth;
82
75
  capabilities: ProjectDashboardCapabilities;
@@ -1,6 +1,5 @@
1
1
  import { listRequestArtifacts } from '../artifacts/request-artifact-service.js';
2
2
  import { scanOpenSpec } from '../openspec/openspec-scan-service.js';
3
- import { scanMcpServers } from '../mcp/mcp-scan-service.js';
4
3
  import { scanUnderstandAnything } from '../understand/understand-scan-service.js';
5
4
  import { seedCapabilityItems } from '../recommendations/capability-seed-items.js';
6
5
  import { requiredSkillNames } from '../../shared/paths.js';
@@ -78,7 +77,6 @@ function buildCapabilitiesSummary(sampleSize) {
78
77
  const items = seedCapabilityItems;
79
78
  return {
80
79
  count: items.length,
81
- mcpCount: items.filter((item) => item.itemType === 'mcp').length,
82
80
  sample: items.slice(0, sampleSize).map((item) => ({
83
81
  capabilityId: item.capabilityId,
84
82
  name: item.name,
@@ -109,10 +107,9 @@ export async function loadProjectDashboard(options) {
109
107
  const clock = options.clock ?? defaultClock;
110
108
  const sampleSize = options.sampleCapabilities ?? 8;
111
109
  const okPolicy = options.okPolicy ?? 'workspace-only';
112
- const [items, openspecReport, mcpReport, understandReport, doctorAndRunbook] = await Promise.all([
110
+ const [items, openspecReport, understandReport, doctorAndRunbook] = await Promise.all([
113
111
  listRequestArtifacts({ projectRoot: options.projectRoot }),
114
112
  scanOpenSpec({ openspecRoot: `${options.projectRoot}/openspec` }),
115
- scanMcpServers({ projectRoot: options.projectRoot }),
116
113
  scanUnderstandAnything({ projectRoot: options.projectRoot }),
117
114
  loadDoctorAndRunbookHealth(options.doctorReport, options.runbookHealth)
118
115
  ]);
@@ -142,10 +139,6 @@ export async function loadProjectDashboard(options) {
142
139
  graphPath: understandReport.graph.path,
143
140
  ...(understandReport.graph.parseError !== undefined ? { parseError: understandReport.graph.parseError } : {})
144
141
  },
145
- mcp: {
146
- servers: mcpReport.servers,
147
- scopes: mcpReport.scopes
148
- },
149
142
  doctor: doctorAndRunbook.doctor,
150
143
  runbookHealth: doctorAndRunbook.runbookHealth,
151
144
  capabilities: buildCapabilitiesSummary(sampleSize),
@@ -46,7 +46,6 @@ export const CLAUDE_CODE_ADAPTER = {
46
46
  capabilities: {
47
47
  gateEnforce: true,
48
48
  statusline: true,
49
- mcpInstall: true,
50
49
  },
51
50
  // Slice #011: standards profile. Claude Code reads its constitution at
52
51
  // CLAUDE.md + module-level rules under .claude/rules/**. The values mirror
@@ -71,19 +71,6 @@ export const TRAE_ADAPTER = {
71
71
  capabilities: {
72
72
  gateEnforce: true,
73
73
  statusline: true,
74
- // Slice #007-007-2026-06-07-mcp-decouple: mcpInstall is LOAD-BEARING.
75
- // The 4 MCP capabilities (playwright, chrome-devtools, figma, context7)
76
- // are installed via `peaks mcp plan/apply` which writes to the global
77
- // `~/.claude/settings.json` file. Trae 1.x's MCP integration is
78
- // UNVERIFIED (the Trae fixture did not dogfood the MCP install path),
79
- // so the 6 SKILL.md files must surface a Trae-specific path (manual
80
- // install + manual tool invocation) rather than promising `peaks mcp
81
- // apply` will work on Trae. Skill bodies consume this flag through
82
- // the IDE adapter's `capabilities.mcpInstall`; setting it to true
83
- // without a real Trae MCP install dogfood would be a regression.
84
- // Cross-reference: .peaks/memory/trae-adapter-sets-mcpinstall-false-trae-mcp-integration-is-unverified.md
85
- // and the 4 per-capability memos under .peaks/memory/mcp-decouple-*.md.
86
- mcpInstall: false
87
74
  }
88
75
  // Standards: UNVERIFIED — see slice #012+ (Trae real-install dogfood for
89
76
  // the `standardsProfile` and `skillInstall` fields). The slice #011
@@ -18,8 +18,6 @@ export interface IdeCapabilities {
18
18
  readonly gateEnforce: true;
19
19
  /** peaks statusline 状态栏是否适用 */
20
20
  readonly statusline: boolean;
21
- /** peaks mcp install 是否适用 */
22
- readonly mcpInstall: boolean;
23
21
  }
24
22
  export interface IdeSettingsLocation {
25
23
  /** 项目根下的 settings 目录名,例如 '.claude' / '.trae' / '.cursor' */
@@ -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
  };