peaks-loop 4.0.42 → 4.0.44

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 (76) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/_register.js +4 -0
  5. package/dist/cli/commands/api-diff-commands.d.ts +16 -0
  6. package/dist/cli/commands/api-diff-commands.js +55 -0
  7. package/dist/cli/commands/audit-commands.d.ts +16 -3
  8. package/dist/cli/commands/audit-commands.js +84 -31
  9. package/dist/cli/commands/codegraph-commands.js +191 -6
  10. package/dist/cli/commands/final-review-commands.d.ts +34 -10
  11. package/dist/cli/commands/final-review-commands.js +130 -34
  12. package/dist/cli/commands/job-commands.js +4 -2
  13. package/dist/cli/commands/scan-commands.js +1 -1
  14. package/dist/cli/commands/share-commands.d.ts +49 -0
  15. package/dist/cli/commands/share-commands.js +114 -14
  16. package/dist/cli/commands/test-commands.d.ts +60 -3
  17. package/dist/cli/commands/test-commands.js +125 -7
  18. package/dist/services/audit/audit-goal-service.js +38 -3
  19. package/dist/services/codegraph/codegraph-autorefresh.js +12 -0
  20. package/dist/services/codegraph/codegraph-exclude-integrity.d.ts +61 -0
  21. package/dist/services/codegraph/codegraph-exclude-integrity.js +98 -0
  22. package/dist/services/codegraph/codegraph-exclude-reconciler.d.ts +26 -0
  23. package/dist/services/codegraph/codegraph-exclude-reconciler.js +217 -0
  24. package/dist/services/codegraph/codegraph-exclude-repair.d.ts +102 -0
  25. package/dist/services/codegraph/codegraph-exclude-repair.js +266 -0
  26. package/dist/services/codegraph/codegraph-preflight-service.js +12 -0
  27. package/dist/services/codegraph/codegraph-service.d.ts +0 -1
  28. package/dist/services/codegraph/codegraph-service.js +5 -4
  29. package/dist/services/doctor/doctor-service/checks/codegraph-exclude-integrity.d.ts +29 -0
  30. package/dist/services/doctor/doctor-service/checks/codegraph-exclude-integrity.js +88 -0
  31. package/dist/services/doctor/doctor-service/checks/ecc-hooks-schema-drift.d.ts +65 -0
  32. package/dist/services/doctor/doctor-service/checks/ecc-hooks-schema-drift.js +186 -0
  33. package/dist/services/doctor/doctor-service/plugin-registry.js +4 -0
  34. package/dist/services/doctor/doctor-service/types.d.ts +47 -0
  35. package/dist/services/final-review/final-review-service.d.ts +154 -0
  36. package/dist/services/final-review/final-review-service.js +621 -7
  37. package/dist/services/final-review/index.d.ts +1 -1
  38. package/dist/services/final-review/index.js +1 -1
  39. package/dist/services/llm/anthropic-runner.d.ts +87 -0
  40. package/dist/services/llm/anthropic-runner.js +171 -0
  41. package/dist/services/llm/stub-runner.d.ts +11 -0
  42. package/dist/services/llm/stub-runner.js +33 -0
  43. package/dist/services/prd/handoff-auto-regen.js +0 -1
  44. package/dist/services/prd/handoff-service.d.ts +9 -1
  45. package/dist/services/prd/handoff-service.js +48 -6
  46. package/dist/services/prd/project-scan-bootstrap-service.js +7 -7
  47. package/dist/services/scan/api-diff-openapi.d.ts +32 -0
  48. package/dist/services/scan/api-diff-openapi.js +359 -0
  49. package/dist/services/scan/api-diff-recorded.d.ts +96 -0
  50. package/dist/services/scan/api-diff-recorded.js +577 -0
  51. package/dist/services/scan/api-diff-service.d.ts +34 -0
  52. package/dist/services/scan/api-diff-service.js +407 -0
  53. package/dist/services/scan/api-diff-types.d.ts +116 -0
  54. package/dist/services/scan/api-diff-types.js +46 -0
  55. package/dist/services/scan/archetype-service.js +27 -1
  56. package/dist/services/scan/existing-system-service.js +17 -4
  57. package/dist/services/scan/hook-convention-service.d.ts +26 -0
  58. package/dist/services/scan/hook-convention-service.js +562 -0
  59. package/dist/services/scan/scan-types.d.ts +47 -0
  60. package/dist/services/session/caller-binding-service.d.ts +28 -0
  61. package/dist/services/session/caller-binding-service.js +10 -2
  62. package/dist/services/session/caller-id-types.d.ts +12 -2
  63. package/dist/services/session/index.d.ts +2 -2
  64. package/dist/services/session/index.js +2 -2
  65. package/dist/services/session/session-binding-bridge.js +11 -6
  66. package/dist/services/session/session-manager.d.ts +33 -1
  67. package/dist/services/session/session-manager.js +84 -25
  68. package/dist/services/skills/skill-presence-service.d.ts +17 -3
  69. package/dist/services/skills/skill-presence-service.js +23 -3
  70. package/package.json +7 -5
  71. package/skills/bee/peaks-rd/SKILL.md +11 -3
  72. package/skills/peaks-code/references/existing-system-extraction.md +5 -1
  73. package/skills/peaks-code/references/frontend-only-mode.md +48 -6
  74. package/skills/peaks-code/references/project-scan-checklist.md +20 -1
  75. package/skills/peaks-doctor/references/doctor-check-catalog.md +1 -0
  76. package/skills/peaks-final-review/SKILL.md +43 -32
@@ -1,14 +1,21 @@
1
1
  /**
2
- * W5 Fix M2 — `peaks prepare-final-review <rid>` CLI wrapper.
2
+ * `peaks prepare-final-review <rid>` CLI wrapper (W5 Fix M2; real provider
3
+ * binding added by the S3 defect-remediation slice).
3
4
  *
4
- * Exposes the `prepareFinalReview()` service (added in W2 T9 on
5
+ * Exposes the `prepareFinalReview()` service (W2 T9 on
5
6
  * `feature/slice-topology-multipass`) via the CLI surface. The service
6
- * depends on an injected `LlmRunner`; this slice wires the CLI route
7
- * with a `stub` provider that returns a structured "scaffold ready"
8
- * envelope so CI can verify the route without a real LLM. A follow-up
9
- * slice will bind a real provider. Until then, non-stub providers fail
10
- * loudly with `LLM_PROVIDER_NOT_IMPLEMENTED` so callers cannot silently
11
- * no-op.
7
+ * depends on an injected `LlmRunner`; this file owns the binding:
8
+ * - `--llm-provider stub` (default) returns a structured "scaffold ready"
9
+ * envelope WITHOUT calling the service, so CI can verify the route
10
+ * offline. It performs no review, and says so in every hint it emits.
11
+ * - `--llm-provider anthropic` binds the real Messages-API runner
12
+ * (`resolveAnthropicConfig()` + `createAnthropicRunner()`) and runs the
13
+ * service for real, carrying the 4-dim result back in the envelope.
14
+ * An absent credential or model raises `LlmBindingError`, reported under
15
+ * its own error code — never degraded into a scaffold a caller could
16
+ * mistake for a review.
17
+ * Unknown provider names still fail loudly with
18
+ * `LLM_PROVIDER_NOT_IMPLEMENTED` rather than silently falling back to stub.
12
19
  *
13
20
  * Per the dev-preference "Default-no on new CLI commands" rule and the
14
21
  * W4 T14 spec, this is a NEW top-level command (`prepare-final-review`),
@@ -19,13 +26,30 @@
19
26
  */
20
27
  import { Command } from 'commander';
21
28
  import { type ProgramIO } from '../cli-helpers.js';
22
- export type FinalReviewStatus = 'scaffold-only' | 'not-applicable';
29
+ import type { FinalReviewOutput } from '../../services/final-review/final-review-types.js';
30
+ export type FinalReviewStatus = 'scaffold-only' | 'review-complete' | 'not-applicable';
31
+ /**
32
+ * Which LLM produced this envelope. `unknown` is reserved for failure
33
+ * envelopes, where no binding was ever established.
34
+ */
35
+ export type FinalReviewProviderBinding = 'stub' | 'anthropic-messages-api' | 'unknown';
23
36
  export interface FinalReviewData {
24
37
  readonly status: FinalReviewStatus;
25
38
  readonly rid: string;
26
39
  readonly sessionId: string;
27
40
  readonly auditGoalPath: string;
28
41
  readonly serviceWired: boolean;
29
- readonly providerBinding: 'pending-follow-up-slice' | 'unknown';
42
+ readonly providerBinding: FinalReviewProviderBinding;
43
+ /** Model id the bound provider answered with (real-provider runs only). */
44
+ readonly model?: string;
45
+ /** The 4-dim review the service produced (real-provider runs only). */
46
+ readonly review?: FinalReviewOutput;
47
+ /**
48
+ * Environment variables that were absent when binding failed. Surfaced on
49
+ * `data` because `fail()` redacts `message` through
50
+ * `redactSensitiveErrorMessage`, whose catch-all pattern matches the words
51
+ * `token` / `api_key` and would blank out the very names an operator needs.
52
+ */
53
+ readonly missingEnv?: readonly string[];
30
54
  }
31
55
  export declare function registerFinalReviewCommands(program: Command, io: ProgramIO): void;
@@ -1,14 +1,21 @@
1
1
  /**
2
- * W5 Fix M2 — `peaks prepare-final-review <rid>` CLI wrapper.
2
+ * `peaks prepare-final-review <rid>` CLI wrapper (W5 Fix M2; real provider
3
+ * binding added by the S3 defect-remediation slice).
3
4
  *
4
- * Exposes the `prepareFinalReview()` service (added in W2 T9 on
5
+ * Exposes the `prepareFinalReview()` service (W2 T9 on
5
6
  * `feature/slice-topology-multipass`) via the CLI surface. The service
6
- * depends on an injected `LlmRunner`; this slice wires the CLI route
7
- * with a `stub` provider that returns a structured "scaffold ready"
8
- * envelope so CI can verify the route without a real LLM. A follow-up
9
- * slice will bind a real provider. Until then, non-stub providers fail
10
- * loudly with `LLM_PROVIDER_NOT_IMPLEMENTED` so callers cannot silently
11
- * no-op.
7
+ * depends on an injected `LlmRunner`; this file owns the binding:
8
+ * - `--llm-provider stub` (default) returns a structured "scaffold ready"
9
+ * envelope WITHOUT calling the service, so CI can verify the route
10
+ * offline. It performs no review, and says so in every hint it emits.
11
+ * - `--llm-provider anthropic` binds the real Messages-API runner
12
+ * (`resolveAnthropicConfig()` + `createAnthropicRunner()`) and runs the
13
+ * service for real, carrying the 4-dim result back in the envelope.
14
+ * An absent credential or model raises `LlmBindingError`, reported under
15
+ * its own error code — never degraded into a scaffold a caller could
16
+ * mistake for a review.
17
+ * Unknown provider names still fail loudly with
18
+ * `LLM_PROVIDER_NOT_IMPLEMENTED` rather than silently falling back to stub.
12
19
  *
13
20
  * Per the dev-preference "Default-no on new CLI commands" rule and the
14
21
  * W4 T14 spec, this is a NEW top-level command (`prepare-final-review`),
@@ -21,8 +28,18 @@ import { existsSync, statSync } from 'node:fs';
21
28
  import { join, resolve } from 'node:path';
22
29
  import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
23
30
  import { fail, ok } from 'peaks-loop-shared/result';
31
+ import { createAnthropicRunner, LlmBindingError, LlmRequestError, resolveAnthropicConfig, } from '../../services/llm/anthropic-runner.js';
32
+ import { IncompleteFinalReviewError, prepareFinalReview, } from '../../services/final-review/final-review-service.js';
24
33
  /** Whitelist of supported `--llm-provider` values for `peaks prepare-final-review`. */
25
- const SUPPORTED_LLM_PROVIDERS = ['stub'];
34
+ const SUPPORTED_LLM_PROVIDERS = ['anthropic', 'stub'];
35
+ /**
36
+ * Default stays `stub` (unlike `peaks audit goal`, whose default is the real
37
+ * provider): the scaffold route is what CI and the registered e2e contract
38
+ * exercise without credentials, and the stub envelope is labelled
39
+ * `status: 'scaffold-only'` / `providerBinding: 'stub'` so it can never be
40
+ * read as a review.
41
+ */
42
+ const DEFAULT_LLM_PROVIDER = 'stub';
26
43
  function isSupportedLlmProvider(value) {
27
44
  return SUPPORTED_LLM_PROVIDERS.includes(value);
28
45
  }
@@ -32,7 +49,7 @@ function isSupportedLlmProvider(value) {
32
49
  * "this is a placeholder on a failure" apart from "this is a real
33
50
  * scaffold-only success".
34
51
  */
35
- function emptyFinalReviewData(rid, sessionId, auditGoalPath) {
52
+ function emptyFinalReviewData(rid, sessionId, auditGoalPath, missingEnv) {
36
53
  return {
37
54
  status: 'not-applicable',
38
55
  rid,
@@ -40,6 +57,7 @@ function emptyFinalReviewData(rid, sessionId, auditGoalPath) {
40
57
  auditGoalPath,
41
58
  serviceWired: false,
42
59
  providerBinding: 'unknown',
60
+ ...(missingEnv === undefined ? {} : { missingEnv }),
43
61
  };
44
62
  }
45
63
  function validateProjectRoot(projectArg) {
@@ -95,7 +113,7 @@ export function registerFinalReviewCommands(program, io) {
95
113
  .description('Prepare the 4-dimension business review (final-review primitive) for human acceptance (W2 T9 service; CLI surface in W5 M2)')
96
114
  .requiredOption('--project <path>', 'target project root')
97
115
  .requiredOption('--session-id <sid>', 'session id whose .peaks/_runtime/<sid>/audit-goal/<rid>.json is the approved goal source')
98
- .option('--llm-provider <name>', 'LLM provider name (default: stub)', 'stub')).action(async (rid, options) => {
116
+ .option('--llm-provider <name>', `LLM provider name: ${SUPPORTED_LLM_PROVIDERS.join(' | ')} (default: ${DEFAULT_LLM_PROVIDER} — performs no review)`, DEFAULT_LLM_PROVIDER)).action(async (rid, options) => {
99
117
  // 1. Project root must exist and be a directory.
100
118
  const projectValidation = validateProjectRoot(options.project);
101
119
  if (!projectValidation.ok) {
@@ -128,34 +146,112 @@ export function registerFinalReviewCommands(program, io) {
128
146
  process.exitCode = 1;
129
147
  return;
130
148
  }
131
- // 5. Provider check: only `stub` is wired in this slice.
132
- const provider = options.llmProvider ?? 'stub';
149
+ // 5. Provider check: unknown names fail loudly — a silent fallback to
150
+ // `stub` would hand the caller a scaffold envelope that reads as a
151
+ // review route, which is the defect class this gate exists to stop.
152
+ const provider = options.llmProvider ?? DEFAULT_LLM_PROVIDER;
133
153
  if (!isSupportedLlmProvider(provider)) {
134
- printResult(io, fail('final-review.prepare', 'LLM_PROVIDER_NOT_IMPLEMENTED', `Provider '${provider}' is not yet wired. The CLI surface is in place; real provider binding is a follow-up slice. Use --llm-provider stub to validate the route without invoking the LLM.`, emptyFinalReviewData(rid, sessionValidation.sessionId, auditGoalPath), [
135
- 'Re-run with `--llm-provider stub` (default) to validate the route.',
136
- 'Real provider binding is tracked as a follow-up slice.',
154
+ printResult(io, fail('final-review.prepare', 'LLM_PROVIDER_NOT_IMPLEMENTED', `LLM provider "${provider}" is not implemented. Supported providers: ${SUPPORTED_LLM_PROVIDERS.join(', ')}.`, emptyFinalReviewData(rid, sessionValidation.sessionId, auditGoalPath), [
155
+ `Re-run with \`--llm-provider anthropic\` for a real 4-dim review, or \`--llm-provider ${DEFAULT_LLM_PROVIDER}\` for an offline scaffold.`,
137
156
  ]), options.json);
138
157
  process.exitCode = 1;
139
158
  return;
140
159
  }
141
160
  // 6. Stub path: surface a structured "scaffold ready" envelope.
142
- // We DO NOT call the service in this slice — the service depends
143
- // on an injected `LlmRunner` interface, and no real provider is
144
- // bound yet. The envelope confirms the route is wired end-to-end
145
- // and reports the audit-goal path the service WOULD read.
146
- const data = {
147
- status: 'scaffold-only',
148
- rid,
149
- sessionId: sessionValidation.sessionId,
150
- auditGoalPath,
151
- serviceWired: true,
152
- providerBinding: 'pending-follow-up-slice',
153
- };
154
- const envelope = ok('final-review.prepare', data, [], [
155
- 'prepareFinalReview() service is wired and reachable. The stub provider returns a scaffold envelope so CI can verify the route without a real LLM.',
156
- `Audit-goal file is present at: ${auditGoalPath}`,
157
- 'A follow-up slice will bind a real LLM provider; until then, non-stub providers fail loudly with `LLM_PROVIDER_NOT_IMPLEMENTED`.',
158
- ]);
159
- printResult(io, envelope, options.json);
161
+ // We DO NOT call the service here — the stub runner answers the
162
+ // audit-goal shape, not the 4-dim review shape, so running it through
163
+ // `prepareFinalReview()` would only manufacture a malformed review.
164
+ // The envelope confirms the route is wired end-to-end and reports the
165
+ // audit-goal path the service WOULD read.
166
+ if (provider === 'stub') {
167
+ const data = {
168
+ status: 'scaffold-only',
169
+ rid,
170
+ sessionId: sessionValidation.sessionId,
171
+ auditGoalPath,
172
+ serviceWired: true,
173
+ providerBinding: 'stub',
174
+ };
175
+ const envelope = ok('final-review.prepare', data, [], [
176
+ 'Stub provider: no 4-dim review was performed. This envelope only proves the route is wired and reachable.',
177
+ `Audit-goal file is present at: ${auditGoalPath}`,
178
+ 'Re-run with `--llm-provider anthropic` to produce a real review.',
179
+ ]);
180
+ printResult(io, envelope, options.json);
181
+ return;
182
+ }
183
+ // 7. Real provider path: bind a real `LlmRunner` and run the service.
184
+ // Binding happens INSIDE the try so an absent credential surfaces as
185
+ // `LlmBindingError`'s own code instead of escaping as a crash. No
186
+ // envelope is emitted on that path — a "successful" empty review would
187
+ // be worse than an error.
188
+ try {
189
+ const config = resolveAnthropicConfig();
190
+ const llmRunner = createAnthropicRunner(config);
191
+ const review = await prepareFinalReview(rid, {
192
+ projectRoot: projectValidation.projectRoot,
193
+ sessionId: sessionValidation.sessionId,
194
+ llmRunner,
195
+ });
196
+ const data = {
197
+ status: 'review-complete',
198
+ rid,
199
+ sessionId: sessionValidation.sessionId,
200
+ auditGoalPath,
201
+ serviceWired: true,
202
+ providerBinding: 'anthropic-messages-api',
203
+ model: config.model,
204
+ review,
205
+ };
206
+ const envelope = ok('final-review.prepare', data, review.allPass ? [] : [
207
+ `Dimensions needing human attention: ${review.needsAttention.join(', ') || 'none flagged'}.`,
208
+ ], [
209
+ `4-dim review produced by anthropic-messages-api (model: ${config.model}).`,
210
+ `allPass: ${String(review.allPass)}.`,
211
+ ]);
212
+ printResult(io, envelope, options.json);
213
+ }
214
+ catch (error) {
215
+ const code = finalReviewErrorCode(error);
216
+ printResult(io, fail('final-review.prepare', code, getErrorMessage(error), emptyFinalReviewData(rid, sessionValidation.sessionId, auditGoalPath, error instanceof LlmBindingError ? error.missingEnv : undefined), finalReviewNextActions(code)), options.json);
217
+ process.exitCode = 1;
218
+ }
160
219
  });
161
220
  }
221
+ /**
222
+ * Map a thrown error to the CLI's error code. The LLM-layer errors already
223
+ * carry codes precise enough to act on (`LLM_CREDENTIAL_MISSING`,
224
+ * `LLM_REQUEST_FAILED`, …), so they are passed through rather than flattened
225
+ * into one opaque failure.
226
+ */
227
+ function finalReviewErrorCode(error) {
228
+ if (error instanceof LlmBindingError ||
229
+ error instanceof LlmRequestError ||
230
+ error instanceof IncompleteFinalReviewError) {
231
+ return error.code;
232
+ }
233
+ return 'FINAL_REVIEW_FAILED';
234
+ }
235
+ function finalReviewNextActions(code) {
236
+ switch (code) {
237
+ case 'LLM_CREDENTIAL_MISSING':
238
+ return [
239
+ 'Export ANTHROPIC_AUTH_TOKEN (or ANTHROPIC_API_KEY) in the environment that launches peaks, then re-run.',
240
+ 'For an offline scaffold instead of a review, re-run with `--llm-provider stub` — it performs NO review.',
241
+ ];
242
+ case 'LLM_MODEL_MISSING':
243
+ return [
244
+ 'Export ANTHROPIC_MODEL (or CLAUDE_CODE_SUBAGENT_MODEL) in the environment that launches peaks, then re-run.',
245
+ ];
246
+ case 'LLM_REQUEST_FAILED':
247
+ return [
248
+ 'Check ANTHROPIC_BASE_URL and network reachability, then re-run — a transport failure produces no review.',
249
+ ];
250
+ case 'INCOMPLETE_FINAL_REVIEW':
251
+ return [
252
+ 'The LLM reply was not valid JSON or omitted a required dimension; re-run so the gate is never read as complete.',
253
+ ];
254
+ default:
255
+ return ['Re-run with `--llm-provider stub` to validate the CLI route without a real LLM.'];
256
+ }
257
+ }
@@ -56,7 +56,8 @@ function findSessionHoldingJob(project, jobId) {
56
56
  * `.peaks/_runtime/session.json` binding points at another session):
57
57
  * 1. `--session-id` flag (explicit override)
58
58
  * 2. `PEAKS_SESSION_ID` env var
59
- * 3. `getCurrentSessionId(project)` — reads `.peaks/_runtime/session.json`
59
+ * 3. `getCurrentSessionId(project)` — this caller's session binding, falling back
60
+ * to `.peaks/_runtime/session.json` when no caller binding is resolvable
60
61
  * 4. Error (NO_ACTIVE_SESSION) — must never silently fall back to a random uuid
61
62
  *
62
63
  * When `jobId` is passed and it is absent from the resolved session, the thrown
@@ -110,7 +111,8 @@ export function registerJobCommands(program, io = { stdout: (t) => process.stdou
110
111
  .option('--project <repo>')
111
112
  .action(async (opts) => {
112
113
  const project = projectRoot(opts);
113
- // Resolve sessionId: explicit flag > PEAKS_SESSION_ID > canonical session binding > FAIL.
114
+ // Resolve sessionId: explicit flag > PEAKS_SESSION_ID > caller-first session binding
115
+ // (this caller's binding, else the project-global session.json) > FAIL.
114
116
  // Per spec §3.3, Job state lives at .peaks/_runtime/<sessionId>/job/<jobId>/state.json —
115
117
  // a random UUID would scatter state across dirs and break resume/auto-compact.
116
118
  let sessionId = opts.sessionId ?? process.env.PEAKS_SESSION_ID ?? getCurrentSessionId(project);
@@ -30,7 +30,7 @@ export function registerScanCommands(program, io) {
30
30
  const scan = program.command('scan').description('Deterministic project scans (archetype, existing system) for Peaks workflows');
31
31
  addJsonOption(scan
32
32
  .command('archetype')
33
- .description('Detect project archetype, frontend-only mode, and supporting signals from the filesystem (read-only)')
33
+ .description('Detect project archetype, integration mode (three scenarios), frontend-only mode, and supporting signals from the filesystem (read-only)')
34
34
  .requiredOption('--project <path>', 'target project root')).action(async (options) => {
35
35
  try {
36
36
  const report = await scanArchetype({ projectRoot: options.project });
@@ -12,6 +12,55 @@
12
12
  */
13
13
  import type { Command } from 'commander';
14
14
  import { type ProgramIO } from '../cli-helpers.js';
15
+ /**
16
+ * A `dispatch-*.json` candidate for `finalize --request-id`, as read off disk.
17
+ * The record's own `createdAt` is carried as the recency key — NOT the file's
18
+ * mtime, which any heartbeat or finalize rewrites, so a `done` record can look
19
+ * newer than the `queued` one that superseded it. Keeping the key on the
20
+ * candidate makes the selection rule a pure function of the records and
21
+ * testable without a filesystem.
22
+ */
23
+ export interface FinalizeCandidate {
24
+ readonly recordPath: string;
25
+ readonly requestId: string;
26
+ readonly status: string;
27
+ readonly createdAt: string;
28
+ }
29
+ /**
30
+ * What `--request-id` actually did, reported in the envelope. N3: the branch
31
+ * used to resolve silently and could not say WHY a record was or was not the
32
+ * one finalized.
33
+ */
34
+ export interface FinalizeSelection {
35
+ readonly requestId: string;
36
+ readonly rule: string;
37
+ readonly matched: number;
38
+ readonly chosen: string | null;
39
+ readonly rejected: readonly {
40
+ readonly recordPath: string;
41
+ readonly status: string;
42
+ readonly reason: string;
43
+ }[];
44
+ }
45
+ /** The one selection rule `--request-id` and `--batch` now share. */
46
+ export declare const FINALIZE_SELECTION_RULE: string;
47
+ /**
48
+ * N3 — pick the record `finalize --request-id` should act on.
49
+ *
50
+ * The branch this replaces `break`ed on the FIRST file whose record carried
51
+ * the requestId and never looked at `status`. With a re-dispatched request
52
+ * (this session holds six records for `2026-09-12-defect-remediation`) it
53
+ * always resolved to the OLDEST one — the already-`done` RD record — so
54
+ * finalizing reported success while the newer `queued` QA record stayed
55
+ * queued forever. `--batch` never had that bug: it filters on `queued`.
56
+ *
57
+ * Both branches are now the same rule. Return `null` when nothing is queued:
58
+ * a record that already left `queued` is precisely the one that must NOT be
59
+ * re-finalized, so the caller reports the survivors instead of touching one.
60
+ */
61
+ export declare function selectFinalizeTarget(candidates: readonly FinalizeCandidate[]): FinalizeCandidate | null;
62
+ /** Why a candidate was not the chosen one — reported, never guessed at. */
63
+ export declare function describeFinalizeRejection(candidate: FinalizeCandidate, chosen: FinalizeCandidate | null): string;
15
64
  export declare function registerShareCommand(parent: Command, io: ProgramIO): void;
16
65
  export declare function registerSharedReadCommand(parent: Command, io: ProgramIO): void;
17
66
  export declare function registerAwaitCommand(parent: Command, io: ProgramIO): void;
@@ -5,6 +5,36 @@ import { readSharedChannel, writeSharedEntry, SHARED_CHANNEL_SOFT_VALUE_WARN } f
5
5
  import { writeLogEntry } from '../../services/log/logger.js';
6
6
  import { getCurrentSessionId } from '../../services/skills/skill-presence-service.js';
7
7
  import { summarizeBatchResults } from './sub-agent-shared.js';
8
+ /** The one selection rule `--request-id` and `--batch` now share. */
9
+ export const FINALIZE_SELECTION_RULE = 'prefer status=queued; among those, newest by record createdAt (then filename); ' +
10
+ 'no queued match means nothing is finalized';
11
+ /**
12
+ * N3 — pick the record `finalize --request-id` should act on.
13
+ *
14
+ * The branch this replaces `break`ed on the FIRST file whose record carried
15
+ * the requestId and never looked at `status`. With a re-dispatched request
16
+ * (this session holds six records for `2026-09-12-defect-remediation`) it
17
+ * always resolved to the OLDEST one — the already-`done` RD record — so
18
+ * finalizing reported success while the newer `queued` QA record stayed
19
+ * queued forever. `--batch` never had that bug: it filters on `queued`.
20
+ *
21
+ * Both branches are now the same rule. Return `null` when nothing is queued:
22
+ * a record that already left `queued` is precisely the one that must NOT be
23
+ * re-finalized, so the caller reports the survivors instead of touching one.
24
+ */
25
+ export function selectFinalizeTarget(candidates) {
26
+ const queued = candidates.filter(candidate => candidate.status === 'queued');
27
+ if (queued.length === 0)
28
+ return null;
29
+ return [...queued].sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.recordPath.localeCompare(a.recordPath))[0];
30
+ }
31
+ /** Why a candidate was not the chosen one — reported, never guessed at. */
32
+ export function describeFinalizeRejection(candidate, chosen) {
33
+ if (candidate.status !== 'queued' || chosen === null) {
34
+ return 'status is ' + candidate.status;
35
+ }
36
+ return 'superseded by the newer queued record ' + chosen.recordPath;
37
+ }
8
38
  export function registerShareCommand(parent, io) {
9
39
  addJsonOption(parent
10
40
  .command('share')
@@ -329,6 +359,31 @@ export function registerFinalizeCommand(parent, io) {
329
359
  const finalized = [];
330
360
  const skipped = [];
331
361
  const errors = [];
362
+ let selection = null;
363
+ /**
364
+ * N2 — one unreadable record must not abort the sweep.
365
+ *
366
+ * Skipping `active-dispatches.json` and `batch-*.counter.json` by
367
+ * FILENAME removed the two non-record files that happened to be in the
368
+ * directory, but a single stale or foreign `dispatch-*.json` (an old
369
+ * `version`, hand-edited JSON, a truncated write) still made
370
+ * `readRecord` throw from OUTSIDE any try/catch — and the throw
371
+ * escaped to the action's outer handler, so `--request-id` AND
372
+ * `--batch` both died with `FINALIZE_ERROR` and exit 1 without
373
+ * touching a single healthy record.
374
+ *
375
+ * A record that cannot be read is now reported in `errors[]` and
376
+ * skipped; every other record is processed as before.
377
+ */
378
+ const tryReadRecord = (recordPath) => {
379
+ try {
380
+ return readRecord(recordPath);
381
+ }
382
+ catch (e) {
383
+ errors.push({ recordPath, error: getErrorMessage(e) });
384
+ return null;
385
+ }
386
+ };
332
387
  const applyOutcome = (recordPath, rid) => {
333
388
  markCompleted({ recordPath, now: () => new Date(), status: mapped.status, outcome: mapped.outcome, projectRoot });
334
389
  finalized.push({ recordPath, requestId: rid, status: mapped.status });
@@ -352,29 +407,59 @@ export function registerFinalizeCommand(parent, io) {
352
407
  const fs2 = await import('node:fs');
353
408
  const path2 = await import('node:path');
354
409
  const dir = path2.resolve(projectRoot, '.peaks', '_sub_agents', sessionId);
355
- let resolvedPath = null;
410
+ const candidates = [];
356
411
  if (fs2.existsSync(dir)) {
357
412
  for (const f of fs2.readdirSync(dir)) {
358
- if (!f.endsWith('.json'))
413
+ // Only dispatch records are readable records. The session
414
+ // directory also holds `active-dispatches.json` (an index)
415
+ // and `batch-<uuid>.counter.json` (batch counters); neither
416
+ // carries a `version` field, so `readRecord` on them throws
417
+ // `Dispatch record version mismatch ... got undefined`. The
418
+ // `--batch` branch below has always used this same filter.
419
+ if (!f.startsWith('dispatch-') || !f.endsWith('.json'))
359
420
  continue;
360
421
  const p = path2.join(dir, f);
361
- const r = readRecord(p);
362
- if (r.requestId === options.requestId) {
363
- resolvedPath = p;
364
- break;
365
- }
422
+ const r = tryReadRecord(p);
423
+ if (r === null || r.requestId !== options.requestId)
424
+ continue;
425
+ candidates.push({
426
+ recordPath: p,
427
+ requestId: r.requestId,
428
+ status: r.status,
429
+ createdAt: r.createdAt
430
+ });
366
431
  }
367
432
  }
368
- if (!resolvedPath) {
433
+ if (candidates.length === 0) {
369
434
  printResult(io, fail('sub-agent.finalize', 'RECORD_NOT_FOUND', 'No dispatch record for requestId=' + options.requestId, { ok: false }, ['Check --request-id matches the dispatch envelope.']), asJson);
370
435
  process.exitCode = 1;
371
436
  return;
372
437
  }
373
- try {
374
- applyOutcome(resolvedPath, options.requestId);
438
+ // N3: same rule as `--batch` — see `selectFinalizeTarget`.
439
+ const chosen = selectFinalizeTarget(candidates);
440
+ selection = {
441
+ requestId: options.requestId,
442
+ rule: FINALIZE_SELECTION_RULE,
443
+ matched: candidates.length,
444
+ chosen: chosen?.recordPath ?? null,
445
+ rejected: candidates
446
+ .filter(candidate => candidate !== chosen)
447
+ .map(candidate => ({
448
+ recordPath: candidate.recordPath,
449
+ status: candidate.status,
450
+ reason: describeFinalizeRejection(candidate, chosen)
451
+ }))
452
+ };
453
+ for (const rejected of selection.rejected) {
454
+ skipped.push({ recordPath: rejected.recordPath, reason: rejected.reason });
375
455
  }
376
- catch (e) {
377
- errors.push({ recordPath: resolvedPath, error: getErrorMessage(e) });
456
+ if (chosen !== null) {
457
+ try {
458
+ applyOutcome(chosen.recordPath, chosen.requestId);
459
+ }
460
+ catch (e) {
461
+ errors.push({ recordPath: chosen.recordPath, error: getErrorMessage(e) });
462
+ }
378
463
  }
379
464
  }
380
465
  else {
@@ -386,7 +471,9 @@ export function registerFinalizeCommand(parent, io) {
386
471
  if (!f.startsWith('dispatch-') || !f.endsWith('.json'))
387
472
  continue;
388
473
  const p = path2.join(dir, f);
389
- const r = readRecord(p);
474
+ const r = tryReadRecord(p);
475
+ if (r === null)
476
+ continue;
390
477
  if (r.batchId !== options.batch)
391
478
  continue;
392
479
  if (r.status !== 'queued') {
@@ -402,7 +489,20 @@ export function registerFinalizeCommand(parent, io) {
402
489
  }
403
490
  }
404
491
  }
405
- printResult(io, ok('sub-agent.finalize', { finalized, skipped, errors, sessionId, outcome }, errors.length > 0 ? [errors.length + ' failed'] : [], errors.length > 0 ? ['Re-run after fixing.'] : ['All targeted records transitioned out of queued.']), asJson);
492
+ const hints = [];
493
+ if (errors.length > 0) {
494
+ hints.push('Re-run after fixing; unreadable records are listed in errors[] and were skipped, not fatal.');
495
+ }
496
+ else if (finalized.length === 0 && skipped.length > 0) {
497
+ hints.push('Nothing was finalized: every matching record had already left `queued`. See skipped[] for each record\'s status.');
498
+ }
499
+ else {
500
+ hints.push('All targeted records transitioned out of queued.');
501
+ }
502
+ if (selection !== null) {
503
+ hints.push(`--request-id selection (${selection.rule}): chose ${selection.chosen ?? '(none)'} of ${selection.matched} matching record(s); ${selection.rejected.length} rejected.`);
504
+ }
505
+ printResult(io, ok('sub-agent.finalize', { finalized, skipped, errors, selection, sessionId, outcome }, errors.length > 0 ? [errors.length + ' failed'] : [], hints), asJson);
406
506
  if (errors.length > 0)
407
507
  process.exitCode = 1;
408
508
  }
@@ -6,14 +6,18 @@
6
6
  *
7
7
  * 1. Auto-detects the framework from package.json (devDependencies +
8
8
  * dependencies) via detectTestFramework().
9
- * 2. Spawns the framework's CLI with --cache enabled (overriding any
9
+ * 2. Resolves the project-LOCAL runner binary (node_modules) and spawns
10
+ * that, so the command works where the runner is not on PATH — notably
11
+ * Windows, where node_modules/.bin/vitest.cmd is not spawnable without
12
+ * a shell. PATH is a last resort and is reported, not silent.
13
+ * 3. Spawns the framework's CLI with --cache enabled (overriding any
10
14
  * --no-cache in the consumer's `test` script). The user can
11
15
  * opt back into no-cache via `peaks test --no-cache` or
12
16
  * `peaks test --passthrough`.
13
- * 3. Skips tests where (fileMtime, fileSha256) is unchanged AND the
17
+ * 4. Skips tests where (fileMtime, fileSha256) is unchanged AND the
14
18
  * previous run status was 'passed' (per-test fingerprint cache at
15
19
  * `<projectRoot>/.peaks/_runtime/test-cache/<hash>.json`).
16
- * 4. Exits 0 on all-pass / all-skip; exits 1 on any failure.
20
+ * 5. Exits 0 on all-pass / all-skip; exits 1 on any failure.
17
21
  *
18
22
  * The CLI is invoked by USER (not just by skill) per slice 2.5.0
19
23
  * sub-fix B (G16) — a documented exception to the
@@ -29,6 +33,7 @@
29
33
  * peaks test --passthrough — do NOT override the consumer's argv
30
34
  * peaks test --framework <name> — force a specific framework
31
35
  */
36
+ import { spawn } from 'node:child_process';
32
37
  import type { Command } from 'commander';
33
38
  import { type ProgramIO } from '../cli-helpers.js';
34
39
  import { type TestFramework } from '../../services/test-cache/test-cache-service.js';
@@ -44,4 +49,56 @@ export declare function buildRunnerArgv(framework: TestFramework, patterns: stri
44
49
  cache?: boolean;
45
50
  passthrough?: boolean;
46
51
  }): string[];
52
+ /** Successful resolution — everything `spawn` needs, plus provenance. */
53
+ export type RunnerFound = {
54
+ ok: true;
55
+ /** Executable to spawn: node itself, a local shim, or a PATH hit. */
56
+ command: string;
57
+ /** argv for `command` (includes the JS entry when spawning node). */
58
+ args: string[];
59
+ /** How the runner was found — named in the PATH-fallback notice. */
60
+ via: string;
61
+ /** True only for the PATH fallback, which the caller surfaces visibly. */
62
+ fromPath: boolean;
63
+ };
64
+ export type RunnerResolution = RunnerFound | {
65
+ ok: false;
66
+ searched: string[];
67
+ };
68
+ /** Injection seams for tests — production passes nothing. */
69
+ export type ResolveRunnerDeps = {
70
+ platform?: NodeJS.Platform;
71
+ existsSync?: (path: string) => boolean;
72
+ readFileSync?: (path: string) => string;
73
+ env?: NodeJS.ProcessEnv;
74
+ /** Node executable used to run the runner's JS entry. */
75
+ nodeExecPath?: string;
76
+ };
77
+ export type RunRunnerDeps = ResolveRunnerDeps & {
78
+ spawnFn?: typeof spawn;
79
+ };
80
+ /**
81
+ * Resolve the consumer project's LOCAL runner, and the form of it that
82
+ * `spawn` can actually launch on this platform.
83
+ *
84
+ * Probed, in order (every probe is reported when nothing is found):
85
+ * 1. `<root>/node_modules/.bin/<runner>` (+ `.cmd`/`.exe` on Windows) —
86
+ * the project-local runner the command documents.
87
+ * 2. `<root>/node_modules/<runner>/package.json` → its `bin` JS entry.
88
+ * 3. PATH — last resort only; the caller prints a visible notice.
89
+ *
90
+ * Between 1 and 2 the **JS entry** wins: `spawn` runs it as
91
+ * `node <entry> …`, which is identical on Windows and POSIX and never
92
+ * routes argv through a shell. The `.cmd` shim is only a fallback because
93
+ * it needs cmd.exe to launch it (see `toSpawnable`).
94
+ */
95
+ export declare function resolveRunner(framework: TestFramework, argv: string[], projectRoot: string, deps?: ResolveRunnerDeps): RunnerResolution;
96
+ /** Actionable message for the no-runner case — never a raw ENOENT. */
97
+ export declare function formatRunnerNotFound(framework: TestFramework, searched: string[]): string;
98
+ export declare function runRunner(framework: TestFramework, argv: string[], projectRoot: string, deps?: RunRunnerDeps): Promise<{
99
+ code: number;
100
+ stdout: string;
101
+ stderr: string;
102
+ notice: string | null;
103
+ }>;
47
104
  export declare function registerTestCommands(program: Command, _io: ProgramIO): void;