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