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/tools/fix.ts
CHANGED
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
* - Atomicity is enforced against `config.atomic.max_lines` unless `force`.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
21
|
-
import { join } from 'node:path'
|
|
20
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
|
|
21
|
+
import { join, sep } from 'node:path'
|
|
22
22
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
23
23
|
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
|
24
24
|
import { loadEffectiveConfig, resolveProjectRootForExec } from '../config-loader.ts'
|
|
@@ -123,6 +123,11 @@ export function readRegistry(projectRoot: string): FixRegistry {
|
|
|
123
123
|
try {
|
|
124
124
|
const parsed = JSON.parse(readFileSync(file, 'utf-8')) as FixRegistry
|
|
125
125
|
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.rounds)) return emptyRegistry()
|
|
126
|
+
// Defensive: a hand-edited or partially-written registry may contain a
|
|
127
|
+
// round without a `records` array — normalize instead of crashing readers.
|
|
128
|
+
parsed.rounds = parsed.rounds.filter(
|
|
129
|
+
(r) => r && typeof r === 'object' && Array.isArray(r.records),
|
|
130
|
+
)
|
|
126
131
|
return parsed
|
|
127
132
|
} catch {
|
|
128
133
|
return emptyRegistry()
|
|
@@ -198,11 +203,56 @@ export function resolveProjectFile(projectRoot: string, file: string): { ok: tru
|
|
|
198
203
|
if (resolved === projectRoot || !resolved.startsWith(projectRoot + '/') && !resolved.startsWith(projectRoot + '\\')) {
|
|
199
204
|
return { ok: false, reason: 'file resolves outside the project root' }
|
|
200
205
|
}
|
|
206
|
+
// Symlink containment: the lexical prefix check above does not resolve
|
|
207
|
+
// symlinks. If the target exists, verify its REAL path stays inside the REAL
|
|
208
|
+
// project root so a symlinked directory/file inside the repo can never route
|
|
209
|
+
// a fix (write/rollback/diff) outside the project.
|
|
210
|
+
if (existsSync(resolved)) {
|
|
211
|
+
let rootReal: string
|
|
212
|
+
let real: string
|
|
213
|
+
try {
|
|
214
|
+
rootReal = realpathSync(projectRoot)
|
|
215
|
+
real = realpathSync(resolved)
|
|
216
|
+
} catch {
|
|
217
|
+
return { ok: false, reason: 'failed to resolve real path for containment check' }
|
|
218
|
+
}
|
|
219
|
+
const rootPrefix = rootReal.endsWith(sep) ? rootReal : rootReal + sep
|
|
220
|
+
if (real !== rootReal && !real.startsWith(rootPrefix)) {
|
|
221
|
+
return { ok: false, reason: 'file resolves outside the project root (symlink escape)' }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
201
224
|
return { ok: true, resolved }
|
|
202
225
|
}
|
|
203
226
|
|
|
204
227
|
// ─── Shared execute helpers ──────────────────────────────────────────────────
|
|
205
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Minimal glob matcher for personalization.protected_paths.
|
|
231
|
+
* Supports `*` (any run of chars within one segment) and `**` (any chars,
|
|
232
|
+
* including separators). All other characters are literal. Pure, unit-testable.
|
|
233
|
+
*/
|
|
234
|
+
export function globMatch(path: string, pattern: string): boolean {
|
|
235
|
+
if (typeof path !== 'string' || typeof pattern !== 'string') return false
|
|
236
|
+
// Escape regex specials except our two wildcards.
|
|
237
|
+
let re = ''
|
|
238
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
239
|
+
const ch = pattern[i] as string
|
|
240
|
+
if (ch === '*') {
|
|
241
|
+
const isDouble = pattern[i + 1] === '*'
|
|
242
|
+
if (isDouble) { re += '[\\s\\S]*'; i++ } else { re += '[^/\\\\]*' }
|
|
243
|
+
} else if ('.[]{}()+-^$|?'.includes(ch)) {
|
|
244
|
+
re += '\\' + ch
|
|
245
|
+
} else {
|
|
246
|
+
re += ch
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
return new RegExp('^' + re + '$').test(path)
|
|
251
|
+
} catch {
|
|
252
|
+
return false
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
206
256
|
/** Read the current content of a file under the project root. */
|
|
207
257
|
function readProjectFile(projectRoot: string, file: string): { ok: true; content: string } | { ok: false; reason: string } {
|
|
208
258
|
const resolved = resolveProjectFile(projectRoot, file)
|
|
@@ -314,6 +364,28 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
314
364
|
if (typeof finding.dimension !== 'string' || finding.dimension.trim().length === 0) {
|
|
315
365
|
return { ok: false, error: 'finding.dimension must be a non-empty string' }
|
|
316
366
|
}
|
|
367
|
+
// The finding must reference the file being fixed — the fix id and the
|
|
368
|
+
// rollback/diff target are derived from finding.file, so a mismatch
|
|
369
|
+
// would back up/restore the WRONG file.
|
|
370
|
+
if (finding.file !== file) {
|
|
371
|
+
return { ok: false, error: `finding.file ("${finding.file}") must match the file being fixed ("${file}")` }
|
|
372
|
+
}
|
|
373
|
+
// Full finding validation, mirroring the review schema: malformed
|
|
374
|
+
// findings would produce lossy registry/log entries and a degraded id.
|
|
375
|
+
const SEVERITY_SET = new Set(['critical', 'high', 'medium', 'low'])
|
|
376
|
+
if (!SEVERITY_SET.has(finding.severity)) {
|
|
377
|
+
return { ok: false, error: 'finding.severity must be one of critical/high/medium/low' }
|
|
378
|
+
}
|
|
379
|
+
if (typeof finding.summary !== 'string' || finding.summary.trim().length === 0) {
|
|
380
|
+
return { ok: false, error: 'finding.summary must be a non-empty string' }
|
|
381
|
+
}
|
|
382
|
+
if (typeof finding.is_atomic !== 'boolean') {
|
|
383
|
+
return { ok: false, error: 'finding.is_atomic must be a boolean' }
|
|
384
|
+
}
|
|
385
|
+
if (finding.line !== undefined && finding.line !== null &&
|
|
386
|
+
(typeof finding.line !== 'number' || !Number.isInteger(finding.line) || finding.line < 0)) {
|
|
387
|
+
return { ok: false, error: 'finding.line must be a non-negative integer (0 = whole-file)' }
|
|
388
|
+
}
|
|
317
389
|
|
|
318
390
|
const current = readProjectFile(projectRoot, file)
|
|
319
391
|
if (!current.ok) return { ok: false, error: current.reason }
|
|
@@ -346,6 +418,30 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
346
418
|
const target = resolveProjectFile(projectRoot, file)
|
|
347
419
|
if (!target.ok) return { ok: false, error: target.reason }
|
|
348
420
|
|
|
421
|
+
// Personalization guards (SKILL.md Phase 2): protected_paths veto the
|
|
422
|
+
// fix outright; forbidden_fixes veto fix approaches appearing in the
|
|
423
|
+
// new content. Both are security-relevant, so they are enforced here
|
|
424
|
+
// in the tool, not left to the model.
|
|
425
|
+
const pers = config.personalization as
|
|
426
|
+
| { protected_paths?: unknown; forbidden_fixes?: unknown }
|
|
427
|
+
| undefined
|
|
428
|
+
const protectedPaths = Array.isArray(pers?.protected_paths)
|
|
429
|
+
? (pers.protected_paths as unknown[]).filter((p): p is string => typeof p === 'string' && p.length > 0)
|
|
430
|
+
: []
|
|
431
|
+
for (const pattern of protectedPaths) {
|
|
432
|
+
if (globMatch(file, pattern)) {
|
|
433
|
+
return { ok: false, error: `skipped: ${file} matches protected path "${pattern}" (personalization.protected_paths forbids modifying it)` }
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
const forbiddenFixes = Array.isArray(pers?.forbidden_fixes)
|
|
437
|
+
? (pers.forbidden_fixes as unknown[]).filter((f): f is string => typeof f === 'string' && f.length > 0)
|
|
438
|
+
: []
|
|
439
|
+
for (const forbidden of forbiddenFixes) {
|
|
440
|
+
if (args.content.includes(forbidden)) {
|
|
441
|
+
return { ok: false, error: `fix uses a forbidden approach: "${forbidden}" appears in the new content (personalization.forbidden_fixes)` }
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
349
445
|
const timestamp = new Date().toISOString()
|
|
350
446
|
const backupPath = fixBackupPath(projectRoot, id, timestamp)
|
|
351
447
|
try {
|
|
@@ -376,7 +472,19 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
376
472
|
try {
|
|
377
473
|
writeFileSync(fixRegistryPath(projectRoot), JSON.stringify(nextRegistry, null, 2), 'utf-8')
|
|
378
474
|
} catch (err) {
|
|
379
|
-
|
|
475
|
+
// Registry write failed → the file was already modified but no record
|
|
476
|
+
// exists, so a later rollback/diff could never see it and a retry would
|
|
477
|
+
// back up the already-fixed content as "original". Restore the file
|
|
478
|
+
// from the backup to leave the tree exactly as it was.
|
|
479
|
+
try {
|
|
480
|
+
copyFileSync(backupPath, target.resolved)
|
|
481
|
+
} catch (restoreErr) {
|
|
482
|
+
return {
|
|
483
|
+
ok: false,
|
|
484
|
+
error: `failed to write fix registry: ${String(err)}; additionally failed to restore ${file} from backup: ${String(restoreErr)}`,
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return { ok: false, error: `failed to write fix registry: ${String(err)} (file restored from backup)` }
|
|
380
488
|
}
|
|
381
489
|
|
|
382
490
|
appendDecisionEntry(projectRoot, {
|
|
@@ -485,6 +593,9 @@ export function registerDiffTool(ctx: { tools: { register: (def: ReturnType<type
|
|
|
485
593
|
if (existing) {
|
|
486
594
|
existing.linesAdded += r.linesAdded
|
|
487
595
|
existing.linesRemoved += r.linesRemoved
|
|
596
|
+
// Recompute the summary from the summed counts so a multi-fix
|
|
597
|
+
// file's text does not contradict its accumulated numbers.
|
|
598
|
+
existing.diffSummary = `+${existing.linesAdded}/-${existing.linesRemoved} lines`
|
|
488
599
|
} else {
|
|
489
600
|
files.push({
|
|
490
601
|
file: r.finding.file,
|
package/src/tools/prune.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* - Each deletion is logged to the decision log (when not dry-run).
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { existsSync, readdirSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
21
|
+
import { existsSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
22
22
|
import { join } from 'node:path'
|
|
23
23
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
24
24
|
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
|
@@ -134,17 +134,17 @@ export function executePrune(
|
|
|
134
134
|
errors: [] as string[],
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
// 1. Rewrite the decision log, keeping only recent entries.
|
|
137
|
+
// 1. Rewrite the decision log, keeping only recent entries. Atomic
|
|
138
|
+
// (temp + rename) so a crash mid-write can never truncate the log.
|
|
138
139
|
try {
|
|
139
140
|
const entries = readDecisionEntries(projectRoot)
|
|
140
141
|
const kept = entries.filter((e) => e.timestamp >= cutoff)
|
|
141
142
|
result.deletedLogEntries = entries.length - kept.length
|
|
142
143
|
if (result.deletedLogEntries > 0) {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
)
|
|
144
|
+
const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl')
|
|
145
|
+
const tmpPath = `${logPath}.tmp-${Date.now()}`
|
|
146
|
+
writeFileSync(tmpPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf-8')
|
|
147
|
+
renameSync(tmpPath, logPath)
|
|
148
148
|
}
|
|
149
149
|
} catch (err) {
|
|
150
150
|
result.errors.push(`failed to rewrite decision log: ${String(err)}`)
|
|
@@ -175,10 +175,13 @@ export function executePrune(
|
|
|
175
175
|
if (report.emptyRounds.length > 0) {
|
|
176
176
|
try {
|
|
177
177
|
let registry = readRegistry(projectRoot)
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
178
|
+
const emptyRoundNos = new Set(report.emptyRounds)
|
|
179
|
+
// Drop whole empty rounds (records.length === 0) instead of only
|
|
180
|
+
// removing their records — an empty round has no records to remove, so
|
|
181
|
+
// the old loop was a no-op that still reported trimmedEmptyRounds.
|
|
182
|
+
registry = {
|
|
183
|
+
...registry,
|
|
184
|
+
rounds: registry.rounds.filter((r) => !emptyRoundNos.has(r.round) || (r.records?.length ?? 0) > 0),
|
|
182
185
|
}
|
|
183
186
|
registry = recomputeRoundCounts(registry)
|
|
184
187
|
writeFileSync(fixRegistryPath(projectRoot), JSON.stringify(registry, null, 2), 'utf-8')
|
package/src/tools/review.ts
CHANGED
|
@@ -170,9 +170,12 @@ export function registerReviewTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
170
170
|
const rawRounds = Array.isArray(args.rounds) ? args.rounds : []
|
|
171
171
|
const rounds: ReviewRound[] = rawRounds
|
|
172
172
|
.map((r) => {
|
|
173
|
-
const rr = r as { round?: number; findings?: unknown }
|
|
173
|
+
const rr = r as { round?: number; findings?: unknown; readFiles?: unknown }
|
|
174
174
|
const findings = Array.isArray(rr?.findings) ? (rr.findings as ReviewFinding[]) : []
|
|
175
|
-
|
|
175
|
+
const readFiles = Array.isArray(rr?.readFiles)
|
|
176
|
+
? (rr.readFiles as unknown[]).filter((f): f is string => typeof f === 'string')
|
|
177
|
+
: []
|
|
178
|
+
return { round: typeof rr?.round === 'number' ? rr.round : 0, findings, readFiles }
|
|
176
179
|
})
|
|
177
180
|
.filter((r: ReviewRound) => r.round > 0)
|
|
178
181
|
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/tools/transcript.ts — `iterate_transcript` tool.
|
|
3
|
+
*
|
|
4
|
+
* Exposes the runtime-observatory manifest to the model (and, via its persisted
|
|
5
|
+
* on-disk copy, to the client observatory panel). Purely local, deterministic,
|
|
6
|
+
* and safe:
|
|
7
|
+
*
|
|
8
|
+
* - `read` — return the persisted transcript manifest (or a structured
|
|
9
|
+
* "not found" empty view). Used each round by the workflow to
|
|
10
|
+
* pick up steering nudges, and polled by tool-reading agents.
|
|
11
|
+
* - `capture` — build a fresh transcript from the review `rounds` + `report`
|
|
12
|
+
* and persist it. Called by the canonical scripts after the
|
|
13
|
+
* final aggregate so the client always sees the latest run.
|
|
14
|
+
* - `nudge` — set (`text`) or clear (`text: null`) steering text persisted
|
|
15
|
+
* for the next round's reviewers to read.
|
|
16
|
+
*
|
|
17
|
+
* All writes are persisted to `.iterate/transcript.json` via an atomic
|
|
18
|
+
* tmp+rename so a crashed writer never leaves a corrupt manifest.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
22
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
23
|
+
import { existsSync } from 'node:fs'
|
|
24
|
+
import { dirname } from 'node:path'
|
|
25
|
+
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
|
26
|
+
import {
|
|
27
|
+
loadEffectiveConfig,
|
|
28
|
+
resolveProjectRootForExec,
|
|
29
|
+
} from '../config-loader.ts'
|
|
30
|
+
import { transcriptPath } from '../paths.ts'
|
|
31
|
+
import { ReviewTranscriptBuilder } from '../transcript.ts'
|
|
32
|
+
import { readLive } from '../live.ts'
|
|
33
|
+
import type {
|
|
34
|
+
TranscriptManifest,
|
|
35
|
+
TranscriptFix,
|
|
36
|
+
} from '../types.ts'
|
|
37
|
+
|
|
38
|
+
/** Build per-dimension threads for one round from its (dimension-tagged) findings. */
|
|
39
|
+
function captureRound(builder: ReviewTranscriptBuilder, round: unknown): void {
|
|
40
|
+
if (!round || typeof round !== 'object') return
|
|
41
|
+
const r = round as {
|
|
42
|
+
round?: unknown
|
|
43
|
+
findings?: unknown
|
|
44
|
+
readFiles?: unknown
|
|
45
|
+
}
|
|
46
|
+
const roundNo = typeof r.round === 'number' ? Math.floor(r.round) : 0
|
|
47
|
+
if (roundNo <= 0) return
|
|
48
|
+
builder.roundStart(roundNo)
|
|
49
|
+
const findings = Array.isArray(r.findings) ? r.findings : []
|
|
50
|
+
const readFiles = Array.isArray(r.readFiles) ? r.readFiles : []
|
|
51
|
+
// Group the round's findings by dimension → one reviewer thread each.
|
|
52
|
+
const byDim = new Map<string, unknown[]>()
|
|
53
|
+
for (const f of findings) {
|
|
54
|
+
if (!f || typeof f !== 'object') continue
|
|
55
|
+
const rec = f as Record<string, unknown>
|
|
56
|
+
const dim = typeof rec.dimension === 'string' && rec.dimension ? rec.dimension : 'review'
|
|
57
|
+
const list = byDim.get(dim) ?? []
|
|
58
|
+
list.push(f)
|
|
59
|
+
byDim.set(dim, list)
|
|
60
|
+
}
|
|
61
|
+
if (byDim.size === 0) {
|
|
62
|
+
builder.reviewerSnapshot('review', [], readFiles)
|
|
63
|
+
} else {
|
|
64
|
+
for (const [dim, list] of byDim) builder.reviewerSnapshot(dim, list, readFiles)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Normalize the checkpoint shape if present. */
|
|
69
|
+
function normalizeCheckpoint(input: unknown): TranscriptManifest['checkpoint'] {
|
|
70
|
+
if (!input || typeof input !== 'object') return null
|
|
71
|
+
const c = input as Record<string, unknown>
|
|
72
|
+
const round = typeof c.round === 'number' ? c.round : 0
|
|
73
|
+
if (round <= 0) return null
|
|
74
|
+
return {
|
|
75
|
+
mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : 'normal',
|
|
76
|
+
round,
|
|
77
|
+
maxRounds: typeof c.maxRounds === 'number' ? c.maxRounds : 0,
|
|
78
|
+
fixedCount: typeof c.fixedCount === 'number' ? c.fixedCount : 0,
|
|
79
|
+
resumeCount: typeof c.resumeCount === 'number' ? c.resumeCount : 0,
|
|
80
|
+
updatedAt: typeof c.updatedAt === 'string' ? c.updatedAt : new Date().toISOString(),
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Normalize a fix record. */
|
|
85
|
+
function normalizeFix(input: unknown): TranscriptFix | null {
|
|
86
|
+
if (!input || typeof input !== 'object') return null
|
|
87
|
+
const f = input as Record<string, unknown>
|
|
88
|
+
const id = typeof f.id === 'string' ? f.id : ''
|
|
89
|
+
const file = typeof f.file === 'string' ? f.file : ''
|
|
90
|
+
if (!id || !file) return null
|
|
91
|
+
return {
|
|
92
|
+
id,
|
|
93
|
+
timestamp: typeof f.timestamp === 'string' ? f.timestamp : new Date().toISOString(),
|
|
94
|
+
round: typeof f.round === 'number' ? f.round : 0,
|
|
95
|
+
file,
|
|
96
|
+
summary: typeof f.summary === 'string' ? f.summary : '',
|
|
97
|
+
linesAdded: typeof f.linesAdded === 'number' ? f.linesAdded : 0,
|
|
98
|
+
linesRemoved: typeof f.linesRemoved === 'number' ? f.linesRemoved : 0,
|
|
99
|
+
success: f.success !== false,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Register the `iterate_transcript` tool. */
|
|
104
|
+
export function registerTranscriptTool(ctx: {
|
|
105
|
+
tools: { register: (def: ReturnType<typeof defineTool>) => void }
|
|
106
|
+
}): void {
|
|
107
|
+
ctx.tools.register(
|
|
108
|
+
defineTool({
|
|
109
|
+
name: 'iterate_transcript',
|
|
110
|
+
description:
|
|
111
|
+
'Runtime-observatory transcript for the iterate workflow. ' +
|
|
112
|
+
'`read` returns the current persisted transcript manifest (per-reviewer threads, ' +
|
|
113
|
+
'convergence series, findings, fixes, checkpoint, timeline, and any steering nudge ' +
|
|
114
|
+
'written for the next round). ' +
|
|
115
|
+
'`capture` builds a fresh transcript from the review `rounds` + `report` and persists it ' +
|
|
116
|
+
'(call once after the final aggregate so the UI reflects the run). ' +
|
|
117
|
+
'`nudge` sets (text) or clears (text:null) steering text the next round\'s reviewers read. ' +
|
|
118
|
+
'Purely local and deterministic — never touches source files.',
|
|
119
|
+
parameters: {
|
|
120
|
+
operation: {
|
|
121
|
+
type: 'string',
|
|
122
|
+
required: true,
|
|
123
|
+
description: '"read" to fetch the manifest, "capture" to persist one, "nudge" to set steering text.',
|
|
124
|
+
enum: ['read', 'capture', 'nudge'],
|
|
125
|
+
},
|
|
126
|
+
rounds: {
|
|
127
|
+
type: 'json',
|
|
128
|
+
description: 'For `capture`: per-round findings, each [{round, findings:[{dimension,file,line?,severity,summary,…}], readFiles:[…]}].',
|
|
129
|
+
},
|
|
130
|
+
report: {
|
|
131
|
+
type: 'json',
|
|
132
|
+
description: 'For `capture`: the ReviewReport (convergence.findingsByRound used for the trend).',
|
|
133
|
+
},
|
|
134
|
+
mode: {
|
|
135
|
+
type: 'string',
|
|
136
|
+
description: 'For `capture`: run mode ("dry-run" | "normal"). Default dry-run.',
|
|
137
|
+
enum: ['dry-run', 'normal'],
|
|
138
|
+
},
|
|
139
|
+
goal: { type: 'string', description: 'For `capture`: run goal.' },
|
|
140
|
+
maxRounds: { type: 'integer', description: 'For `capture`: round cap.' },
|
|
141
|
+
roundsExecuted: { type: 'integer', description: 'For `capture`: number of rounds actually executed.' },
|
|
142
|
+
findingsByRound: { type: 'json', description: 'For `capture`: the per-round new-findings count series (report.convergence.findingsByRound). Preferred over passing the whole report.' },
|
|
143
|
+
checkpoint: { type: 'json', description: 'For `capture`: checkpoint summary (optional).' },
|
|
144
|
+
fixes: {
|
|
145
|
+
type: 'json',
|
|
146
|
+
description: 'For `capture`: array of applied fixes [{id, file, round, summary, linesAdded, linesRemoved, success}].',
|
|
147
|
+
},
|
|
148
|
+
refReadFiles: { type: 'json', description: 'For `capture`: flat array of all read files across rounds (optional).' },
|
|
149
|
+
text: { type: 'string', description: 'For `nudge`: steering text to set (or null to clear).' },
|
|
150
|
+
path: { type: 'string', description: 'Project root directory (default: current working directory).' },
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
output: {
|
|
154
|
+
schema: {
|
|
155
|
+
type: 'object',
|
|
156
|
+
additionalProperties: false,
|
|
157
|
+
properties: {
|
|
158
|
+
operation: { type: 'string', required: true },
|
|
159
|
+
found: { type: 'boolean' },
|
|
160
|
+
transcript: { type: 'json' },
|
|
161
|
+
live: { type: 'json', description: 'Recent live reviewer-activity entries (newest first).' },
|
|
162
|
+
updated: { type: 'boolean' },
|
|
163
|
+
error: { type: 'string' },
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
async execute(args, exec) {
|
|
170
|
+
const resolved = resolveProjectRootForExec(exec, args.path)
|
|
171
|
+
if (!resolved.ok) return { operation: args.operation, error: resolved.reason }
|
|
172
|
+
const projectRoot = resolved.root
|
|
173
|
+
const file = transcriptPath(projectRoot)
|
|
174
|
+
const { config } = loadEffectiveConfig(projectRoot)
|
|
175
|
+
const approval = config.observatory?.approval ?? 'ask'
|
|
176
|
+
|
|
177
|
+
if (args.operation === 'read') {
|
|
178
|
+
const live = await readLive(projectRoot)
|
|
179
|
+
if (!existsSync(file)) {
|
|
180
|
+
return {
|
|
181
|
+
operation: 'read',
|
|
182
|
+
found: false,
|
|
183
|
+
live: live as unknown as JsonValue,
|
|
184
|
+
transcript: new ReviewTranscriptBuilder({
|
|
185
|
+
project: projectRoot,
|
|
186
|
+
approval,
|
|
187
|
+
}).serialize() as unknown as JsonValue,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
const raw = await readFile(file, 'utf-8')
|
|
192
|
+
const parsed = JSON.parse(raw) as unknown as TranscriptManifest
|
|
193
|
+
return {
|
|
194
|
+
operation: 'read',
|
|
195
|
+
found: true,
|
|
196
|
+
live: live as unknown as JsonValue,
|
|
197
|
+
transcript: parsed as unknown as JsonValue,
|
|
198
|
+
}
|
|
199
|
+
} catch (err) {
|
|
200
|
+
return {
|
|
201
|
+
operation: 'read',
|
|
202
|
+
found: false,
|
|
203
|
+
error: `Failed to read transcript: ${err instanceof Error ? err.message : String(err)}`,
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (args.operation === 'nudge') {
|
|
209
|
+
let manifest: TranscriptManifest | null = null
|
|
210
|
+
if (existsSync(file)) {
|
|
211
|
+
try {
|
|
212
|
+
const parsed = JSON.parse(await readFile(file, 'utf-8')) as unknown as TranscriptManifest
|
|
213
|
+
manifest = parsed
|
|
214
|
+
} catch {
|
|
215
|
+
manifest = null
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const builder = manifest
|
|
219
|
+
? rehydrateBuilder(manifest, approval)
|
|
220
|
+
: new ReviewTranscriptBuilder({ project: projectRoot, mode: 'normal', approval })
|
|
221
|
+
builder.setNudge(typeof args.text === 'string' && args.text.trim() ? args.text : null)
|
|
222
|
+
await persist(file, builder.serialize())
|
|
223
|
+
return {
|
|
224
|
+
operation: 'nudge',
|
|
225
|
+
updated: true,
|
|
226
|
+
transcript: builder.serialize() as unknown as JsonValue,
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// capture
|
|
231
|
+
const mode = args.mode === 'normal' ? 'normal' : 'dry-run'
|
|
232
|
+
const goal = typeof args.goal === 'string' ? args.goal : ''
|
|
233
|
+
const maxRounds =
|
|
234
|
+
typeof args.maxRounds === 'number' ? Math.floor(args.maxRounds) : 0
|
|
235
|
+
const builder = new ReviewTranscriptBuilder({ project: projectRoot, mode, approval, goal, maxRounds })
|
|
236
|
+
const report = args.report as Record<string, unknown> | null | undefined
|
|
237
|
+
const reportFindings: unknown =
|
|
238
|
+
report && typeof report === 'object' && Array.isArray(report.findings)
|
|
239
|
+
? report.findings
|
|
240
|
+
: []
|
|
241
|
+
const convergence =
|
|
242
|
+
Array.isArray(args.findingsByRound) ? (args.findingsByRound as number[])
|
|
243
|
+
: report && typeof report === 'object' && report.convergence
|
|
244
|
+
? ((report.convergence as Record<string, unknown>).findingsByRound as number[] | undefined) ?? []
|
|
245
|
+
: []
|
|
246
|
+
|
|
247
|
+
const rounds = Array.isArray(args.rounds) ? (args.rounds as unknown[]) : []
|
|
248
|
+
for (const r of rounds) captureRound(builder, r)
|
|
249
|
+
if (rounds.length === 0) {
|
|
250
|
+
// No pre-grouped rounds: fall back to the report's flattened findings.
|
|
251
|
+
const readFiles = Array.isArray(args.refReadFiles) ? args.refReadFiles : []
|
|
252
|
+
const byDim = new Map<string, unknown[]>()
|
|
253
|
+
for (const f of reportFindings as unknown[]) {
|
|
254
|
+
if (!f || typeof f !== 'object') continue
|
|
255
|
+
const rec = f as Record<string, unknown>
|
|
256
|
+
const dim = typeof rec.dimension === 'string' && rec.dimension ? rec.dimension : 'review'
|
|
257
|
+
const list = byDim.get(dim) ?? []
|
|
258
|
+
list.push(f)
|
|
259
|
+
byDim.set(dim, list)
|
|
260
|
+
}
|
|
261
|
+
for (const [dim, list] of byDim) builder.reviewerSnapshot(dim, list, readFiles)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Convergence series from the report (position per round).
|
|
265
|
+
for (let i = 0; i < convergence.length; i += 1) {
|
|
266
|
+
const n = convergence[i]
|
|
267
|
+
if (typeof n === 'number') builder.snapshotConvergence(i + 1, n)
|
|
268
|
+
}
|
|
269
|
+
const roundsExecuted =
|
|
270
|
+
typeof args.roundsExecuted === 'number' ? Math.floor(args.roundsExecuted) : rounds.length
|
|
271
|
+
if (roundsExecuted > 0) builder.roundStart(roundsExecuted, maxRounds)
|
|
272
|
+
|
|
273
|
+
builder.recordCheckpoint(normalizeCheckpoint(args.checkpoint))
|
|
274
|
+
if (Array.isArray(args.fixes)) {
|
|
275
|
+
for (const fx of args.fixes) {
|
|
276
|
+
const record = normalizeFix(fx)
|
|
277
|
+
if (record) builder.fix(record)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// Convergence "found nothing → settled" marker when the trend ends on 0.
|
|
281
|
+
const last = convergence[convergence.length - 1]
|
|
282
|
+
if (convergence.length > 0 && last === 0) builder.finish()
|
|
283
|
+
|
|
284
|
+
await persist(file, builder.serialize())
|
|
285
|
+
const live = await readLive(projectRoot)
|
|
286
|
+
return {
|
|
287
|
+
operation: 'capture',
|
|
288
|
+
found: true,
|
|
289
|
+
updated: true,
|
|
290
|
+
live: live as unknown as JsonValue,
|
|
291
|
+
transcript: builder.serialize() as unknown as JsonValue,
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
}),
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Rebuild a builder from a persisted manifest so nudge edits preserve history. */
|
|
299
|
+
function rehydrateBuilder(manifest: TranscriptManifest, approval: 'ask' | 'deny' | 'allow'): ReviewTranscriptBuilder {
|
|
300
|
+
const builder = new ReviewTranscriptBuilder({
|
|
301
|
+
project: manifest.project,
|
|
302
|
+
mode: manifest.mode ?? null,
|
|
303
|
+
approval,
|
|
304
|
+
goal: manifest.goal,
|
|
305
|
+
maxRounds: manifest.maxRounds,
|
|
306
|
+
})
|
|
307
|
+
for (const r of Array.isArray(manifest.rounds) ? manifest.rounds : []) {
|
|
308
|
+
builder.roundStart(r.round, manifest.maxRounds)
|
|
309
|
+
for (const t of Array.isArray(r.threads) ? r.threads : []) {
|
|
310
|
+
builder.reviewerStart(t.dimension || 'review', t.attempt || 1)
|
|
311
|
+
builder.reviewerMessage((t.messages ?? []).join('\n'))
|
|
312
|
+
builder.reviewerRead(t.readFiles ?? [])
|
|
313
|
+
for (const f of t.findings ?? []) builder.reviewerFindings([f])
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
for (let idx = 0; idx < (manifest.convergence ?? []).length; idx += 1) {
|
|
317
|
+
const n = manifest.convergence[idx]
|
|
318
|
+
if (typeof n === 'number' && n >= 0) builder.snapshotConvergence(idx + 1, n)
|
|
319
|
+
}
|
|
320
|
+
if (manifest.checkpoint) builder.recordCheckpoint(manifest.checkpoint)
|
|
321
|
+
if (Array.isArray(manifest.fixes)) for (const fx of manifest.fixes) builder.fix(fx as TranscriptFix)
|
|
322
|
+
if (Array.isArray(manifest.timeline)) for (const e of manifest.timeline) builder.decision(e)
|
|
323
|
+
builder.setNudge(manifest.nudge?.text ?? null)
|
|
324
|
+
if (!manifest.active) builder.finish()
|
|
325
|
+
return builder
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Atomically persist a manifest (tmp + rename) under `.iterate/`. */
|
|
329
|
+
async function persist(file: string, manifest: TranscriptManifest): Promise<void> {
|
|
330
|
+
await mkdir(dirname(file), { recursive: true })
|
|
331
|
+
const tmp = `${file}.tmp`
|
|
332
|
+
await writeFile(tmp, JSON.stringify(manifest, null, 2), 'utf-8')
|
|
333
|
+
await rename(tmp, file)
|
|
334
|
+
}
|
package/src/tools/triage.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
1
|
+
import { copyFileSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
4
4
|
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
|
@@ -231,16 +231,19 @@ function applyEntries(
|
|
|
231
231
|
try {
|
|
232
232
|
writeFileSync(configPath, yamlText, 'utf-8')
|
|
233
233
|
} catch (err) {
|
|
234
|
-
// Rollback: restore the backup
|
|
234
|
+
// Rollback: restore the backup, or REMOVE the file we just created when
|
|
235
|
+
// there was no prior config — an empty file left behind would poison all
|
|
236
|
+
// future config reads (empty YAML is not a valid mapping).
|
|
237
|
+
let rollbackError = ''
|
|
235
238
|
try {
|
|
236
239
|
if (backupPath) copyFileSync(backupPath, configPath)
|
|
237
|
-
else if (existsSync(configPath))
|
|
238
|
-
} catch {
|
|
239
|
-
|
|
240
|
+
else if (existsSync(configPath)) rmSync(configPath, { force: true })
|
|
241
|
+
} catch (rbErr) {
|
|
242
|
+
rollbackError = `; rollback also failed: ${String(rbErr)}`
|
|
240
243
|
}
|
|
241
244
|
return {
|
|
242
245
|
ok: false,
|
|
243
|
-
error: `Failed to write config: ${String(err)}`,
|
|
246
|
+
error: `Failed to write config: ${String(err)}${rollbackError}`,
|
|
244
247
|
}
|
|
245
248
|
}
|
|
246
249
|
|
package/src/tools/validate.ts
CHANGED
|
@@ -46,10 +46,13 @@ async function runCommand(
|
|
|
46
46
|
},
|
|
47
47
|
(error, stdout, stderr) => {
|
|
48
48
|
const durationMs = Math.round(performance.now() - start)
|
|
49
|
-
// error.code is the exit code when the command ran;
|
|
49
|
+
// error.code is the exit code when the command ran; when the binary
|
|
50
|
+
// cannot be spawned Node sets error.code to a STRING ('ENOENT' etc).
|
|
51
|
+
// Coerce to a number so the integer output schema is never violated.
|
|
52
|
+
const exitCode = typeof error?.code === 'number' ? error.code : (error ? 1 : 0)
|
|
50
53
|
resolve({
|
|
51
54
|
command,
|
|
52
|
-
exitCode
|
|
55
|
+
exitCode,
|
|
53
56
|
stdout: stdout ?? '',
|
|
54
57
|
stderr: stderr ?? '',
|
|
55
58
|
timedOut: error?.killed === true,
|