peaks-loop 4.0.43 → 4.0.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -0
- package/README-en.md +1 -1
- package/README.md +1 -1
- package/dist/cli/commands/codegraph-commands.d.ts +1 -0
- package/dist/cli/commands/codegraph-commands.js +239 -8
- package/dist/cli/commands/final-review-commands.d.ts +34 -10
- package/dist/cli/commands/final-review-commands.js +132 -34
- package/dist/cli/commands/share-commands.d.ts +49 -0
- package/dist/cli/commands/share-commands.js +114 -14
- package/dist/services/codegraph/codegraph-autorefresh.js +12 -0
- package/dist/services/codegraph/codegraph-exclude-integrity.d.ts +61 -0
- package/dist/services/codegraph/codegraph-exclude-integrity.js +98 -0
- package/dist/services/codegraph/codegraph-exclude-reconciler.d.ts +26 -0
- package/dist/services/codegraph/codegraph-exclude-reconciler.js +217 -0
- package/dist/services/codegraph/codegraph-exclude-repair.d.ts +102 -0
- package/dist/services/codegraph/codegraph-exclude-repair.js +266 -0
- package/dist/services/codegraph/codegraph-preflight-service.js +12 -0
- package/dist/services/codegraph/codegraph-service.d.ts +0 -1
- package/dist/services/codegraph/codegraph-service.js +5 -4
- package/dist/services/doctor/doctor-service/checks/codegraph-exclude-integrity.d.ts +29 -0
- package/dist/services/doctor/doctor-service/checks/codegraph-exclude-integrity.js +88 -0
- package/dist/services/doctor/doctor-service/plugin-registry.js +2 -0
- package/dist/services/doctor/doctor-service/types.d.ts +27 -0
- package/dist/services/final-review/final-review-service.d.ts +335 -1
- package/dist/services/final-review/final-review-service.js +1457 -6
- package/dist/services/final-review/index.d.ts +2 -1
- package/dist/services/final-review/index.js +2 -1
- package/dist/services/final-review/pre-post-diff.d.ts +137 -0
- package/dist/services/final-review/pre-post-diff.js +657 -0
- package/dist/services/prd/handoff-auto-regen.js +0 -1
- package/dist/services/prd/handoff-service.d.ts +9 -1
- package/dist/services/prd/handoff-service.js +48 -6
- package/package.json +7 -5
- package/skills/peaks-final-review/SKILL.md +79 -35
- package/skills/peaks-final-review/references/4-dimensions.md +42 -5
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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 (
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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,8 @@ 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>',
|
|
116
|
+
.option('--llm-provider <name>', `LLM provider name: ${SUPPORTED_LLM_PROVIDERS.join(' | ')} (default: ${DEFAULT_LLM_PROVIDER} — performs no review)`, DEFAULT_LLM_PROVIDER)
|
|
117
|
+
.option('--base <ref>', 'base ref for the pre/post baseline diff (`existing-functionality-intact`); default: merge-base with origin/HEAD, then origin/main, then origin/master, then HEAD~1 — pass this explicitly when none of those resolve')).action(async (rid, options) => {
|
|
99
118
|
// 1. Project root must exist and be a directory.
|
|
100
119
|
const projectValidation = validateProjectRoot(options.project);
|
|
101
120
|
if (!projectValidation.ok) {
|
|
@@ -128,34 +147,113 @@ export function registerFinalReviewCommands(program, io) {
|
|
|
128
147
|
process.exitCode = 1;
|
|
129
148
|
return;
|
|
130
149
|
}
|
|
131
|
-
// 5. Provider check:
|
|
132
|
-
|
|
150
|
+
// 5. Provider check: unknown names fail loudly — a silent fallback to
|
|
151
|
+
// `stub` would hand the caller a scaffold envelope that reads as a
|
|
152
|
+
// review route, which is the defect class this gate exists to stop.
|
|
153
|
+
const provider = options.llmProvider ?? DEFAULT_LLM_PROVIDER;
|
|
133
154
|
if (!isSupportedLlmProvider(provider)) {
|
|
134
|
-
printResult(io, fail('final-review.prepare', 'LLM_PROVIDER_NOT_IMPLEMENTED', `
|
|
135
|
-
|
|
136
|
-
'Real provider binding is tracked as a follow-up slice.',
|
|
155
|
+
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), [
|
|
156
|
+
`Re-run with \`--llm-provider anthropic\` for a real 4-dim review, or \`--llm-provider ${DEFAULT_LLM_PROVIDER}\` for an offline scaffold.`,
|
|
137
157
|
]), options.json);
|
|
138
158
|
process.exitCode = 1;
|
|
139
159
|
return;
|
|
140
160
|
}
|
|
141
161
|
// 6. Stub path: surface a structured "scaffold ready" envelope.
|
|
142
|
-
// We DO NOT call the service
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
162
|
+
// We DO NOT call the service here — the stub runner answers the
|
|
163
|
+
// audit-goal shape, not the 4-dim review shape, so running it through
|
|
164
|
+
// `prepareFinalReview()` would only manufacture a malformed review.
|
|
165
|
+
// The envelope confirms the route is wired end-to-end and reports the
|
|
166
|
+
// audit-goal path the service WOULD read.
|
|
167
|
+
if (provider === 'stub') {
|
|
168
|
+
const data = {
|
|
169
|
+
status: 'scaffold-only',
|
|
170
|
+
rid,
|
|
171
|
+
sessionId: sessionValidation.sessionId,
|
|
172
|
+
auditGoalPath,
|
|
173
|
+
serviceWired: true,
|
|
174
|
+
providerBinding: 'stub',
|
|
175
|
+
};
|
|
176
|
+
const envelope = ok('final-review.prepare', data, [], [
|
|
177
|
+
'Stub provider: no 4-dim review was performed. This envelope only proves the route is wired and reachable.',
|
|
178
|
+
`Audit-goal file is present at: ${auditGoalPath}`,
|
|
179
|
+
'Re-run with `--llm-provider anthropic` to produce a real review.',
|
|
180
|
+
]);
|
|
181
|
+
printResult(io, envelope, options.json);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
// 7. Real provider path: bind a real `LlmRunner` and run the service.
|
|
185
|
+
// Binding happens INSIDE the try so an absent credential surfaces as
|
|
186
|
+
// `LlmBindingError`'s own code instead of escaping as a crash. No
|
|
187
|
+
// envelope is emitted on that path — a "successful" empty review would
|
|
188
|
+
// be worse than an error.
|
|
189
|
+
try {
|
|
190
|
+
const config = resolveAnthropicConfig();
|
|
191
|
+
const llmRunner = createAnthropicRunner(config);
|
|
192
|
+
const review = await prepareFinalReview(rid, {
|
|
193
|
+
projectRoot: projectValidation.projectRoot,
|
|
194
|
+
sessionId: sessionValidation.sessionId,
|
|
195
|
+
llmRunner,
|
|
196
|
+
...(options.base === undefined ? {} : { baseRef: options.base }),
|
|
197
|
+
});
|
|
198
|
+
const data = {
|
|
199
|
+
status: 'review-complete',
|
|
200
|
+
rid,
|
|
201
|
+
sessionId: sessionValidation.sessionId,
|
|
202
|
+
auditGoalPath,
|
|
203
|
+
serviceWired: true,
|
|
204
|
+
providerBinding: 'anthropic-messages-api',
|
|
205
|
+
model: config.model,
|
|
206
|
+
review,
|
|
207
|
+
};
|
|
208
|
+
const envelope = ok('final-review.prepare', data, review.allPass ? [] : [
|
|
209
|
+
`Dimensions needing human attention: ${review.needsAttention.join(', ') || 'none flagged'}.`,
|
|
210
|
+
], [
|
|
211
|
+
`4-dim review produced by anthropic-messages-api (model: ${config.model}).`,
|
|
212
|
+
`allPass: ${String(review.allPass)}.`,
|
|
213
|
+
]);
|
|
214
|
+
printResult(io, envelope, options.json);
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
const code = finalReviewErrorCode(error);
|
|
218
|
+
printResult(io, fail('final-review.prepare', code, getErrorMessage(error), emptyFinalReviewData(rid, sessionValidation.sessionId, auditGoalPath, error instanceof LlmBindingError ? error.missingEnv : undefined), finalReviewNextActions(code)), options.json);
|
|
219
|
+
process.exitCode = 1;
|
|
220
|
+
}
|
|
160
221
|
});
|
|
161
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Map a thrown error to the CLI's error code. The LLM-layer errors already
|
|
225
|
+
* carry codes precise enough to act on (`LLM_CREDENTIAL_MISSING`,
|
|
226
|
+
* `LLM_REQUEST_FAILED`, …), so they are passed through rather than flattened
|
|
227
|
+
* into one opaque failure.
|
|
228
|
+
*/
|
|
229
|
+
function finalReviewErrorCode(error) {
|
|
230
|
+
if (error instanceof LlmBindingError ||
|
|
231
|
+
error instanceof LlmRequestError ||
|
|
232
|
+
error instanceof IncompleteFinalReviewError) {
|
|
233
|
+
return error.code;
|
|
234
|
+
}
|
|
235
|
+
return 'FINAL_REVIEW_FAILED';
|
|
236
|
+
}
|
|
237
|
+
function finalReviewNextActions(code) {
|
|
238
|
+
switch (code) {
|
|
239
|
+
case 'LLM_CREDENTIAL_MISSING':
|
|
240
|
+
return [
|
|
241
|
+
'Export ANTHROPIC_AUTH_TOKEN (or ANTHROPIC_API_KEY) in the environment that launches peaks, then re-run.',
|
|
242
|
+
'For an offline scaffold instead of a review, re-run with `--llm-provider stub` — it performs NO review.',
|
|
243
|
+
];
|
|
244
|
+
case 'LLM_MODEL_MISSING':
|
|
245
|
+
return [
|
|
246
|
+
'Export ANTHROPIC_MODEL (or CLAUDE_CODE_SUBAGENT_MODEL) in the environment that launches peaks, then re-run.',
|
|
247
|
+
];
|
|
248
|
+
case 'LLM_REQUEST_FAILED':
|
|
249
|
+
return [
|
|
250
|
+
'Check ANTHROPIC_BASE_URL and network reachability, then re-run — a transport failure produces no review.',
|
|
251
|
+
];
|
|
252
|
+
case 'INCOMPLETE_FINAL_REVIEW':
|
|
253
|
+
return [
|
|
254
|
+
'The LLM reply was not valid JSON or omitted a required dimension; re-run so the gate is never read as complete.',
|
|
255
|
+
];
|
|
256
|
+
default:
|
|
257
|
+
return ['Re-run with `--llm-provider stub` to validate the CLI route without a real LLM.'];
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -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
|
-
|
|
410
|
+
const candidates = [];
|
|
356
411
|
if (fs2.existsSync(dir)) {
|
|
357
412
|
for (const f of fs2.readdirSync(dir)) {
|
|
358
|
-
|
|
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 =
|
|
362
|
-
if (r.requestId
|
|
363
|
-
|
|
364
|
-
|
|
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 (
|
|
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
|
-
|
|
374
|
-
|
|
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
|
-
|
|
377
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
import { existsSync, statSync } from 'node:fs';
|
|
39
39
|
import { join } from 'node:path';
|
|
40
40
|
import { CODEGRAPH_DIR_NAME, CODEGRAPH_MARKER_NAME, createCodegraphInvocation, executeCodegraphInvocation, isCodegraphInitialized, } from './codegraph-service.js';
|
|
41
|
+
import { repairCodegraphExcludeFromProject } from './codegraph-exclude-repair.js';
|
|
41
42
|
/**
|
|
42
43
|
* True when `<projectRoot>/.codegraph/` exists and is a directory.
|
|
43
44
|
* Pure fs probe; never throws.
|
|
@@ -107,6 +108,17 @@ export async function refreshCodegraphAfterSlice(projectRoot, runner) {
|
|
|
107
108
|
note: `auto codegraph refresh self-heal init failed (exit ${String(initResult.exitCode)}): ${firstMeaningfulLine(initResult.stderr || initResult.stdout)}`,
|
|
108
109
|
};
|
|
109
110
|
}
|
|
111
|
+
// That init just wrote upstream's 99-rule default `exclude`
|
|
112
|
+
// template, some of which block tracked source files — the same
|
|
113
|
+
// self-heal the CLI's `peaks codegraph init` performs, via the
|
|
114
|
+
// same shared helper. Skipped, this path would stamp the
|
|
115
|
+
// peaks-loop marker over an incomplete index that no later
|
|
116
|
+
// `init` (it would no-op) could ever repair.
|
|
117
|
+
//
|
|
118
|
+
// Never throws (the helper catches everything), and
|
|
119
|
+
// `reindex: false` because the index call below covers the
|
|
120
|
+
// recovered files.
|
|
121
|
+
await repairCodegraphExcludeFromProject(projectRoot, runner, { reindex: false });
|
|
110
122
|
}
|
|
111
123
|
const invocation = createCodegraphInvocation({ subcommand: 'index', project: projectRoot, quiet: true });
|
|
112
124
|
const result = await executeCodegraphInvocation(invocation, runner);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { type CodegraphExcludeViolation } from './codegraph-exclude-reconciler.js';
|
|
2
|
+
/**
|
|
3
|
+
* Exit code `peaks codegraph status` uses when the index is
|
|
4
|
+
* demonstrably incomplete. Distinct from the upstream pass-through
|
|
5
|
+
* exit code and from `CODEGRAPH_INIT_CONFLICT_EXIT_CODE` (73) so a CI
|
|
6
|
+
* job can tell "codegraph said no" apart from "peaks-loop found a
|
|
7
|
+
* tracked source file the index silently dropped".
|
|
8
|
+
*/
|
|
9
|
+
export declare const CODEGRAPH_INTEGRITY_EXIT_CODE = 74;
|
|
10
|
+
export type CodegraphExcludeRuleImpact = {
|
|
11
|
+
/** The `exclude` rule that must be dropped. */
|
|
12
|
+
readonly rule: string;
|
|
13
|
+
/** Distinct tracked source files this single rule blocks. */
|
|
14
|
+
readonly blockedCount: number;
|
|
15
|
+
};
|
|
16
|
+
export type CodegraphExcludeIntegrityReport = {
|
|
17
|
+
/** Absolute path of the reconciled `.codegraph/config.json`. */
|
|
18
|
+
readonly configPath: string;
|
|
19
|
+
/** True when at least one tracked source file is blocked. */
|
|
20
|
+
readonly gap: boolean;
|
|
21
|
+
/** Tracked files that pass the config's `include` filter at all. */
|
|
22
|
+
readonly trackedSourceCount: number;
|
|
23
|
+
/** Distinct tracked source files blocked by at least one rule. */
|
|
24
|
+
readonly excludedTrackedCount: number;
|
|
25
|
+
/** One entry per (file, rule) pair — see S1's reconciler. */
|
|
26
|
+
readonly violations: readonly CodegraphExcludeViolation[];
|
|
27
|
+
/** Rules that must be dropped; empty exactly when `gap` is false. */
|
|
28
|
+
readonly rulesToRemove: readonly string[];
|
|
29
|
+
/** Per-rule blocked-file counts, in `rulesToRemove` order. */
|
|
30
|
+
readonly ruleImpacts: readonly CodegraphExcludeRuleImpact[];
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* True when `<projectRoot>/.codegraph/config.json` exists, i.e. when an
|
|
34
|
+
* exclude list is actually in play here. Both consumers use this to
|
|
35
|
+
* stay SILENT on a project that never ran `peaks codegraph init`: there
|
|
36
|
+
* is no exclusion to report, and a missing config is not a finding.
|
|
37
|
+
*
|
|
38
|
+
* Note the difference from `isCodegraphInitialized` in
|
|
39
|
+
* `codegraph-service.ts`, which probes `codegraph.db` (upstream's own
|
|
40
|
+
* definition of "initialized"). The exclude list is written by upstream
|
|
41
|
+
* init, so its presence is the narrower question this module asks.
|
|
42
|
+
*/
|
|
43
|
+
export declare function isCodegraphExcludeConfigPresent(projectRoot: string): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Read the project's git-tracked files + `.codegraph/config.json` and
|
|
46
|
+
* fold the reconciliation into a report.
|
|
47
|
+
*
|
|
48
|
+
* Throws (never silently degrades) when the project is not a git work
|
|
49
|
+
* tree, when the config is missing, or when it is malformed — callers
|
|
50
|
+
* that must stay alive (doctor, `status`) catch and surface the reason.
|
|
51
|
+
*/
|
|
52
|
+
export declare function inspectCodegraphExcludeIntegrity(projectRoot: string): CodegraphExcludeIntegrityReport;
|
|
53
|
+
/**
|
|
54
|
+
* Human-readable detail lines for a gapped report: the headline count,
|
|
55
|
+
* then the offending rules, then a sample of the blocked files so an
|
|
56
|
+
* operator can point at a concrete path without re-running anything.
|
|
57
|
+
*
|
|
58
|
+
* Returns an empty array for a clean report — the caller decides
|
|
59
|
+
* whether "clean" is worth printing at all.
|
|
60
|
+
*/
|
|
61
|
+
export declare function renderCodegraphExcludeIntegrityLines(report: CodegraphExcludeIntegrityReport): readonly string[];
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// src/services/codegraph/codegraph-exclude-integrity.ts
|
|
2
|
+
//
|
|
3
|
+
// Slice S2 of `2026-09-12-codegraph-exclude-integrity` — the READ-ONLY
|
|
4
|
+
// integrity report shared by `peaks codegraph status` and the
|
|
5
|
+
// `capability:codegraph-exclude-integrity` doctor check.
|
|
6
|
+
//
|
|
7
|
+
// S1's reconciler computes the raw truth (`violations` /
|
|
8
|
+
// `rulesToRemove`). This module folds it into a verdict-shaped report
|
|
9
|
+
// that both consumers can render and gate on, so neither of them has
|
|
10
|
+
// to re-derive "is there a gap, how big, and which rules cause it".
|
|
11
|
+
//
|
|
12
|
+
// It NEVER writes `.codegraph/config.json`. The only write path is
|
|
13
|
+
// `codegraph-exclude-repair.ts`, reachable from `peaks codegraph init`
|
|
14
|
+
// (fresh-initialization self-heal) and the explicit
|
|
15
|
+
// `peaks codegraph repair-exclude` command. Read stays read.
|
|
16
|
+
import { existsSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { CODEGRAPH_CONFIG_FILENAME, reconcileCodegraphExcludeFromProject } from './codegraph-exclude-reconciler.js';
|
|
19
|
+
import { CODEGRAPH_DIR_NAME } from './codegraph-service.js';
|
|
20
|
+
/**
|
|
21
|
+
* Exit code `peaks codegraph status` uses when the index is
|
|
22
|
+
* demonstrably incomplete. Distinct from the upstream pass-through
|
|
23
|
+
* exit code and from `CODEGRAPH_INIT_CONFLICT_EXIT_CODE` (73) so a CI
|
|
24
|
+
* job can tell "codegraph said no" apart from "peaks-loop found a
|
|
25
|
+
* tracked source file the index silently dropped".
|
|
26
|
+
*/
|
|
27
|
+
export const CODEGRAPH_INTEGRITY_EXIT_CODE = 74;
|
|
28
|
+
/** How many offending rules and blocked files we name on the human path. */
|
|
29
|
+
const MAX_REPORTED_RULES = 10;
|
|
30
|
+
const MAX_REPORTED_FILES = 10;
|
|
31
|
+
/**
|
|
32
|
+
* True when `<projectRoot>/.codegraph/config.json` exists, i.e. when an
|
|
33
|
+
* exclude list is actually in play here. Both consumers use this to
|
|
34
|
+
* stay SILENT on a project that never ran `peaks codegraph init`: there
|
|
35
|
+
* is no exclusion to report, and a missing config is not a finding.
|
|
36
|
+
*
|
|
37
|
+
* Note the difference from `isCodegraphInitialized` in
|
|
38
|
+
* `codegraph-service.ts`, which probes `codegraph.db` (upstream's own
|
|
39
|
+
* definition of "initialized"). The exclude list is written by upstream
|
|
40
|
+
* init, so its presence is the narrower question this module asks.
|
|
41
|
+
*/
|
|
42
|
+
export function isCodegraphExcludeConfigPresent(projectRoot) {
|
|
43
|
+
return existsSync(join(projectRoot, CODEGRAPH_DIR_NAME, CODEGRAPH_CONFIG_FILENAME));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Read the project's git-tracked files + `.codegraph/config.json` and
|
|
47
|
+
* fold the reconciliation into a report.
|
|
48
|
+
*
|
|
49
|
+
* Throws (never silently degrades) when the project is not a git work
|
|
50
|
+
* tree, when the config is missing, or when it is malformed — callers
|
|
51
|
+
* that must stay alive (doctor, `status`) catch and surface the reason.
|
|
52
|
+
*/
|
|
53
|
+
export function inspectCodegraphExcludeIntegrity(projectRoot) {
|
|
54
|
+
const result = reconcileCodegraphExcludeFromProject(projectRoot);
|
|
55
|
+
const blockedCounts = new Map();
|
|
56
|
+
for (const violation of result.violations) {
|
|
57
|
+
blockedCounts.set(violation.matchedRule, (blockedCounts.get(violation.matchedRule) ?? 0) + 1);
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
configPath: join(projectRoot, CODEGRAPH_DIR_NAME, CODEGRAPH_CONFIG_FILENAME),
|
|
61
|
+
gap: result.excludedTrackedCount > 0,
|
|
62
|
+
trackedSourceCount: result.trackedSourceCount,
|
|
63
|
+
excludedTrackedCount: result.excludedTrackedCount,
|
|
64
|
+
violations: result.violations,
|
|
65
|
+
rulesToRemove: result.rulesToRemove,
|
|
66
|
+
ruleImpacts: result.rulesToRemove.map((rule) => ({ rule, blockedCount: blockedCounts.get(rule) ?? 0 }))
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Human-readable detail lines for a gapped report: the headline count,
|
|
71
|
+
* then the offending rules, then a sample of the blocked files so an
|
|
72
|
+
* operator can point at a concrete path without re-running anything.
|
|
73
|
+
*
|
|
74
|
+
* Returns an empty array for a clean report — the caller decides
|
|
75
|
+
* whether "clean" is worth printing at all.
|
|
76
|
+
*/
|
|
77
|
+
export function renderCodegraphExcludeIntegrityLines(report) {
|
|
78
|
+
if (!report.gap) {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
const lines = [
|
|
82
|
+
`[FAIL] codegraph index is incomplete: ${report.excludedTrackedCount} of ${report.trackedSourceCount} tracked source files are excluded by ${report.rulesToRemove.length} rule(s).`
|
|
83
|
+
];
|
|
84
|
+
for (const impact of report.ruleImpacts.slice(0, MAX_REPORTED_RULES)) {
|
|
85
|
+
lines.push(` rule ${impact.rule} blocks ${impact.blockedCount} tracked file(s)`);
|
|
86
|
+
}
|
|
87
|
+
if (report.ruleImpacts.length > MAX_REPORTED_RULES) {
|
|
88
|
+
lines.push(` … and ${report.ruleImpacts.length - MAX_REPORTED_RULES} more rule(s)`);
|
|
89
|
+
}
|
|
90
|
+
for (const violation of report.violations.slice(0, MAX_REPORTED_FILES)) {
|
|
91
|
+
lines.push(` excluded: ${violation.path} <- ${violation.matchedRule}`);
|
|
92
|
+
}
|
|
93
|
+
if (report.violations.length > MAX_REPORTED_FILES) {
|
|
94
|
+
lines.push(` … and ${report.violations.length - MAX_REPORTED_FILES} more file/rule pair(s)`);
|
|
95
|
+
}
|
|
96
|
+
lines.push('Run `peaks codegraph repair-exclude --project <root>` to drop these rules, back up the config, and rebuild the index.');
|
|
97
|
+
return lines;
|
|
98
|
+
}
|