iterate-plugin 3.5.7 → 3.6.0
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/README.md +64 -20
- package/README.zh-CN.md +53 -17
- package/dist/approval-gate.js +143 -23
- package/dist/atomic-fs.js +148 -17
- package/dist/config-loader.js +509 -34
- package/dist/config-write.js +83 -22
- package/dist/evidence.js +141 -87
- package/dist/file-lock.js +165 -0
- package/dist/index.js +54 -28
- package/dist/jobs.js +56 -11
- package/dist/live.js +52 -13
- package/dist/meta-review.js +132 -28
- package/dist/method-scope.js +31 -8
- package/dist/review-scope.js +32 -9
- package/dist/review.js +96 -17
- package/dist/session-hooks.js +55 -13
- package/dist/skill-prompt.js +396 -66
- package/dist/tools/checkpoint.js +85 -19
- package/dist/tools/config.js +44 -2
- package/dist/tools/context.js +22 -5
- package/dist/tools/decision-log.js +111 -27
- package/dist/tools/defense-events.js +90 -31
- package/dist/tools/defense-store.js +28 -3
- package/dist/tools/experience-bank.js +108 -43
- package/dist/tools/experience-store.js +54 -4
- package/dist/tools/fix.js +347 -119
- package/dist/tools/history.js +68 -14
- package/dist/tools/present.js +80 -0
- package/dist/tools/prune.js +150 -53
- package/dist/tools/quality-gate.js +52 -2
- package/dist/tools/quality-store.js +17 -1
- package/dist/tools/review.js +158 -7
- package/dist/tools/transcript.js +173 -36
- package/dist/tools/triage.js +67 -12
- package/dist/tools/validate.js +115 -24
- package/dist/transcript.js +267 -4
- package/lib/client.js +945 -181
- package/lib/parse.js +760 -57
- package/package.json +14 -12
- package/src/approval-gate.ts +160 -23
- package/src/atomic-fs.ts +152 -17
- package/src/client/index.ts +600 -129
- package/src/config-loader.ts +542 -35
- package/src/config-write.ts +108 -26
- package/src/evidence.ts +194 -92
- package/src/file-lock.ts +184 -0
- package/src/index.ts +53 -29
- package/src/jobs.ts +63 -12
- package/src/live.ts +48 -11
- package/src/meta-review.ts +151 -34
- package/src/method-scope.ts +31 -8
- package/src/review-scope.ts +33 -10
- package/src/review.ts +96 -17
- package/src/session-hooks.ts +54 -12
- package/src/skill-prompt.ts +396 -66
- package/src/tools/checkpoint.ts +81 -19
- package/src/tools/config.ts +44 -2
- package/src/tools/context.ts +25 -5
- package/src/tools/decision-log.ts +115 -22
- package/src/tools/defense-events.ts +87 -31
- package/src/tools/defense-store.ts +30 -3
- package/src/tools/experience-bank.ts +112 -44
- package/src/tools/experience-store.ts +54 -5
- package/src/tools/fix.ts +258 -20
- package/src/tools/history.ts +74 -15
- package/src/tools/present.ts +89 -0
- package/src/tools/prune.ts +154 -51
- package/src/tools/quality-gate.ts +57 -2
- package/src/tools/quality-store.ts +18 -1
- package/src/tools/review.ts +155 -6
- package/src/tools/transcript.ts +178 -36
- package/src/tools/triage.ts +70 -13
- package/src/tools/validate.ts +138 -32
- package/src/transcript.ts +279 -4
- package/src/types.ts +29 -1
|
@@ -59,6 +59,11 @@ export function readQualityGate(projectRoot) {
|
|
|
59
59
|
})
|
|
60
60
|
: [];
|
|
61
61
|
return {
|
|
62
|
+
// Snapshot-level fallback timestamp: display-only metadata — the gate
|
|
63
|
+
// is a SINGLE snapshot file (overwritten wholesale), and nothing sorts
|
|
64
|
+
// or evicts by `timestamp`, so a missing/corrupt value degrading to
|
|
65
|
+
// "now" cannot affect retention or ordering. (Reaffirmed from the
|
|
66
|
+
// earlier review: comment only, behavior unchanged.)
|
|
62
67
|
timestamp: typeof parsed.timestamp === 'string' ? parsed.timestamp : emptySnapshot().timestamp,
|
|
63
68
|
overallStatus: status,
|
|
64
69
|
overallScore: num(parsed.overallScore),
|
|
@@ -160,6 +165,12 @@ export function computeQualityGate(opts) {
|
|
|
160
165
|
dimensionStats[dim] = { count: 0, critical: 0, high: 0, medium: 0, low: 0 };
|
|
161
166
|
}
|
|
162
167
|
for (const finding of findings) {
|
|
168
|
+
// Findings in dimensions outside the gated list land here with no matching
|
|
169
|
+
// `dimensionStats` entry and are skipped — they never touch any dimension
|
|
170
|
+
// score (only a global critical still fails the gate). Scoring stays
|
|
171
|
+
// unchanged by contract; the tool layer surfaces the excluded dimensions
|
|
172
|
+
// as `warnings` so the numbers are never silently incomplete
|
|
173
|
+
// (see src/tools/quality-gate.ts).
|
|
163
174
|
const stats = dimensionStats[finding.dimension];
|
|
164
175
|
if (stats) {
|
|
165
176
|
stats.count++;
|
|
@@ -179,7 +190,12 @@ export function computeQualityGate(opts) {
|
|
|
179
190
|
// Score: 100 - (critical*30 + high*15 + medium*5 + low*1), capped at 0
|
|
180
191
|
const penalty = stats.critical * 30 + stats.high * 15 + stats.medium * 5 + stats.low * 1;
|
|
181
192
|
const score = Math.max(0, 100 - penalty);
|
|
182
|
-
|
|
193
|
+
// Arithmetic alone would grade a single critical as 100-30=70 → 'warn',
|
|
194
|
+
// while the snapshot's overall status is 'fail' (hasCritical below) — a
|
|
195
|
+
// certificate that contradicts itself ("WARN" on the dimension that made
|
|
196
|
+
// the gate fail). Any critical finding is a hard FAIL for that dimension,
|
|
197
|
+
// regardless of its score; non-critical dimensions keep the score bands.
|
|
198
|
+
const status = stats.critical > 0 ? 'fail' : score >= 80 ? 'pass' : score >= 50 ? 'warn' : 'fail';
|
|
183
199
|
const series = findingsByRound?.[dim];
|
|
184
200
|
const convergenceRate = convergenceRateFor(Array.isArray(series) ? series : undefined, stats.count);
|
|
185
201
|
return {
|
package/dist/tools/review.js
CHANGED
|
@@ -1,13 +1,73 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
2
2
|
import { loadEffectiveConfig, resolveProjectRootForExec } from "../config-loader.js";
|
|
3
3
|
import { runWithJob } from "../jobs.js";
|
|
4
|
-
import { buildReviewPlan, buildReviewReport, sanitizeRounds, validateRoundsSchema, } from "../review.js";
|
|
4
|
+
import { buildReviewPlan, buildReviewReport, sanitizeRounds, validateRoundsSchema, MAX_REVIEW_ROUNDS_CAP, } from "../review.js";
|
|
5
5
|
import { buildFinalReviewReport, metaReviewReport } from "../meta-review.js";
|
|
6
|
-
import { evidenceToPlain, verifyFindings } from "../evidence.js";
|
|
6
|
+
import { evidenceToPlain, resolveWithin, verifyFindings } from "../evidence.js";
|
|
7
|
+
import { readKnownIntentional } from "./triage.js";
|
|
8
|
+
import { asNumber, asRecord, parseRenderedJson } from "./present.js";
|
|
7
9
|
import { collectScopeFiles, computeCoverage, coverageToDict, } from "../review-scope.js";
|
|
8
10
|
import { resolveChangedFiles } from "../git-scope.js";
|
|
9
11
|
/** Default round cap when neither the arg nor config provides one. */
|
|
10
12
|
const DEFAULT_MAX_REVIEW_ROUNDS = 3;
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the effective round cap at the TOOL boundary so `plan` and
|
|
15
|
+
* `aggregate` can never disagree on it (the plan advertises a cap the
|
|
16
|
+
* aggregate must then honor).
|
|
17
|
+
*
|
|
18
|
+
* Precedence: a FINITE caller value → a FINITE config value → the default (3).
|
|
19
|
+
* A non-finite / non-numeric caller value does NOT collapse to 1 — it falls
|
|
20
|
+
* through to the config value (the old doc comment claimed otherwise, which
|
|
21
|
+
* the implementation never did). Only a finite `≤ 0` value collapses to 1,
|
|
22
|
+
* the smallest sane cap.
|
|
23
|
+
*
|
|
24
|
+
* The resolved value is then clamped to `[1, MAX_REVIEW_ROUNDS_CAP]`, so an
|
|
25
|
+
* absurd caller value (1e15) can no longer leak an unbounded cap into the
|
|
26
|
+
* report's `maxReviewRounds` field (`buildReviewReport` echoes its input and
|
|
27
|
+
* `aggregateRounds` sizes `findingsByRound` from it). Upper clamp mirrors
|
|
28
|
+
* `clampMaxReviewRounds` in src/review.ts.
|
|
29
|
+
*/
|
|
30
|
+
export function resolveMaxReviewRounds(argsMax, configMax) {
|
|
31
|
+
const raw = typeof argsMax === 'number' && Number.isFinite(argsMax)
|
|
32
|
+
? argsMax
|
|
33
|
+
: typeof configMax === 'number' && Number.isFinite(configMax)
|
|
34
|
+
? configMax
|
|
35
|
+
: DEFAULT_MAX_REVIEW_ROUNDS;
|
|
36
|
+
return Math.min(MAX_REVIEW_ROUNDS_CAP, Math.max(1, Math.floor(raw)));
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Build the READ set backing the meta-review evidence verdict from the
|
|
40
|
+
* report's self-reported `readFiles` (aggregated across rounds; falls back to
|
|
41
|
+
* the per-round lists for older reports that predate the flat field).
|
|
42
|
+
*
|
|
43
|
+
* Entries are resolved the SAME way `verifyFinding` resolves a finding's
|
|
44
|
+
* `file` (lexical, root-relative) so the Set membership check matches, and
|
|
45
|
+
* entries escaping the project root are dropped. Returns `undefined` when
|
|
46
|
+
* nothing was read — an absent read set keeps `readVerified` "not checkable"
|
|
47
|
+
* (null ratio) instead of a misleading `0.0`.
|
|
48
|
+
*/
|
|
49
|
+
function buildReadSet(projectRoot, report) {
|
|
50
|
+
const raw = [];
|
|
51
|
+
if (Array.isArray(report.readFiles)) {
|
|
52
|
+
raw.push(...report.readFiles);
|
|
53
|
+
}
|
|
54
|
+
else if (Array.isArray(report.rounds)) {
|
|
55
|
+
for (const r of report.rounds) {
|
|
56
|
+
const files = r?.readFiles;
|
|
57
|
+
if (Array.isArray(files))
|
|
58
|
+
raw.push(...files);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const set = new Set();
|
|
62
|
+
for (const p of raw) {
|
|
63
|
+
if (typeof p !== 'string' || p === '')
|
|
64
|
+
continue;
|
|
65
|
+
const abs = resolveWithin(projectRoot, p);
|
|
66
|
+
if (abs !== null)
|
|
67
|
+
set.add(abs);
|
|
68
|
+
}
|
|
69
|
+
return set.size > 0 ? set : undefined;
|
|
70
|
+
}
|
|
11
71
|
/**
|
|
12
72
|
* Register the `iterate_review` tool.
|
|
13
73
|
*
|
|
@@ -21,9 +81,73 @@ const DEFAULT_MAX_REVIEW_ROUNDS = 3;
|
|
|
21
81
|
* severity sort, and convergence stats; returns a ReviewReport.
|
|
22
82
|
* Purely computational — NEVER touches the filesystem.
|
|
23
83
|
*/
|
|
84
|
+
/**
|
|
85
|
+
* Card headline for an `aggregate` result: what the report found and where
|
|
86
|
+
* convergence stands (#12). Defensive — returns `undefined` whenever the
|
|
87
|
+
* payload is not the shape this tool renders, so the card is declined rather
|
|
88
|
+
* than guessed at.
|
|
89
|
+
*/
|
|
90
|
+
function aggregateHeadline(value) {
|
|
91
|
+
const report = asRecord(value.report);
|
|
92
|
+
const summary = report ? asRecord(report.summary) : undefined;
|
|
93
|
+
const total = summary ? asNumber(summary.totalFindings) : undefined;
|
|
94
|
+
if (total === undefined || !summary)
|
|
95
|
+
return undefined;
|
|
96
|
+
const buckets = ['critical', 'high', 'medium', 'low']
|
|
97
|
+
.map((k) => ({ label: k, n: asNumber(summary[k]) ?? 0 }))
|
|
98
|
+
.filter((b) => b.n > 0)
|
|
99
|
+
.map((b) => `${b.n} ${b.label}`);
|
|
100
|
+
const convergence = report ? asRecord(report.convergence) : undefined;
|
|
101
|
+
// A mid-run aggregate legitimately has converged:false — say so instead of
|
|
102
|
+
// implying the run is over.
|
|
103
|
+
const status = convergence && convergence.converged === true ? 'converged' : 'not converged yet';
|
|
104
|
+
return (`Review report: ${total} finding${total === 1 ? '' : 's'}` +
|
|
105
|
+
(buckets.length > 0 ? ` (${buckets.join(', ')})` : '') +
|
|
106
|
+
` — ${status}`);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Card headline for a `meta-review` result: the verdict plus what the audit
|
|
110
|
+
* checked (#12). Same defensive contract as {@link aggregateHeadline}.
|
|
111
|
+
*/
|
|
112
|
+
function metaReviewHeadline(value) {
|
|
113
|
+
const finalReport = asRecord(value.finalReport);
|
|
114
|
+
if (!finalReport)
|
|
115
|
+
return undefined;
|
|
116
|
+
const verdict = typeof finalReport.verdict === 'string' && finalReport.verdict
|
|
117
|
+
? finalReport.verdict
|
|
118
|
+
: undefined;
|
|
119
|
+
if (!verdict)
|
|
120
|
+
return undefined;
|
|
121
|
+
const meta = asRecord(finalReport.metaReview);
|
|
122
|
+
const issues = meta && Array.isArray(meta.issues) ? meta.issues.length : undefined;
|
|
123
|
+
const checks = meta ? asNumber(meta.checksRun) : undefined;
|
|
124
|
+
const bits = [
|
|
125
|
+
issues !== undefined ? `${issues} issue${issues === 1 ? '' : 's'}` : '',
|
|
126
|
+
checks !== undefined ? `${checks} check${checks === 1 ? '' : 's'} run` : '',
|
|
127
|
+
].filter(Boolean);
|
|
128
|
+
return `Meta-review: ${verdict}` + (bits.length > 0 ? ` (${bits.join(', ')})` : '');
|
|
129
|
+
}
|
|
24
130
|
export function registerReviewTool(ctx) {
|
|
25
131
|
ctx.tools.register(defineTool({
|
|
26
132
|
name: 'iterate_review',
|
|
133
|
+
// Result card (#12): aggregate/meta-review results are large raw-JSON
|
|
134
|
+
// bodies — give them a readable headline (finding counts + convergence /
|
|
135
|
+
// verdict) instead of an unsummarized blob. Only these two operations
|
|
136
|
+
// get a card; `plan` keeps the default. Pure: parsed from the rendered
|
|
137
|
+
// result only (replay-safe); a shape miss declines the card.
|
|
138
|
+
presentResult: (args, result) => {
|
|
139
|
+
if (result.isError)
|
|
140
|
+
return undefined;
|
|
141
|
+
const a = args;
|
|
142
|
+
const op = a.operation;
|
|
143
|
+
if (op !== 'aggregate' && op !== 'meta-review')
|
|
144
|
+
return undefined;
|
|
145
|
+
const value = parseRenderedJson(result);
|
|
146
|
+
if (!value)
|
|
147
|
+
return undefined;
|
|
148
|
+
const title = op === 'aggregate' ? aggregateHeadline(value) : metaReviewHeadline(value);
|
|
149
|
+
return title ? { card: 'generic', title } : undefined;
|
|
150
|
+
},
|
|
27
151
|
description: 'Deterministic review engine for the iterate workflow. ' +
|
|
28
152
|
'Use `plan` to generate the review plan (dimensions, reviewer prompts, findings schema, round cap) ' +
|
|
29
153
|
'for normal or dry-run mode. Use `aggregate` to merge raw per-round findings into a deduped, ' +
|
|
@@ -128,7 +252,7 @@ export function registerReviewTool(ctx) {
|
|
|
128
252
|
const { config } = loadEffectiveConfig(projectRoot);
|
|
129
253
|
const mode = args.mode ?? 'dry-run';
|
|
130
254
|
if (args.operation === 'plan') {
|
|
131
|
-
const maxReviewRounds = args.maxReviewRounds
|
|
255
|
+
const maxReviewRounds = resolveMaxReviewRounds(args.maxReviewRounds, config.max_rounds);
|
|
132
256
|
const knownIntentional = config.personalization
|
|
133
257
|
?.known_intentional;
|
|
134
258
|
// changed-only scope: resolve the changed-file set against
|
|
@@ -169,7 +293,12 @@ export function registerReviewTool(ctx) {
|
|
|
169
293
|
: [];
|
|
170
294
|
return { round: typeof rr?.round === 'number' ? rr.round : 0, findings, readFiles };
|
|
171
295
|
})
|
|
172
|
-
|
|
296
|
+
// Boundary guard: convergence is keyed on `round - 1`, so a
|
|
297
|
+
// fractional/zero/NaN round (`1.5`, `NaN` — all passable through
|
|
298
|
+
// the `json` argument) must never reach the deterministic core.
|
|
299
|
+
// Mirrors the core's own `Number.isInteger(round) && round >= 1`
|
|
300
|
+
// filter; a non-integer round is simply "not a round" here.
|
|
301
|
+
.filter((r) => Number.isInteger(r.round) && r.round >= 1);
|
|
173
302
|
if (rounds.length === 0) {
|
|
174
303
|
return {
|
|
175
304
|
operation: 'aggregate',
|
|
@@ -177,7 +306,7 @@ export function registerReviewTool(ctx) {
|
|
|
177
306
|
error: 'rounds must be a non-empty array of {round, findings}.',
|
|
178
307
|
};
|
|
179
308
|
}
|
|
180
|
-
const maxReviewRounds = args.maxReviewRounds
|
|
309
|
+
const maxReviewRounds = resolveMaxReviewRounds(args.maxReviewRounds, config.max_rounds);
|
|
181
310
|
const goal = args.goal ?? config.goal ?? '';
|
|
182
311
|
const dimensions = config.dimensions ?? [];
|
|
183
312
|
// Output schema validation gate (reviewer.output_schema_validation,
|
|
@@ -197,7 +326,18 @@ export function registerReviewTool(ctx) {
|
|
|
197
326
|
dimensions,
|
|
198
327
|
maxReviewRounds,
|
|
199
328
|
rounds: cleanRounds,
|
|
200
|
-
|
|
329
|
+
// `type:'json'` passes schema validation for ANY JSON value, so a
|
|
330
|
+
// bare string/object used to reach filterKnownIntentional →
|
|
331
|
+
// `known.some` TypeError that escaped execute. Every sibling json
|
|
332
|
+
// arg is guarded the same way: a non-array means "no known
|
|
333
|
+
// entries" (identical to omitting it). The element shape reuses
|
|
334
|
+
// triage's guard — the same one behind the config-sourced list —
|
|
335
|
+
// so config-provided and arg-provided rows stay interchangeable.
|
|
336
|
+
knownIntentional: Array.isArray(args.knownIntentional)
|
|
337
|
+
? readKnownIntentional({
|
|
338
|
+
personalization: { known_intentional: args.knownIntentional },
|
|
339
|
+
})
|
|
340
|
+
: undefined,
|
|
201
341
|
fixedCount: typeof args.fixedCount === 'number' ? args.fixedCount : undefined,
|
|
202
342
|
});
|
|
203
343
|
return {
|
|
@@ -222,7 +362,18 @@ export function registerReviewTool(ctx) {
|
|
|
222
362
|
// verdict. Disable via config `reviewer.evidence_validation: false`.
|
|
223
363
|
const evidenceEnabled = config.reviewer?.evidence_validation !== false;
|
|
224
364
|
const findings = Array.isArray(source.findings) ? source.findings : [];
|
|
225
|
-
|
|
365
|
+
// READ set for the evidence verdict: `readVerified`/`readVerifiedRatio`
|
|
366
|
+
// are only computable when the audit knows which files reviewers
|
|
367
|
+
// actually opened, yet the tool never supplied one — so the skill
|
|
368
|
+
// prompt's "every finding anchors to real, READ code" was
|
|
369
|
+
// unverifiable (always null). The report already aggregates every
|
|
370
|
+
// round's self-reported `readFiles`; wiring it through evidence.ts's
|
|
371
|
+
// existing optional `readSet` needs no core change. An empty read
|
|
372
|
+
// list keeps the "not checkable" (null) semantics instead of 0.0.
|
|
373
|
+
const readSet = buildReadSet(projectRoot, source);
|
|
374
|
+
const evidence = evidenceEnabled
|
|
375
|
+
? verifyFindings(projectRoot, findings, readSet ? { readSet } : {})
|
|
376
|
+
: null;
|
|
226
377
|
// Prompt-informative coverage: compare the reviewer's self-reported
|
|
227
378
|
// reads against the assigned scope inventory (never flips the
|
|
228
379
|
// verdict). Disable via config `reviewer.coverage_validation: false`.
|
package/dist/tools/transcript.js
CHANGED
|
@@ -9,10 +9,12 @@
|
|
|
9
9
|
* "not found" empty view). Used each round by the workflow to
|
|
10
10
|
* pick up steering nudges, and polled by tool-reading agents.
|
|
11
11
|
* - `capture` — build a fresh transcript from the review `rounds` + `report`
|
|
12
|
-
* and persist it. Called by
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* (plus the decision-log timeline) and persist it. Called by
|
|
13
|
+
* the canonical scripts after the final aggregate so the
|
|
14
|
+
* client always sees the latest run. Skipped entirely when
|
|
15
|
+
* `observatory.capture: false` is configured.
|
|
16
|
+
* - `nudge` — set (`text`) or clear (`text: null` / empty string) steering
|
|
17
|
+
* text persisted for the next round's reviewers to read.
|
|
16
18
|
*
|
|
17
19
|
* All writes are persisted to `.iterate/transcript.json` via an atomic
|
|
18
20
|
* tmp+rename so a crashed writer never leaves a corrupt manifest.
|
|
@@ -24,7 +26,8 @@ import { writeTextAtomicAsync } from "../atomic-fs.js";
|
|
|
24
26
|
import { dirname } from 'node:path';
|
|
25
27
|
import { loadEffectiveConfig, resolveProjectRootForExec, } from "../config-loader.js";
|
|
26
28
|
import { transcriptPath } from "../paths.js";
|
|
27
|
-
import { ReviewTranscriptBuilder } from "../transcript.js";
|
|
29
|
+
import { ReviewTranscriptBuilder, MAX_TIMELINE, MAX_ROUNDS, normalizeManifestBounds } from "../transcript.js";
|
|
30
|
+
import { readDecisionLogDetailed } from "./decision-log.js";
|
|
28
31
|
import { readLive } from "../live.js";
|
|
29
32
|
/** Build per-dimension threads for one round from its (dimension-tagged) findings. */
|
|
30
33
|
function captureRound(builder, round) {
|
|
@@ -98,6 +101,43 @@ function normalizeFix(input) {
|
|
|
98
101
|
success: f.success !== false,
|
|
99
102
|
};
|
|
100
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Structural gate for a parsed `.iterate/transcript.json` payload.
|
|
106
|
+
*
|
|
107
|
+
* `JSON.parse` accepts `null`, `[]`, `{}` … — parseable values that are NOT
|
|
108
|
+
* manifests; the read path used to return them as `found: true`. Only a
|
|
109
|
+
* payload carrying the builder's field layout is accepted here (the ROOT
|
|
110
|
+
* gate); row-level bounds and values are then re-normalized on the way out by
|
|
111
|
+
* {@link normalizeManifestBounds}, so an older but still renderable manifest
|
|
112
|
+
* is shown instead of blanked — and a hostile one is bounded instead of
|
|
113
|
+
* passed through.
|
|
114
|
+
*
|
|
115
|
+
* Deliberately does NOT rehydrate through the builder for normalization:
|
|
116
|
+
* `rehydrateBuilder(...).serialize()` drops fields the builder cannot rebuild
|
|
117
|
+
* (notably `phases`), so a plain read would silently rewrite history.
|
|
118
|
+
*/
|
|
119
|
+
function isManifestShaped(value) {
|
|
120
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
121
|
+
return false;
|
|
122
|
+
const m = value;
|
|
123
|
+
if (typeof m.version !== 'number' || typeof m.project !== 'string' || typeof m.active !== 'boolean') {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
if (!Array.isArray(m.rounds) ||
|
|
127
|
+
!Array.isArray(m.convergence) ||
|
|
128
|
+
!Array.isArray(m.findings) ||
|
|
129
|
+
!Array.isArray(m.fixes) ||
|
|
130
|
+
!Array.isArray(m.timeline)) {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
// Rows must be objects (round rows with a numeric index): a manifest whose
|
|
134
|
+
// rows are `null`/`"x"` is structurally broken, not merely old.
|
|
135
|
+
return (m.rounds.every((r) => !!r && typeof r === 'object' && typeof r.round === 'number') &&
|
|
136
|
+
m.convergence.every((n) => typeof n === 'number') &&
|
|
137
|
+
m.findings.every((f) => !!f && typeof f === 'object') &&
|
|
138
|
+
m.fixes.every((f) => !!f && typeof f === 'object') &&
|
|
139
|
+
m.timeline.every((e) => !!e && typeof e === 'object'));
|
|
140
|
+
}
|
|
101
141
|
/** Register the `iterate_transcript` tool. */
|
|
102
142
|
export function registerTranscriptTool(ctx) {
|
|
103
143
|
ctx.tools.register(defineTool({
|
|
@@ -108,10 +148,13 @@ export function registerTranscriptTool(ctx) {
|
|
|
108
148
|
description: 'Runtime-observatory transcript for the iterate workflow. ' +
|
|
109
149
|
'`read` returns the current persisted transcript manifest (per-reviewer threads, ' +
|
|
110
150
|
'convergence series, findings, fixes, checkpoint, timeline, and any steering nudge ' +
|
|
111
|
-
'written for the next round)
|
|
112
|
-
'
|
|
113
|
-
'
|
|
114
|
-
'
|
|
151
|
+
'written for the next round) — or, when the file is missing/malformed, a structured ' +
|
|
152
|
+
'empty view (`found:false` plus a blank manifest). ' +
|
|
153
|
+
'`capture` builds a fresh transcript from the review `rounds` + `report`, folds in the ' +
|
|
154
|
+
'decision-log timeline, and persists it (call once after the final aggregate so the UI ' +
|
|
155
|
+
'reflects the run; skipped when `observatory.capture` is disabled). Round validation outcomes ' +
|
|
156
|
+
'can be carried on capture via `validations` ({round, command, exitCode, allowed, rejectReason?}). ' +
|
|
157
|
+
'`nudge` sets (text) or clears (text:null or an empty string) steering text the next round\'s reviewers read. ' +
|
|
115
158
|
'Purely local and deterministic — never touches source files.',
|
|
116
159
|
parameters: {
|
|
117
160
|
operation: {
|
|
@@ -147,9 +190,26 @@ export function registerTranscriptTool(ctx) {
|
|
|
147
190
|
type: 'json',
|
|
148
191
|
description: 'For `capture`: array of applied fixes [{id, file, round, summary, linesAdded, linesRemoved, success}].',
|
|
149
192
|
},
|
|
193
|
+
validations: {
|
|
194
|
+
type: 'json',
|
|
195
|
+
description: 'For `capture`: per-round validation outcomes [{round, command, exitCode:number|null, allowed:boolean, rejectReason?}] — ' +
|
|
196
|
+
'the exact commands run by iterate_validate with their exit codes. Malformed rows are dropped, ' +
|
|
197
|
+
'out-of-range rounds clamped, and the list capped (newest kept). Pass every round\'s validate results so ' +
|
|
198
|
+
'the run console can show which commands ran and whether they passed.',
|
|
199
|
+
},
|
|
150
200
|
refReadFiles: { type: 'json', description: 'For `capture`: flat array of all read files across rounds (optional).' },
|
|
151
201
|
stoppedReason: { type: 'string', description: 'For `capture`: why the run ended — "converged" | "max_rounds_reached" | "aborted_by_validation" | "aborted_by_config" | "schema_invalid" | "no_usable_reviewer_output" | "inconclusive". When omitted, derived from the convergence trend (trailing 0 = converged, otherwise = max_rounds_reached once a round ran).' },
|
|
152
|
-
|
|
202
|
+
// `string | null` (NOT plain `type: 'string'`): dsh validates arguments
|
|
203
|
+
// against the compiled schema BEFORE execute, and the docs — plus the
|
|
204
|
+
// client's clear button — advertise `{"operation":"nudge","text":null}`.
|
|
205
|
+
// Under `type: 'string'` that call always failed validation, so the
|
|
206
|
+
// advertised clear flow could never run. Probed against the installed
|
|
207
|
+
// dsh-tools validator: `oneOf[string,null]` accepts null + string and
|
|
208
|
+
// still rejects numbers (`type:'json'` would have accepted those too).
|
|
209
|
+
text: {
|
|
210
|
+
oneOf: [{ type: 'string' }, { type: 'null' }],
|
|
211
|
+
description: 'For `nudge`: steering text to set. `null` (or an empty/whitespace-only string) clears the stored nudge.',
|
|
212
|
+
},
|
|
153
213
|
path: { type: 'string', description: 'Project root directory (default: current working directory).' },
|
|
154
214
|
},
|
|
155
215
|
output: {
|
|
@@ -162,6 +222,11 @@ export function registerTranscriptTool(ctx) {
|
|
|
162
222
|
transcript: { type: 'json' },
|
|
163
223
|
live: { type: 'json', description: 'Recent live reviewer-activity entries (newest first).' },
|
|
164
224
|
updated: { type: 'boolean' },
|
|
225
|
+
skipped: {
|
|
226
|
+
type: 'boolean',
|
|
227
|
+
description: 'For `capture`: true when nothing was persisted because `observatory.capture` is disabled.',
|
|
228
|
+
},
|
|
229
|
+
reason: { type: 'string', description: 'For a skipped `capture`: why persistence was skipped.' },
|
|
165
230
|
error: { type: 'string' },
|
|
166
231
|
},
|
|
167
232
|
},
|
|
@@ -177,34 +242,50 @@ export function registerTranscriptTool(ctx) {
|
|
|
177
242
|
const approval = config.observatory?.approval ?? 'ask';
|
|
178
243
|
if (args.operation === 'read') {
|
|
179
244
|
const live = await readLive(projectRoot);
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
245
|
+
// Every "no usable manifest" outcome (missing file, unparseable JSON,
|
|
246
|
+
// parseable-but-wrong-shape JSON) returns the SAME structured
|
|
247
|
+
// not-found view: `found:false` plus an empty manifest. The client's
|
|
248
|
+
// F1–F7 tabs read `transcript.<field>` unconditionally, so the old
|
|
249
|
+
// bare `{found:false, error}` parse-error branch blanked the panel.
|
|
250
|
+
const emptyView = () => ({
|
|
251
|
+
operation: 'read',
|
|
252
|
+
found: false,
|
|
253
|
+
live: live,
|
|
254
|
+
transcript: new ReviewTranscriptBuilder({
|
|
255
|
+
project: projectRoot,
|
|
256
|
+
approval,
|
|
257
|
+
}).serialize(),
|
|
258
|
+
});
|
|
259
|
+
if (!existsSync(file))
|
|
260
|
+
return emptyView();
|
|
261
|
+
let parsed;
|
|
191
262
|
try {
|
|
192
|
-
|
|
193
|
-
const parsed = JSON.parse(raw);
|
|
194
|
-
return {
|
|
195
|
-
operation: 'read',
|
|
196
|
-
found: true,
|
|
197
|
-
live: live,
|
|
198
|
-
transcript: parsed,
|
|
199
|
-
};
|
|
263
|
+
parsed = JSON.parse(await readFile(file, 'utf-8'));
|
|
200
264
|
}
|
|
201
265
|
catch (err) {
|
|
202
266
|
return {
|
|
203
|
-
|
|
204
|
-
found: false,
|
|
267
|
+
...emptyView(),
|
|
205
268
|
error: `Failed to read transcript: ${err instanceof Error ? err.message : String(err)}`,
|
|
206
269
|
};
|
|
207
270
|
}
|
|
271
|
+
if (!isManifestShaped(parsed)) {
|
|
272
|
+
// `JSON.parse` happily accepts `null`, `[]` and `{}` — values that
|
|
273
|
+
// are parseable but carry no manifest. Surfacing them as
|
|
274
|
+
// `found:true` made the observatory render junk panels.
|
|
275
|
+
return {
|
|
276
|
+
...emptyView(),
|
|
277
|
+
error: 'Transcript file is not a valid manifest; returning an empty view.',
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
operation: 'read',
|
|
282
|
+
found: true,
|
|
283
|
+
live: live,
|
|
284
|
+
// Defensive normalization (bounded rows + sanitized field values)
|
|
285
|
+
// so a hand-edited/hostile manifest is never handed to the client
|
|
286
|
+
// raw — same normalizers/caps the builder applies at capture time.
|
|
287
|
+
transcript: normalizeManifestBounds(parsed),
|
|
288
|
+
};
|
|
208
289
|
}
|
|
209
290
|
if (args.operation === 'nudge') {
|
|
210
291
|
let manifest = null;
|
|
@@ -255,6 +336,18 @@ export function registerTranscriptTool(ctx) {
|
|
|
255
336
|
};
|
|
256
337
|
}
|
|
257
338
|
// capture
|
|
339
|
+
// `observatory.capture: false` opts the project out of transcript
|
|
340
|
+
// persistence entirely. Only an EXPLICIT false skips: an unreadable or
|
|
341
|
+
// missing config falls back to the defaults (capture on), so a broken
|
|
342
|
+
// config can never silently blind the observatory.
|
|
343
|
+
if (config.observatory?.capture === false) {
|
|
344
|
+
return {
|
|
345
|
+
operation: 'capture',
|
|
346
|
+
updated: false,
|
|
347
|
+
skipped: true,
|
|
348
|
+
reason: 'observatory.capture is disabled',
|
|
349
|
+
};
|
|
350
|
+
}
|
|
258
351
|
const mode = args.mode === 'normal' ? 'normal' : 'dry-run';
|
|
259
352
|
const taskMode = args.taskMode === 'code' || args.taskMode === 'iterate' ? args.taskMode : undefined;
|
|
260
353
|
const goal = typeof args.goal === 'string' ? args.goal : '';
|
|
@@ -287,15 +380,45 @@ export function registerTranscriptTool(ctx) {
|
|
|
287
380
|
for (const [dim, list] of byDim)
|
|
288
381
|
builder.reviewerSnapshot(dim, list, readFiles);
|
|
289
382
|
}
|
|
290
|
-
// Convergence series from the report (position per round).
|
|
291
|
-
|
|
383
|
+
// Convergence series from the report (position per round). The rehydrate
|
|
384
|
+
// path already refuses negative/non-finite counts; capture must not be
|
|
385
|
+
// the asymmetric back door that persists a hostile `-3` into the F2
|
|
386
|
+
// trend (a non-number entry was already skipped; `Number.isFinite`
|
|
387
|
+
// additionally rejects NaN, which `typeof NaN === 'number'` admits).
|
|
388
|
+
// Truncate at MAX_ROUNDS on the FEED side: `snapshotConvergence` clamps
|
|
389
|
+
// an over-cap round to the last slot, so feeding 1000+ entries would
|
|
390
|
+
// silently FOLD every extra round into slot 1000 (one value winning).
|
|
391
|
+
for (let i = 0; i < Math.min(convergence.length, MAX_ROUNDS); i += 1) {
|
|
292
392
|
const n = convergence[i];
|
|
293
|
-
if (typeof n === 'number')
|
|
393
|
+
if (typeof n === 'number' && Number.isFinite(n) && n >= 0)
|
|
294
394
|
builder.snapshotConvergence(i + 1, n);
|
|
295
395
|
}
|
|
296
396
|
const roundsExecuted = typeof args.roundsExecuted === 'number' ? Math.floor(args.roundsExecuted) : rounds.length;
|
|
397
|
+
// Advance the round MARKER only — never `roundStart`, which would
|
|
398
|
+
// pre-create phantom EMPTY round rows for rounds we captured no data
|
|
399
|
+
// for (a resumed run reports `roundsExecuted` above the rounds with
|
|
400
|
+
// captured findings). advanceRound also refuses to rewind below the
|
|
401
|
+
// last captured round.
|
|
297
402
|
if (roundsExecuted > 0)
|
|
298
|
-
builder.
|
|
403
|
+
builder.advanceRound(roundsExecuted, maxRounds);
|
|
404
|
+
// F7 timeline: the decision log is the source of truth for what the run
|
|
405
|
+
// decided, and the tool docs promise a captured `timeline`. Without
|
|
406
|
+
// folding it in, `manifest.timeline` was ALWAYS empty on capture (the
|
|
407
|
+
// builder's decision() ran only during nudge rehydration), so the
|
|
408
|
+
// client's F7 tab rendered nothing even for a run with a full audit
|
|
409
|
+
// trail. Feed only the builder's cap worth — the builder evicts oldest
|
|
410
|
+
// first anyway, and slicing first saves a clone per dropped entry.
|
|
411
|
+
// Fail-safe: an unreadable/corrupt log must never break the capture —
|
|
412
|
+
// the transcript is a UI projection, the log stays the primary record.
|
|
413
|
+
try {
|
|
414
|
+
const { entries } = readDecisionLogDetailed(projectRoot);
|
|
415
|
+
for (const e of entries.slice(-MAX_TIMELINE))
|
|
416
|
+
builder.decision(e);
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
// readDecisionLogDetailed already skips corrupt lines; this catch
|
|
420
|
+
// covers an unexpected reader failure (unreadable dir, EACCES …).
|
|
421
|
+
}
|
|
299
422
|
builder.recordCheckpoint(normalizeCheckpoint(args.checkpoint));
|
|
300
423
|
if (Array.isArray(args.fixes)) {
|
|
301
424
|
for (const fx of args.fixes) {
|
|
@@ -304,6 +427,15 @@ export function registerTranscriptTool(ctx) {
|
|
|
304
427
|
builder.fix(record);
|
|
305
428
|
}
|
|
306
429
|
}
|
|
430
|
+
// Validation outcomes (contract with the client run console):
|
|
431
|
+
// [{round, command, exitCode, allowed, rejectReason?}]. The builder
|
|
432
|
+
// normalizes every row defensively (bad rows dropped, rounds clamped,
|
|
433
|
+
// capped newest-wins), so a hostile/malformed payload can neither
|
|
434
|
+
// crash the capture nor persist junk.
|
|
435
|
+
if (Array.isArray(args.validations)) {
|
|
436
|
+
for (const v of args.validations)
|
|
437
|
+
builder.validation(v);
|
|
438
|
+
}
|
|
307
439
|
// End state: an explicit stoppedReason wins; otherwise derive it from
|
|
308
440
|
// the trend. A run that settled closes as "converged"; a run that did
|
|
309
441
|
// real work but never trended to 0 closes as "max_rounds_reached";
|
|
@@ -364,7 +496,7 @@ function rehydrateBuilder(manifest, approval) {
|
|
|
364
496
|
});
|
|
365
497
|
}
|
|
366
498
|
}
|
|
367
|
-
for (let idx = 0; idx < (manifest.convergence ?? []).length; idx += 1) {
|
|
499
|
+
for (let idx = 0; idx < Math.min((manifest.convergence ?? []).length, MAX_ROUNDS); idx += 1) {
|
|
368
500
|
const n = manifest.convergence[idx];
|
|
369
501
|
if (typeof n === 'number' && n >= 0)
|
|
370
502
|
builder.snapshotConvergence(idx + 1, n);
|
|
@@ -374,6 +506,11 @@ function rehydrateBuilder(manifest, approval) {
|
|
|
374
506
|
if (Array.isArray(manifest.fixes))
|
|
375
507
|
for (const fx of manifest.fixes)
|
|
376
508
|
builder.fix(fx);
|
|
509
|
+
// Validation rows survive a nudge-edit re-persist (same defensive
|
|
510
|
+
// normalization as capture — a hand-edited manifest is re-clamped here).
|
|
511
|
+
if (Array.isArray(manifest.validations))
|
|
512
|
+
for (const v of manifest.validations)
|
|
513
|
+
builder.validation(v);
|
|
377
514
|
if (Array.isArray(manifest.timeline))
|
|
378
515
|
for (const e of manifest.timeline)
|
|
379
516
|
builder.decision(e);
|