iterate-plugin 2.10.0 → 2.12.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 +42 -2
- package/README.zh-CN.md +40 -2
- package/dist/approval-gate.js +92 -0
- package/dist/config-loader.js +18 -3
- package/dist/config-write.js +7 -4
- package/dist/evidence.js +67 -1
- package/dist/git-scope.js +35 -6
- package/dist/index.js +15 -5
- package/dist/live.js +155 -0
- package/dist/meta-review.js +19 -5
- package/dist/method-scope.js +5 -1
- package/dist/paths.js +4 -0
- package/dist/review-scope.js +12 -8
- package/dist/review.js +76 -24
- package/dist/session-hooks.js +89 -0
- package/dist/skill-prompt.js +101 -19
- package/dist/tools/checkpoint.js +10 -3
- package/dist/tools/context.js +16 -4
- package/dist/tools/decision-log.js +29 -9
- package/dist/tools/fix.js +120 -3
- package/dist/tools/prune.js +16 -9
- package/dist/tools/review.js +4 -1
- package/dist/tools/transcript.js +324 -0
- package/dist/tools/triage.js +9 -6
- package/dist/tools/validate.js +5 -2
- package/dist/transcript.js +421 -0
- package/lib/client.js +966 -80
- package/lib/parse.js +302 -17
- package/package.json +1 -1
- package/src/approval-gate.ts +119 -0
- package/src/client/index.ts +807 -62
- package/src/config-loader.ts +16 -2
- package/src/config-write.ts +6 -4
- package/src/evidence.ts +69 -1
- package/src/git-scope.ts +34 -6
- package/src/index.ts +17 -6
- package/src/live.ts +185 -0
- package/src/meta-review.ts +24 -10
- package/src/method-scope.ts +5 -1
- package/src/paths.ts +5 -0
- package/src/review-scope.ts +11 -7
- package/src/review.ts +82 -25
- package/src/session-hooks.ts +90 -0
- package/src/skill-prompt.ts +101 -19
- package/src/tools/checkpoint.ts +10 -3
- package/src/tools/context.ts +14 -3
- package/src/tools/decision-log.ts +27 -10
- package/src/tools/fix.ts +114 -3
- package/src/tools/prune.ts +14 -11
- package/src/tools/review.ts +5 -2
- package/src/tools/transcript.ts +334 -0
- package/src/tools/triage.ts +9 -6
- package/src/tools/validate.ts +5 -2
- package/src/transcript.ts +475 -0
- package/src/types.ts +129 -0
package/src/review.ts
CHANGED
|
@@ -44,9 +44,11 @@ export function sortFindings(findings: ReviewFinding[]): ReviewFinding[] {
|
|
|
44
44
|
const rankB = SEVERITY_RANK[b.severity] ?? SEVERITY_RANK.low
|
|
45
45
|
const bySeverity = rankA - rankB
|
|
46
46
|
if (bySeverity !== 0) return bySeverity
|
|
47
|
-
|
|
47
|
+
// Defensive coercion: `file`/`line` can be wrong-typed when schema
|
|
48
|
+
// validation is disabled — String()/Number() keep the comparator total.
|
|
49
|
+
const byFile = String(a.file ?? '').localeCompare(String(b.file ?? ''))
|
|
48
50
|
if (byFile !== 0) return byFile
|
|
49
|
-
return (a.line
|
|
51
|
+
return (Number(a.line) || 0) - (Number(b.line) || 0)
|
|
50
52
|
})
|
|
51
53
|
}
|
|
52
54
|
|
|
@@ -58,9 +60,15 @@ export function normalizeSummary(summary: string): string {
|
|
|
58
60
|
.replace(/[\s\n\t]+/g, ' ')
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Dedupe key: same file + same dimension + similar summary + explicit line.
|
|
65
|
+
* Including the line keeps two genuine issues with identical wording at
|
|
66
|
+
* different locations from collapsing into one (the line is omitted only when
|
|
67
|
+
* neither side anchors one, i.e. whole-file findings).
|
|
68
|
+
*/
|
|
62
69
|
export function findingKey(f: ReviewFinding): string {
|
|
63
|
-
|
|
70
|
+
const line = typeof f.line === 'number' && f.line > 0 ? f.line : 0
|
|
71
|
+
return `${f.file}|${f.dimension}|${line}|${normalizeSummary(f.summary)}`
|
|
64
72
|
}
|
|
65
73
|
|
|
66
74
|
/**
|
|
@@ -129,12 +137,19 @@ export function aggregateRounds(
|
|
|
129
137
|
const merged: ReviewFinding[] = []
|
|
130
138
|
|
|
131
139
|
// Guard: round numbers are expected to be positive integers. Skip malformed
|
|
132
|
-
// entries defensively rather than letting `firstRoundByKey` key on NaN/0
|
|
140
|
+
// entries defensively rather than letting `firstRoundByKey` key on NaN/0 or
|
|
141
|
+
// crashing on null / non-array findings.
|
|
142
|
+
// Hard ceiling: round numbers are model-authored JSON; an absurd round (e.g.
|
|
143
|
+
// 1e9) would otherwise allocate an array of that size below (OOM). Round
|
|
144
|
+
// numbers above the configured cap are clamped to the cap.
|
|
133
145
|
let maxRound = 0
|
|
146
|
+
const roundCap = Math.max(1, maxReviewRounds)
|
|
134
147
|
for (const round of rounds) {
|
|
148
|
+
if (!round || typeof round !== 'object') continue
|
|
135
149
|
if (typeof round.round !== 'number' || !Number.isInteger(round.round) || round.round < 1) continue
|
|
150
|
+
const findings = Array.isArray(round.findings) ? round.findings : []
|
|
136
151
|
if (round.round > maxRound) maxRound = round.round
|
|
137
|
-
for (const f of
|
|
152
|
+
for (const f of findings) {
|
|
138
153
|
const key = findingKey(f)
|
|
139
154
|
if (seen.has(key)) continue
|
|
140
155
|
seen.add(key)
|
|
@@ -142,9 +157,11 @@ export function aggregateRounds(
|
|
|
142
157
|
merged.push(f)
|
|
143
158
|
}
|
|
144
159
|
}
|
|
160
|
+
// Clamp the allocation bound so a hostile round number cannot OOM the tool.
|
|
161
|
+
const effectiveMax = Math.min(maxRound, Math.max(1, roundCap * 2))
|
|
145
162
|
|
|
146
163
|
const findingsByRound: number[] = []
|
|
147
|
-
for (let r = 1; r <=
|
|
164
|
+
for (let r = 1; r <= effectiveMax; r++) {
|
|
148
165
|
let count = 0
|
|
149
166
|
for (const key of firstRoundByKey.keys()) {
|
|
150
167
|
if (firstRoundByKey.get(key) === r) count++
|
|
@@ -165,12 +182,18 @@ export function computeConvergence(
|
|
|
165
182
|
const { findingsByRound } = aggregateRounds(rounds, maxReviewRounds)
|
|
166
183
|
const totalRounds = rounds.length
|
|
167
184
|
// `findingsByRound` is indexed by the actual round number (round r → index
|
|
168
|
-
// r-1),
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
185
|
+
// r-1), sized to the highest present round (clamped). Convergence must read
|
|
186
|
+
// the HIGHEST PRESENT round's count — not the last array element (rounds
|
|
187
|
+
// may arrive unsorted) and not `totalRounds - 1` (only valid for contiguous
|
|
188
|
+
// 1..N). The count index is bounded by the array length aggregateRounds
|
|
189
|
+
// actually allocated.
|
|
190
|
+
let lastRound = 0
|
|
191
|
+
for (const round of rounds) {
|
|
192
|
+
if (!round || typeof round.round !== 'number' || !Number.isInteger(round.round) || round.round < 1) continue
|
|
193
|
+
if (round.round > lastRound) lastRound = round.round
|
|
194
|
+
}
|
|
195
|
+
const idx = Math.min(lastRound, findingsByRound.length) - 1
|
|
196
|
+
const lastRoundCount = idx >= 0 ? (findingsByRound[idx] ?? 0) : 0
|
|
174
197
|
const converged = totalRounds > 0 && lastRoundCount === 0
|
|
175
198
|
return {
|
|
176
199
|
totalRounds,
|
|
@@ -224,8 +247,12 @@ export function buildReviewReport(input: {
|
|
|
224
247
|
}): ReviewReport {
|
|
225
248
|
// 1. Filter known-intentional per round (before cross-round dedupe).
|
|
226
249
|
const filteredRounds = input.rounds.map((r) => ({
|
|
227
|
-
round: r.round,
|
|
228
|
-
findings: filterKnownIntentional(
|
|
250
|
+
round: typeof r?.round === 'number' ? r.round : 0,
|
|
251
|
+
findings: filterKnownIntentional(
|
|
252
|
+
Array.isArray(r?.findings) ? r.findings : [],
|
|
253
|
+
input.knownIntentional,
|
|
254
|
+
),
|
|
255
|
+
readFiles: Array.isArray(r?.readFiles) ? r.readFiles : [],
|
|
229
256
|
}))
|
|
230
257
|
|
|
231
258
|
// 2. Cross-round dedupe + per-round "first seen" tracking.
|
|
@@ -262,6 +289,9 @@ export function buildReviewReport(input: {
|
|
|
262
289
|
maxReviewRounds: input.maxReviewRounds,
|
|
263
290
|
rounds: filteredRounds,
|
|
264
291
|
findings: sorted,
|
|
292
|
+
// Aggregate of every round's self-reported reads, so the meta-review
|
|
293
|
+
// coverage gate can compare against the assigned inventory.
|
|
294
|
+
readFiles: ([] as string[]).concat(...filteredRounds.map((r) => r.readFiles ?? [])),
|
|
265
295
|
convergence: {
|
|
266
296
|
totalRounds: filteredRounds.length,
|
|
267
297
|
findingsByRound,
|
|
@@ -470,8 +500,8 @@ export interface RoundSchemaValidation {
|
|
|
470
500
|
*/
|
|
471
501
|
export function validateRoundsSchema(rounds: ReviewRound[]): RoundSchemaValidation[] {
|
|
472
502
|
return rounds.map((r) => {
|
|
473
|
-
const issues = validateFindingsSchema(r.findings)
|
|
474
|
-
return { round: r.round, valid: issues.length === 0, issues }
|
|
503
|
+
const issues = validateFindingsSchema(Array.isArray(r?.findings) ? r.findings : [])
|
|
504
|
+
return { round: typeof r?.round === 'number' ? r.round : 0, valid: issues.length === 0, issues }
|
|
475
505
|
})
|
|
476
506
|
}
|
|
477
507
|
|
|
@@ -491,21 +521,27 @@ export function sanitizeRounds(
|
|
|
491
521
|
schemaValidation: RoundSchemaValidation[] | null,
|
|
492
522
|
): ReviewRound[] {
|
|
493
523
|
return rounds.map((r, i) => {
|
|
524
|
+
// Defensive: malformed rounds must never crash the deterministic core.
|
|
525
|
+
const findings = Array.isArray(r?.findings) ? r.findings : []
|
|
526
|
+
const roundNo = typeof r?.round === 'number' ? r.round : 0
|
|
527
|
+
const readFiles = Array.isArray(r?.readFiles) ? r.readFiles : []
|
|
494
528
|
if (schemaValidation) {
|
|
495
529
|
const issues = schemaValidation[i]?.issues ?? []
|
|
496
|
-
if (issues.some((iss) => iss.index === -1)) return { round:
|
|
530
|
+
if (issues.some((iss) => iss.index === -1)) return { round: roundNo, findings: [], readFiles }
|
|
497
531
|
const bad = new Set(issues.map((iss) => iss.index))
|
|
498
532
|
return {
|
|
499
|
-
round:
|
|
500
|
-
findings:
|
|
533
|
+
round: roundNo,
|
|
534
|
+
findings: findings.filter((_, fi) => !bad.has(fi)),
|
|
535
|
+
readFiles,
|
|
501
536
|
}
|
|
502
537
|
}
|
|
503
538
|
return {
|
|
504
|
-
round:
|
|
505
|
-
findings:
|
|
539
|
+
round: roundNo,
|
|
540
|
+
findings: findings.filter(
|
|
506
541
|
(f): f is ReviewFinding =>
|
|
507
542
|
Boolean(f) && typeof f === 'object' && !Array.isArray(f),
|
|
508
543
|
),
|
|
544
|
+
readFiles,
|
|
509
545
|
}
|
|
510
546
|
})
|
|
511
547
|
}
|
|
@@ -539,6 +575,12 @@ export function reviewerTaskPrompt(input: {
|
|
|
539
575
|
* `changedFiles`.
|
|
540
576
|
*/
|
|
541
577
|
scopeFiles?: string[]
|
|
578
|
+
/**
|
|
579
|
+
* Per-dimension focus guidance (from personalization.dimension_focus, or the
|
|
580
|
+
* skill's dimension definitions). Appended to the reviewer prompt so the
|
|
581
|
+
* review concentrates on the areas the user cares about.
|
|
582
|
+
*/
|
|
583
|
+
focus?: string
|
|
542
584
|
}): string {
|
|
543
585
|
const parts: string[] = []
|
|
544
586
|
parts.push(
|
|
@@ -546,6 +588,9 @@ export function reviewerTaskPrompt(input: {
|
|
|
546
588
|
`Goal: ${input.goal}`,
|
|
547
589
|
`Scope: ${input.scope === 'full' ? 'entire codebase' : 'changed files only'}.`,
|
|
548
590
|
)
|
|
591
|
+
if (input.focus) {
|
|
592
|
+
parts.push(`FOCUS: ${input.focus}`)
|
|
593
|
+
}
|
|
549
594
|
if (input.scopeFiles && input.scopeFiles.length > 0) {
|
|
550
595
|
parts.push(
|
|
551
596
|
'COVERAGE RULE (mandatory): below is the exact file inventory you are ' +
|
|
@@ -592,9 +637,9 @@ export function reviewerTaskPrompt(input: {
|
|
|
592
637
|
parts.push(
|
|
593
638
|
`Return a JSON object: {"findings": [...], "readFiles": [...]}.`,
|
|
594
639
|
`Each finding: dimension (must be "${input.dimension}"), file (relative path), ` +
|
|
595
|
-
'line (
|
|
596
|
-
'
|
|
597
|
-
'
|
|
640
|
+
'line (optional; the exact line you READ for a line-targeted issue; ' +
|
|
641
|
+
'0 or omitted for whole-file/module-level issues), ' +
|
|
642
|
+
'severity (critical/high/medium/low), summary (one line), ' +
|
|
598
643
|
'failure_scenario (how/when it fails, backed by the code you actually ' +
|
|
599
644
|
'read), suggested_fix (the concrete fix), ' +
|
|
600
645
|
`is_atomic (true if the fix is <= ${input.maxLines} lines within a SINGLE file/function, else false).`,
|
|
@@ -672,6 +717,17 @@ export function buildReviewPlan(input: {
|
|
|
672
717
|
reviewerPrompt: string
|
|
673
718
|
findingsSchema: Record<string, unknown>
|
|
674
719
|
}[] = []
|
|
720
|
+
// personalization.dimension_focus: [{dimension, focus}] — appended to the
|
|
721
|
+
// matching dimension's reviewer prompt.
|
|
722
|
+
const focusMap = new Map<string, string>()
|
|
723
|
+
const pf = input.config.personalization as { dimension_focus?: { dimension?: string; focus?: string }[] } | undefined
|
|
724
|
+
if (pf && Array.isArray(pf.dimension_focus)) {
|
|
725
|
+
for (const entry of pf.dimension_focus) {
|
|
726
|
+
if (entry && typeof entry.dimension === 'string' && typeof entry.focus === 'string' && entry.focus) {
|
|
727
|
+
focusMap.set(entry.dimension, entry.focus)
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
675
731
|
for (const d of dimensions) {
|
|
676
732
|
batches.forEach((batch, index) => {
|
|
677
733
|
const dimensionId = batches.length === 1 ? d : `${d}#${index + 1}`
|
|
@@ -687,6 +743,7 @@ export function buildReviewPlan(input: {
|
|
|
687
743
|
maxLines,
|
|
688
744
|
changedFiles: effectiveChangedOnly ? changedFiles : undefined,
|
|
689
745
|
scopeFiles: batch,
|
|
746
|
+
focus: focusMap.get(d),
|
|
690
747
|
}),
|
|
691
748
|
findingsSchema: findingsSchema(),
|
|
692
749
|
})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/session-hooks.ts — dsh pipeline hooks for the iterate observatory (F8).
|
|
3
|
+
*
|
|
4
|
+
* Wires the {@link decideApproval} policy gate to dsh's `tools/pre-execute`
|
|
5
|
+
* waterfall. This is the AUTHORITATIVE approval seam for destructive iterate
|
|
6
|
+
* tools (`iterate_fix` / `iterate_rollback` / `iterate_prune` with dryRun:false):
|
|
7
|
+
*
|
|
8
|
+
* - `allow` policy → the call runs.
|
|
9
|
+
* - `deny` policy → the call is refused (fail-closed), surfaced as an
|
|
10
|
+
* error to the model.
|
|
11
|
+
* - `ask` policy → return `{ kind: 'ask', reason }`; dsh's own
|
|
12
|
+
* scheduler routes it through the `approval` service
|
|
13
|
+
* (see `@deepseek-ai/dsh-user-approval`), which
|
|
14
|
+
* prompts the human and audits an approve/deny pair
|
|
15
|
+
* on the session.
|
|
16
|
+
*
|
|
17
|
+
* We deliberately do NOT also add `approved` flags inside the tool bodies:
|
|
18
|
+
* the pre-execute waterfall consumes the human decision before the tool runs,
|
|
19
|
+
* so a second tool-internal gate would double-ask. This one gate is enough and
|
|
20
|
+
* stays dsh-native.
|
|
21
|
+
*
|
|
22
|
+
* Safety properties:
|
|
23
|
+
* - Read-only tools and non-iterate tools are always allowed (the gate only
|
|
24
|
+
* inspects the three destructive iterate toolnames).
|
|
25
|
+
* - If the project root / observatory config cannot be resolved, the policy
|
|
26
|
+
* degrades to `ask` (fail-safe: destructive writes always require consent).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { loadEffectiveConfig, resolveProjectRoot } from './config-loader.ts'
|
|
30
|
+
import { decideApproval, isDestructiveIterateTool } from './approval-gate.ts'
|
|
31
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
32
|
+
import type { ToolExecution, PreToolDecision } from '@deepseek-ai/dsh-tools'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build the per-call approval decision for a tool execution.
|
|
36
|
+
* Returns a dsh `PreToolDecision` so the caller can short-circuit the caller.
|
|
37
|
+
*/
|
|
38
|
+
export function gateDecision(exec: ToolExecution): PreToolDecision {
|
|
39
|
+
// Importing the decision, and only inspecting our own tools, keeps unrelated
|
|
40
|
+
// tooling untouched. Anything we cannot classify is allowed by default.
|
|
41
|
+
if (!isDestructiveIterateTool(exec.name)) return { kind: 'allow' }
|
|
42
|
+
|
|
43
|
+
// Resolve the project root (use the call's own `path` arg, else the agent's
|
|
44
|
+
// session cwd) to read the effective observatory policy.
|
|
45
|
+
const argPath = typeof exec.arguments === 'object' && exec.arguments && !Array.isArray(exec.arguments)
|
|
46
|
+
&& typeof (exec.arguments as Record<string, unknown>).path === 'string'
|
|
47
|
+
? (exec.arguments as Record<string, unknown>).path as string
|
|
48
|
+
: undefined
|
|
49
|
+
const sessionCwd = exec.agent?.session?.header?.cwd
|
|
50
|
+
const resolved = resolveProjectRoot(argPath, sessionCwd)
|
|
51
|
+
let policy: 'ask' | 'deny' | 'allow' = 'ask'
|
|
52
|
+
if (resolved.ok) {
|
|
53
|
+
const { config } = loadEffectiveConfig(resolved.root)
|
|
54
|
+
const p = config.observatory?.approval
|
|
55
|
+
if (p === 'deny') policy = 'deny'
|
|
56
|
+
else if (p === 'allow') policy = 'allow'
|
|
57
|
+
// anything else (including a corrupt/missing `ask`) → 'ask'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const decision = decideApproval(exec, policy)
|
|
61
|
+
if (decision.kind === 'deny') return { kind: 'deny', reason: decision.reason }
|
|
62
|
+
if (decision.kind === 'ask') return { kind: 'ask', reason: decision.reason }
|
|
63
|
+
return { kind: 'allow' }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Register the `tools/pre-execute` waterfall listener that applies the
|
|
68
|
+
* observatory approval gate to every destructive iterate tool call.
|
|
69
|
+
*/
|
|
70
|
+
export function registerSessionHooks(ctx: Context): void {
|
|
71
|
+
ctx.on('tools/pre-execute', (exec: ToolExecution, next: () => Promise<PreToolDecision>) => {
|
|
72
|
+
// Never let a throwing gate break the pipeline — degrade to allow.
|
|
73
|
+
let decision: PreToolDecision
|
|
74
|
+
try {
|
|
75
|
+
decision = gateDecision(exec)
|
|
76
|
+
} catch {
|
|
77
|
+
return next()
|
|
78
|
+
}
|
|
79
|
+
if (decision.kind === 'ask') {
|
|
80
|
+
// Delegate the actual human-consent prompt + audit to dsh's approval
|
|
81
|
+
// service via the scheduler's `ask` path. `next()` here would short-circuit
|
|
82
|
+
// to allow, which would bypass consent — so return our ask decision.
|
|
83
|
+
return Promise.resolve(decision)
|
|
84
|
+
}
|
|
85
|
+
if (decision.kind === 'deny') {
|
|
86
|
+
return Promise.resolve(decision)
|
|
87
|
+
}
|
|
88
|
+
return next()
|
|
89
|
+
})
|
|
90
|
+
}
|
package/src/skill-prompt.ts
CHANGED
|
@@ -23,6 +23,7 @@ You have the iterate plugin installed, which registers these tools:
|
|
|
23
23
|
- \`iterate_status\` — summarize the current run: mode, round, fixes applied, architectural remaining, decision-log size, checkpoint presence, and whether the run was interrupted (a checkpoint left on disk means the previous run was interrupted and can be resumed)
|
|
24
24
|
- \`iterate_history\` — inspect the runtime state in detail: decision-log entries and applied fixes (optionally scoped to a round or a fixed file)
|
|
25
25
|
- \`iterate_prune\` — remove stale runtime artifacts (\`.iterate/\` entries). Defaults to a read-only dry-run that reports what WOULD be removed; pass \`dryRun:false\` to actually prune.
|
|
26
|
+
- \`iterate_transcript\` — runtime observatory file (\`.iterate/transcript.json\`). \`read\` fetches the persisted manifest including any steering \`nudge\` for this run's reviewers; \`capture\` (call once after the final report) persists the per-reviewer threads, convergence trend, findings, fixes, checkpoint, and timeline so the client observatory panel reflects the run; \`nudge\` sets/clears steering text the next round's reviewers read. Purely local, never touches source files.
|
|
26
27
|
|
|
27
28
|
### When to use
|
|
28
29
|
When the user asks to review or iterate on the project (e.g. "review this project", "iterate on error handling", "check the codebase for issues", "dry-run review", "反复审查"), run an iterate **workflow** by calling the \`workflow\` tool.
|
|
@@ -78,6 +79,14 @@ const knownIntentional = (plan.knownIntentional || []) // config personalizati
|
|
|
78
79
|
let known = [] // cumulative DEDUPED findings fed back to reviewers
|
|
79
80
|
const rounds = [] // raw per-round findings
|
|
80
81
|
|
|
82
|
+
phase('transcript')
|
|
83
|
+
// Read any steering nudge written (via iterate_transcript nudge) for this run's reviewers.
|
|
84
|
+
const transRead = await agent(
|
|
85
|
+
'Call iterate_transcript({operation:"read"}) and return {nudge:<transcript.nudge ? transcript.nudge.text : null>}.',
|
|
86
|
+
Object.assign({ label: 'transcript:read' }, backend)
|
|
87
|
+
)
|
|
88
|
+
const steering = transRead && typeof transRead.nudge === 'string' && transRead.nudge ? transRead.nudge : null
|
|
89
|
+
|
|
81
90
|
phase('review')
|
|
82
91
|
for (let r = 1; r <= maxRounds; r++) {
|
|
83
92
|
log('round ' + r + ' of ' + maxRounds + ' — finding NEW issues only')
|
|
@@ -89,14 +98,29 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
89
98
|
const nudge = retries > 0
|
|
90
99
|
? '\\nSTRICT JSON REQUIRED: your previous output failed schema validation. Return ONLY a JSON object {"findings":[...]} where EVERY finding has dimension, file, line (non-negative integer; 0 = whole-file), severity (critical|high|medium|low), summary, failure_scenario, suggested_fix, is_atomic (boolean).'
|
|
91
100
|
: ''
|
|
92
|
-
const raw = await parallel(dims.map(dim => () =>
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
101
|
+
const raw = await parallel(dims.map(dim => () => {
|
|
102
|
+
// Pass the plan's full per-dimension reviewerPrompt (goal, COVERAGE RULE
|
|
103
|
+
// with the assigned file inventory, EVIDENCE RULE, output language) and
|
|
104
|
+
// append the round-specific context — the reviewers must receive the
|
|
105
|
+
// file inventory or the coverage machinery has nothing to enforce.
|
|
106
|
+
const meta = plan.dimensions.find(x => x.id === dim)
|
|
107
|
+
const base = (meta && typeof meta.reviewerPrompt === 'string' && meta.reviewerPrompt)
|
|
108
|
+
? meta.reviewerPrompt
|
|
109
|
+
: 'Review dimension "' + dim + '".'
|
|
110
|
+
const extra =
|
|
111
|
+
(steering ? '\\n STEERING — read this first: ' + steering : '') +
|
|
112
|
+
(attachments.length > 0 ? '\\n User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
|
|
113
|
+
'\\n Already-known findings (do NOT re-report): ' +
|
|
114
|
+
JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.'
|
|
115
|
+
return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
|
|
116
|
+
}))
|
|
117
|
+
const thisRound = {
|
|
118
|
+
round: r,
|
|
119
|
+
findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
|
|
120
|
+
// readFiles are threaded through so the aggregate/meta-review coverage
|
|
121
|
+
// gate can compare self-reported reads against the assigned inventory.
|
|
122
|
+
readFiles: [].concat(...raw.map(x => x && Array.isArray(x.readFiles) ? x.readFiles : [])),
|
|
123
|
+
}
|
|
100
124
|
if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
|
|
101
125
|
// Deterministic aggregate: cross-round dedupe + known_intentional filter + severity sort.
|
|
102
126
|
agg = await agent(
|
|
@@ -143,6 +167,13 @@ const metaRes = await agent(
|
|
|
143
167
|
const finalReport = metaRes && metaRes.finalReport ? metaRes.finalReport : null
|
|
144
168
|
const metaAudit = finalReport && finalReport.metaReview ? finalReport.metaReview : null
|
|
145
169
|
|
|
170
|
+
// Persist the run's observatory transcript (reviewer threads, trend, findings)
|
|
171
|
+
// so the client observatory panel reflects this review. Writes ONLY .iterate/transcript.json.
|
|
172
|
+
await agent(
|
|
173
|
+
'Call iterate_transcript({operation:"capture", mode:"dry-run", goal:' + JSON.stringify(report.goal) + ', maxRounds:' + maxRounds + ', roundsExecuted:' + report.convergence.totalRounds + ', findingsByRound:' + JSON.stringify(report.convergence.findingsByRound || []) + ', rounds:' + JSON.stringify(rounds.map(rr => ({ round: rr.round, findings: rr.findings, readFiles: rr.readFiles }))) + '}). Return {operation:"ok"}.',
|
|
174
|
+
Object.assign({ label: 'transcript:capture' }, backend)
|
|
175
|
+
)
|
|
176
|
+
|
|
146
177
|
return {
|
|
147
178
|
mode: 'dry-run',
|
|
148
179
|
goal: report.goal,
|
|
@@ -167,7 +198,7 @@ Key rules for dry-run:
|
|
|
167
198
|
- Stop when a round reports 0 new findings (converged) or maxReviewRounds is reached.
|
|
168
199
|
- The report (with per-round convergence stats + suggested fix priorities) is the deliverable.
|
|
169
200
|
- **Meta-review**: after building the report, audit it with \`iterate_review({operation:"meta-review"})\` for internal consistency (counts, severity buckets, dimension sums, sort order, convergence math). The meta-review ALSO runs the hard code-evidence gate (default on): every finding's file/line is validated against real files on disk, so any fabricated location surfaces as a critical \`EVIDENCE_VIOLATION\` and flips the verdict to \`needs_revision\`. The \`finalReport.verdict\` is \`approved\` only when the report passes every check AND every finding anchors to real, read code; otherwise \`needs_revision\`. Surface the final report and its verdict as the closing deliverable.
|
|
170
|
-
- Only a single \`report\` entry may be appended to the decision log; nothing else is written.
|
|
201
|
+
- Only a single \`report\` entry may be appended to the decision log; nothing else is written to source files. The final \`iterate_transcript capture\` writes ONLY the observatory file (\`.iterate/transcript.json\`) so the client panel reflects the run — it is not a source-code write.
|
|
171
202
|
|
|
172
203
|
### Normal-mode workflow (autonomous closed loop)
|
|
173
204
|
Set \`args.mode = "normal"\`. Loop: resume → plan → parallel review ×N → atomic fixes via \`iterate_fix\` → validate → rollback on failure → checkpoint → loop → auto-stop when zero findings remain.
|
|
@@ -225,10 +256,23 @@ let fixedCount = (checkpoint && typeof checkpoint.fixedCount === 'number') ? che
|
|
|
225
256
|
let converged = false
|
|
226
257
|
let abortedByValidation = false
|
|
227
258
|
let failedCommands = []
|
|
259
|
+
const fixRecords = [] // observatory fix records collected round by round
|
|
260
|
+
|
|
261
|
+
// Read any steering nudge intended for this run's reviewers.
|
|
262
|
+
const transRead = await agent(
|
|
263
|
+
'Call iterate_transcript({operation:"read"}) and return {nudge:<transcript.nudge ? transcript.nudge.text : null>}.',
|
|
264
|
+
Object.assign({ label: 'transcript:read' }, backend)
|
|
265
|
+
)
|
|
266
|
+
const steering = transRead && typeof transRead.nudge === 'string' && transRead.nudge ? transRead.nudge : null
|
|
228
267
|
|
|
229
268
|
phase('loop')
|
|
230
269
|
for (let r = startRound; r <= maxRounds; r++) {
|
|
231
270
|
log('round ' + r + ' of ' + maxRounds + ' — review current state, fix atomics via iterate_fix, validate')
|
|
271
|
+
// Audit-trail: record the round start (SKILL.md Phase 4 requires per-round records).
|
|
272
|
+
await agent(
|
|
273
|
+
'Call iterate_decision_log({operation:"append", type:"round_start", round:' + r + ', data:{maxRounds:' + maxRounds + ', fixedSoFar:' + fixedCount + '}})',
|
|
274
|
+
Object.assign({ label: 'log:start:r' + r }, backend)
|
|
275
|
+
)
|
|
232
276
|
let agg = null
|
|
233
277
|
let schemaInvalid = false
|
|
234
278
|
let retries = 0
|
|
@@ -237,13 +281,25 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
237
281
|
const nudge = retries > 0
|
|
238
282
|
? '\\nSTRICT JSON REQUIRED: your previous output failed schema validation. Return ONLY a JSON object {"findings":[...]} where EVERY finding has dimension, file, line (non-negative integer; 0 = whole-file), severity (critical|high|medium|low), summary, failure_scenario, suggested_fix, is_atomic (boolean).'
|
|
239
283
|
: ''
|
|
240
|
-
const raw = await parallel(dims.map(dim => () =>
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
284
|
+
const raw = await parallel(dims.map(dim => () => {
|
|
285
|
+
// Pass the plan's full per-dimension reviewerPrompt (COVERAGE RULE with
|
|
286
|
+
// the assigned file inventory, EVIDENCE RULE, output language) plus the
|
|
287
|
+
// round-specific context.
|
|
288
|
+
const meta = plan.dimensions.find(x => x.id === dim)
|
|
289
|
+
const base = (meta && typeof meta.reviewerPrompt === 'string' && meta.reviewerPrompt)
|
|
290
|
+
? meta.reviewerPrompt
|
|
291
|
+
: 'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed).'
|
|
292
|
+
const extra =
|
|
293
|
+
(steering ? '\\n STEERING — read this first: ' + steering : '') +
|
|
294
|
+
(attachments.length > 0 ? '\\n User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
|
|
295
|
+
'\\n Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.'
|
|
296
|
+
return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
|
|
297
|
+
}))
|
|
298
|
+
const thisRound = {
|
|
299
|
+
round: r,
|
|
300
|
+
findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
|
|
301
|
+
readFiles: [].concat(...raw.map(x => x && Array.isArray(x.readFiles) ? x.readFiles : [])),
|
|
302
|
+
}
|
|
247
303
|
if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
|
|
248
304
|
|
|
249
305
|
// Deterministic dedupe / known_intentional filter / severity sort for this round.
|
|
@@ -279,12 +335,14 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
279
335
|
'Apply the fixes for ' + file + ' using iterate_fix. For EACH finding in this list, ' +
|
|
280
336
|
'read the current file, compute the edited full content (change <= ' + atomicMaxLines + ' lines), and call ' +
|
|
281
337
|
'iterate_fix({ file: "' + file + '", content: <full new file content>, finding: <that finding>, round: ' + r + ' }). ' +
|
|
282
|
-
'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file: "' + file + '" }) to verify the accumulated diff
|
|
283
|
-
'
|
|
338
|
+
'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file: "' + file + '" }) to verify the accumulated diff and ' +
|
|
339
|
+
'read its line statistics (lines added/removed). ' +
|
|
340
|
+
'Findings: ' + JSON.stringify(byFile[file]) + '. Return the array of {id, ok, error, file, linesAdded, linesRemoved} per iterate_fix call ' +
|
|
341
|
+
'(id/ok required; put the file-wide line stats from iterate_diff on each record, or on the last record and 0 elsewhere).',
|
|
284
342
|
Object.assign({ label: 'fix:' + file, phase: 'fix', schema: {
|
|
285
343
|
type: 'object', additionalProperties: false,
|
|
286
344
|
properties: {
|
|
287
|
-
fixes: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { id: { type: 'string' }, ok: { type: 'boolean' }, error: { type: 'string' } }, required: ['id', 'ok'] } }
|
|
345
|
+
fixes: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { id: { type: 'string' }, ok: { type: 'boolean' }, error: { type: 'string' }, file: { type: 'string' }, linesAdded: { type: 'integer' }, linesRemoved: { type: 'integer' } }, required: ['id', 'ok'] } }
|
|
288
346
|
},
|
|
289
347
|
required: ['fixes'] } }, backend)
|
|
290
348
|
)))
|
|
@@ -292,6 +350,17 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
292
350
|
if (res && Array.isArray(res.fixes)) {
|
|
293
351
|
for (const fx of res.fixes) {
|
|
294
352
|
if (fx && fx.ok === true) { fixedCount += 1; roundFixIds.push(fx.id) }
|
|
353
|
+
// Collect fix records for the observatory transcript (defensive defaults).
|
|
354
|
+
const fixFileKeys = Object.keys(byFile)
|
|
355
|
+
fixRecords.push({
|
|
356
|
+
id: fx && typeof fx.id === 'string' ? fx.id : '',
|
|
357
|
+
file: fx && typeof fx.file === 'string' ? fx.file : (fixFileKeys.length === 1 ? fixFileKeys[0] : ''),
|
|
358
|
+
round: r,
|
|
359
|
+
summary: '',
|
|
360
|
+
linesAdded: fx && typeof fx.linesAdded === 'number' ? fx.linesAdded : 0,
|
|
361
|
+
linesRemoved: fx && typeof fx.linesRemoved === 'number' ? fx.linesRemoved : 0,
|
|
362
|
+
success: !!(fx && fx.ok === true),
|
|
363
|
+
})
|
|
295
364
|
}
|
|
296
365
|
}
|
|
297
366
|
}
|
|
@@ -374,6 +443,19 @@ if (!abortedByValidation) {
|
|
|
374
443
|
{ label: 'checkpoint:clear' }
|
|
375
444
|
)
|
|
376
445
|
}
|
|
446
|
+
// Persist the run's observatory transcript (threads, trend, fixes, checkpoint)
|
|
447
|
+
// so the client observatory panel reflects the run. Writes ONLY .iterate/transcript.json.
|
|
448
|
+
const obsCheckpoint = abortedByValidation ? null : {
|
|
449
|
+
mode: 'normal',
|
|
450
|
+
round: rounds.length,
|
|
451
|
+
maxRounds: maxRounds,
|
|
452
|
+
fixedCount: fixedCount,
|
|
453
|
+
resumeCount: effectiveResumeCount,
|
|
454
|
+
}
|
|
455
|
+
await agent(
|
|
456
|
+
'Call iterate_transcript({operation:"capture", mode:"normal", goal:' + JSON.stringify(plan.goal) + ', maxRounds:' + maxRounds + ', roundsExecuted:' + rounds.length + ', findingsByRound:' + JSON.stringify(rounds.map(rr => (rr.findings && rr.findings.length) ? rr.findings.length : 0)) + ', fixes:' + JSON.stringify(fixRecords) + ', checkpoint:' + JSON.stringify(obsCheckpoint) + ', rounds:' + JSON.stringify(rounds.map(rr => ({ round: rr.round, findings: rr.findings, readFiles: rr.readFiles }))) + '}). Return {operation:"ok"}.',
|
|
457
|
+
{ label: 'transcript:capture' }
|
|
458
|
+
)
|
|
377
459
|
return {
|
|
378
460
|
mode: 'normal',
|
|
379
461
|
goal: plan.goal,
|
package/src/tools/checkpoint.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Checkpoint layout: `.iterate/checkpoint.json`.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
13
13
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
14
14
|
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
|
15
15
|
import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
@@ -107,7 +107,9 @@ export function computeStatus(input: {
|
|
|
107
107
|
totalRounds,
|
|
108
108
|
fixedCount,
|
|
109
109
|
architecturalCount,
|
|
110
|
-
|
|
110
|
+
// A checkpoint may predate the `findings` field (or be hand-edited) — a
|
|
111
|
+
// missing findings must degrade to 0, never throw.
|
|
112
|
+
findingsCount: Array.isArray(checkpoint?.findings) ? checkpoint.findings.length : 0,
|
|
111
113
|
totalDecisionLogEntries: entries.length,
|
|
112
114
|
hasCheckpoint: checkpoint !== null,
|
|
113
115
|
// A checkpoint left on disk means the previous run was interrupted before
|
|
@@ -209,7 +211,12 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
|
|
|
209
211
|
}
|
|
210
212
|
try {
|
|
211
213
|
mkdirSync(iterateDir(projectRoot), { recursive: true })
|
|
212
|
-
|
|
214
|
+
// Atomic write (temp + rename): a crash mid-write must not corrupt
|
|
215
|
+
// the checkpoint and silently lose the interruption state.
|
|
216
|
+
const cpPath = checkpointPath(projectRoot)
|
|
217
|
+
const tmpPath = `${cpPath}.tmp-${Date.now()}`
|
|
218
|
+
writeFileSync(tmpPath, JSON.stringify(checkpoint, null, 2), 'utf-8')
|
|
219
|
+
renameSync(tmpPath, cpPath)
|
|
213
220
|
} catch (err) {
|
|
214
221
|
return { operation: 'save', ok: false, error: `failed to write checkpoint: ${String(err)}` }
|
|
215
222
|
}
|
package/src/tools/context.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from 'node:fs'
|
|
1
|
+
import { readFileSync, existsSync, statSync } from 'node:fs'
|
|
2
2
|
import { join, dirname, resolve } from 'node:path'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
4
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
@@ -263,7 +263,8 @@ export function registerContextTool(ctx: { tools: { register: (def: ReturnType<t
|
|
|
263
263
|
return { found: false, error: resolved.reason, searched: [] }
|
|
264
264
|
}
|
|
265
265
|
const projectRoot = resolved.root
|
|
266
|
-
|
|
266
|
+
// Guard: `files` must be a comma-separated string (model-controlled).
|
|
267
|
+
const requested = (typeof args.files === 'string' ? args.files : '')
|
|
267
268
|
.split(',')
|
|
268
269
|
.map((s) => s.trim().toLowerCase())
|
|
269
270
|
.filter(Boolean)
|
|
@@ -295,7 +296,17 @@ export function registerContextTool(ctx: { tools: { register: (def: ReturnType<t
|
|
|
295
296
|
// are all supported.
|
|
296
297
|
const skillRoot = findSkillRoot(PLUGIN_SRC_DIR)
|
|
297
298
|
const candidates: string[] = []
|
|
298
|
-
|
|
299
|
+
// skillDir is a model-controlled path; only honor it when it is an
|
|
300
|
+
// existing directory (resolve it first) — otherwise fall through to
|
|
301
|
+
// the auto-detected root / project root.
|
|
302
|
+
if (typeof args.skillDir === 'string' && args.skillDir.trim()) {
|
|
303
|
+
try {
|
|
304
|
+
const dir = resolve(args.skillDir)
|
|
305
|
+
if (existsSync(dir) && statSync(dir).isDirectory()) candidates.push(dir)
|
|
306
|
+
} catch {
|
|
307
|
+
// unreadable/invalid skillDir — skip it
|
|
308
|
+
}
|
|
309
|
+
}
|
|
299
310
|
if (skillRoot) candidates.push(skillRoot)
|
|
300
311
|
candidates.push(projectRoot)
|
|
301
312
|
result.searched = candidates
|
|
@@ -52,12 +52,19 @@ function logPath(projectRoot: string): string {
|
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
54
|
* Append one entry to the decision log (JSONL format).
|
|
55
|
-
* Returns the entry count after appending.
|
|
55
|
+
* Returns the entry count after appending. Never throws — a disk failure is
|
|
56
|
+
* surfaced through `error` so callers (fix/prune) can report the audit-trail
|
|
57
|
+
* miss without failing the mutation they already performed.
|
|
56
58
|
*/
|
|
57
|
-
export function appendDecisionEntry(projectRoot: string, entry: DecisionLogEntry): { count: number; path: string } {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
export function appendDecisionEntry(projectRoot: string, entry: DecisionLogEntry): { count: number; path: string; error?: string } {
|
|
60
|
+
let filePath: string
|
|
61
|
+
try {
|
|
62
|
+
filePath = logPath(projectRoot)
|
|
63
|
+
const line = JSON.stringify(entry) + '\n'
|
|
64
|
+
appendFileSync(filePath, line, 'utf-8')
|
|
65
|
+
} catch (err) {
|
|
66
|
+
return { count: -1, path: join(projectRoot, LOG_DIR, LOG_FILE), error: `failed to append decision log: ${String(err)}` }
|
|
67
|
+
}
|
|
61
68
|
// Count entries
|
|
62
69
|
let count = 0
|
|
63
70
|
try {
|
|
@@ -71,19 +78,29 @@ export function appendDecisionEntry(projectRoot: string, entry: DecisionLogEntry
|
|
|
71
78
|
|
|
72
79
|
/**
|
|
73
80
|
* Read all entries from the decision log.
|
|
81
|
+
* A single corrupt line (partial write, hand-edit) is SKIPPED, not fatal —
|
|
82
|
+
* one bad line must never empty the whole history for every reader.
|
|
74
83
|
*/
|
|
75
84
|
export function readDecisionEntries(projectRoot: string): DecisionLogEntry[] {
|
|
76
85
|
const filePath = join(projectRoot, LOG_DIR, LOG_FILE)
|
|
77
86
|
if (!existsSync(filePath)) return []
|
|
87
|
+
let content: string
|
|
78
88
|
try {
|
|
79
|
-
|
|
80
|
-
return content
|
|
81
|
-
.split('\n')
|
|
82
|
-
.filter((l) => l.trim().length > 0)
|
|
83
|
-
.map((l) => JSON.parse(l) as DecisionLogEntry)
|
|
89
|
+
content = readFileSync(filePath, 'utf-8')
|
|
84
90
|
} catch {
|
|
85
91
|
return []
|
|
86
92
|
}
|
|
93
|
+
const out: DecisionLogEntry[] = []
|
|
94
|
+
for (const line of content.split('\n')) {
|
|
95
|
+
const trimmed = line.trim()
|
|
96
|
+
if (trimmed.length === 0) continue
|
|
97
|
+
try {
|
|
98
|
+
out.push(JSON.parse(trimmed) as DecisionLogEntry)
|
|
99
|
+
} catch {
|
|
100
|
+
// skip the corrupt line, keep the rest
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out
|
|
87
104
|
}
|
|
88
105
|
|
|
89
106
|
/**
|