iterate-plugin 3.5.7 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -20
- package/README.zh-CN.md +53 -17
- package/dist/approval-gate.js +143 -23
- package/dist/atomic-fs.js +148 -17
- package/dist/config-loader.js +509 -34
- package/dist/config-write.js +83 -22
- package/dist/evidence.js +141 -87
- package/dist/file-lock.js +165 -0
- package/dist/index.js +54 -28
- package/dist/jobs.js +56 -11
- package/dist/live.js +52 -13
- package/dist/meta-review.js +132 -28
- package/dist/method-scope.js +31 -8
- package/dist/review-scope.js +32 -9
- package/dist/review.js +96 -17
- package/dist/session-hooks.js +55 -13
- package/dist/skill-prompt.js +396 -66
- package/dist/tools/checkpoint.js +85 -19
- package/dist/tools/config.js +44 -2
- package/dist/tools/context.js +22 -5
- package/dist/tools/decision-log.js +111 -27
- package/dist/tools/defense-events.js +90 -31
- package/dist/tools/defense-store.js +28 -3
- package/dist/tools/experience-bank.js +108 -43
- package/dist/tools/experience-store.js +54 -4
- package/dist/tools/fix.js +347 -119
- package/dist/tools/history.js +68 -14
- package/dist/tools/present.js +80 -0
- package/dist/tools/prune.js +150 -53
- package/dist/tools/quality-gate.js +52 -2
- package/dist/tools/quality-store.js +17 -1
- package/dist/tools/review.js +158 -7
- package/dist/tools/transcript.js +173 -36
- package/dist/tools/triage.js +67 -12
- package/dist/tools/validate.js +115 -24
- package/dist/transcript.js +267 -4
- package/lib/client.js +945 -181
- package/lib/parse.js +760 -57
- package/package.json +14 -12
- package/src/approval-gate.ts +160 -23
- package/src/atomic-fs.ts +152 -17
- package/src/client/index.ts +600 -129
- package/src/config-loader.ts +542 -35
- package/src/config-write.ts +108 -26
- package/src/evidence.ts +194 -92
- package/src/file-lock.ts +184 -0
- package/src/index.ts +53 -29
- package/src/jobs.ts +63 -12
- package/src/live.ts +48 -11
- package/src/meta-review.ts +151 -34
- package/src/method-scope.ts +31 -8
- package/src/review-scope.ts +33 -10
- package/src/review.ts +96 -17
- package/src/session-hooks.ts +54 -12
- package/src/skill-prompt.ts +396 -66
- package/src/tools/checkpoint.ts +81 -19
- package/src/tools/config.ts +44 -2
- package/src/tools/context.ts +25 -5
- package/src/tools/decision-log.ts +115 -22
- package/src/tools/defense-events.ts +87 -31
- package/src/tools/defense-store.ts +30 -3
- package/src/tools/experience-bank.ts +112 -44
- package/src/tools/experience-store.ts +54 -5
- package/src/tools/fix.ts +258 -20
- package/src/tools/history.ts +74 -15
- package/src/tools/present.ts +89 -0
- package/src/tools/prune.ts +154 -51
- package/src/tools/quality-gate.ts +57 -2
- package/src/tools/quality-store.ts +18 -1
- package/src/tools/review.ts +155 -6
- package/src/tools/transcript.ts +178 -36
- package/src/tools/triage.ts +70 -13
- package/src/tools/validate.ts +138 -32
- package/src/transcript.ts +279 -4
- package/src/types.ts +29 -1
package/src/tools/fix.ts
CHANGED
|
@@ -19,10 +19,11 @@
|
|
|
19
19
|
|
|
20
20
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
|
|
21
21
|
import { writeJsonAtomic, writeTextAtomic } from '../atomic-fs.ts'
|
|
22
|
-
import { dirname, join, sep } from 'node:path'
|
|
22
|
+
import { dirname, isAbsolute, join, normalize, sep } from 'node:path'
|
|
23
23
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
24
24
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
25
25
|
import { loadEffectiveConfig, resolveProjectRootForExec } from '../config-loader.ts'
|
|
26
|
+
import { withProjectLock } from '../file-lock.ts'
|
|
26
27
|
import { runWithJob } from '../jobs.ts'
|
|
27
28
|
import { countTouchedMethods } from '../method-scope.ts'
|
|
28
29
|
import { fixBackupPath, fixRegistryPath, fixesDir } from '../paths.ts'
|
|
@@ -56,11 +57,28 @@ export function fixId(finding: Pick<ReviewFinding, 'file' | 'dimension' | 'line'
|
|
|
56
57
|
return `fix-${hashString(key)}`
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Exact-alignment budget for the changed middle block: a dynamic-programming
|
|
62
|
+
* LCS pass over `m × n` cells. Beyond these bounds diffLines falls back to the
|
|
63
|
+
* legacy single-hunk report (coarser counts, but never a hang), so a
|
|
64
|
+
* pathological multi-thousand-line input cannot stall the tool loop.
|
|
65
|
+
*/
|
|
66
|
+
export const DIFF_MAX_REGION_CELLS = 20_000
|
|
67
|
+
export const DIFF_MAX_REGION_LINES = 5_000
|
|
68
|
+
|
|
59
69
|
/**
|
|
60
70
|
* Compute a minimal line diff between two texts.
|
|
61
|
-
* Returns an array of hunks (empty when unchanged).
|
|
62
|
-
*
|
|
63
|
-
*
|
|
71
|
+
* Returns an array of hunks (empty when unchanged).
|
|
72
|
+
*
|
|
73
|
+
* Common prefix/suffix are trimmed first, then the changed middle block is
|
|
74
|
+
* aligned EXACTLY (suffix-LCS dynamic programming, bounded by
|
|
75
|
+
* DIFF_MAX_REGION_CELLS / DIFF_MAX_REGION_LINES) and split into one hunk per
|
|
76
|
+
* run of changed lines separated by unchanged lines. This keeps counts honest:
|
|
77
|
+
* changing line 1 AND line 100 of a 200-line file reports {added:2, removed:2}
|
|
78
|
+
* across two hunks, not {100,100} for the whole span — the atomic `max_lines`
|
|
79
|
+
* gate and the persisted FixRecord line counts are derived from these numbers.
|
|
80
|
+
* When the region exceeds the budget, the old single-hunk behavior is kept so
|
|
81
|
+
* huge inputs degrade to coarse-but-correct counts instead of hanging.
|
|
64
82
|
*/
|
|
65
83
|
export function diffLines(before: string, after: string): FileDiffHunk[] {
|
|
66
84
|
const a = before.split('\n')
|
|
@@ -76,6 +94,14 @@ export function diffLines(before: string, after: string): FileDiffHunk[] {
|
|
|
76
94
|
const removed = a.slice(start, endA)
|
|
77
95
|
const added = b.slice(start, endB)
|
|
78
96
|
if (removed.length === 0 && added.length === 0) return []
|
|
97
|
+
return alignRegion(removed, added, start) ?? singleRegionHunk(removed, added, start)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Legacy single-hunk report for the trimmed changed block (also the bounded
|
|
102
|
+
* fallback when `alignRegion` refuses an over-budget region).
|
|
103
|
+
*/
|
|
104
|
+
function singleRegionHunk(removed: string[], added: string[], start: number): FileDiffHunk[] {
|
|
79
105
|
const contentLines: string[] = []
|
|
80
106
|
for (const line of removed) contentLines.push(`- ${line}`)
|
|
81
107
|
for (const line of added) contentLines.push(`+ ${line}`)
|
|
@@ -90,6 +116,80 @@ export function diffLines(before: string, after: string): FileDiffHunk[] {
|
|
|
90
116
|
]
|
|
91
117
|
}
|
|
92
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Exactly align the trimmed changed block and split it into per-spot hunks
|
|
121
|
+
* (each maximal run of changed lines, separated by at least one unchanged
|
|
122
|
+
* line). Returns `null` when the region exceeds the DP budget so the caller
|
|
123
|
+
* can fall back to {@link singleRegionHunk}.
|
|
124
|
+
*
|
|
125
|
+
* Hunk coordinates follow GNU `diff -U0` conventions: for a pure insertion
|
|
126
|
+
* `oldStart` is the line BEFORE the insertion point (oldLines = 0), for a pure
|
|
127
|
+
* deletion `newStart` is the line before the deletion point (newLines = 0),
|
|
128
|
+
* and for a replacement both point at the replaced line.
|
|
129
|
+
*/
|
|
130
|
+
function alignRegion(removed: string[], added: string[], start: number): FileDiffHunk[] | null {
|
|
131
|
+
const m = removed.length
|
|
132
|
+
const n = added.length
|
|
133
|
+
if (m > DIFF_MAX_REGION_LINES || n > DIFF_MAX_REGION_LINES || m * n > DIFF_MAX_REGION_CELLS) return null
|
|
134
|
+
|
|
135
|
+
// dp[i][j] = LCS length of removed[i..] / added[j..] (suffix table).
|
|
136
|
+
const stride = n + 1
|
|
137
|
+
const dp = new Int32Array((m + 1) * stride)
|
|
138
|
+
for (let i = m - 1; i >= 0; i--) {
|
|
139
|
+
for (let j = n - 1; j >= 0; j--) {
|
|
140
|
+
dp[i * stride + j] = removed[i] === added[j]
|
|
141
|
+
? dp[(i + 1) * stride + j + 1]! + 1
|
|
142
|
+
: Math.max(dp[(i + 1) * stride + j]!, dp[i * stride + j + 1]!)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const hunks: FileDiffHunk[] = []
|
|
147
|
+
let open: { iOpen: number; jOpen: number; oldLines: number; newLines: number; content: string[] } | null = null
|
|
148
|
+
const closeHunk = (): void => {
|
|
149
|
+
if (!open) return
|
|
150
|
+
hunks.push({
|
|
151
|
+
// oldLines > 0 → first removed line (1-based); pure insertion → the
|
|
152
|
+
// line before the insertion point (= its 0-based index).
|
|
153
|
+
oldStart: start + open.iOpen + (open.oldLines > 0 ? 1 : 0),
|
|
154
|
+
oldLines: open.oldLines,
|
|
155
|
+
newStart: start + open.jOpen + (open.newLines > 0 ? 1 : 0),
|
|
156
|
+
newLines: open.newLines,
|
|
157
|
+
content: open.content.join('\n'),
|
|
158
|
+
})
|
|
159
|
+
open = null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let i = 0 // region index into removed
|
|
163
|
+
let j = 0 // region index into added
|
|
164
|
+
while (i < m || j < n) {
|
|
165
|
+
// A matching pair always belongs to SOME optimal alignment (CLRS
|
|
166
|
+
// x_i = y_j ⇒ LCS(i,j) = 1 + LCS(i+1,j+1)), so greedily closing the
|
|
167
|
+
// current hunk here is exact, not heuristic.
|
|
168
|
+
if (i < m && j < n && removed[i] === added[j]) {
|
|
169
|
+
closeHunk()
|
|
170
|
+
i++
|
|
171
|
+
j++
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
// Diverged: consume whichever side the suffix table says costs less.
|
|
175
|
+
// Ties prefer removal so a plain replacement reads `- old / + new`.
|
|
176
|
+
const takeRemoved = i >= m ? false : j >= n ? true : dp[(i + 1) * stride + j]! >= dp[i * stride + j + 1]!
|
|
177
|
+
if (takeRemoved) {
|
|
178
|
+
if (!open) open = { iOpen: i, jOpen: j, oldLines: 0, newLines: 0, content: [] }
|
|
179
|
+
open.oldLines++
|
|
180
|
+
open.content.push(`- ${removed[i]}`)
|
|
181
|
+
i++
|
|
182
|
+
} else {
|
|
183
|
+
if (!open) open = { iOpen: i, jOpen: j, oldLines: 0, newLines: 0, content: [] }
|
|
184
|
+
open.newLines++
|
|
185
|
+
open.content.push(`+ ${added[j]}`)
|
|
186
|
+
j++
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
closeHunk()
|
|
190
|
+
return hunks
|
|
191
|
+
}
|
|
192
|
+
|
|
93
193
|
/** Added/removed line counts for a change (derived from diffLines). */
|
|
94
194
|
export function countChangedLines(before: string, after: string): { added: number; removed: number } {
|
|
95
195
|
const hunks = diffLines(before, after)
|
|
@@ -130,11 +230,18 @@ export function readRegistry(projectRoot: string): FixRegistry {
|
|
|
130
230
|
// contain a round without a `records` array, or records that are missing
|
|
131
231
|
// their id / finding object — all of which would make readers
|
|
132
232
|
// (findFixRecord / recordsForFile / iterate_diff) throw or sum NaN.
|
|
233
|
+
// Numeric counters are also floored to non-negative integers: the status
|
|
234
|
+
// output schema declares them `type: 'integer'`, so a `fixedCount: 1.5`
|
|
235
|
+
// must never reach iterate_status.
|
|
236
|
+
const intCount = (v: unknown): number =>
|
|
237
|
+
typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0
|
|
133
238
|
parsed.rounds = parsed.rounds
|
|
134
239
|
.filter((r) => r && typeof r === 'object' && Array.isArray(r.records))
|
|
135
240
|
.map((r) => ({
|
|
136
241
|
...r,
|
|
137
242
|
round: typeof r.round === 'number' && Number.isFinite(r.round) ? Math.floor(r.round) : 0,
|
|
243
|
+
fixedCount: intCount(r.fixedCount),
|
|
244
|
+
failedCount: intCount(r.failedCount),
|
|
138
245
|
records: r.records.filter(
|
|
139
246
|
(rec): rec is FixRecord =>
|
|
140
247
|
!!rec &&
|
|
@@ -160,12 +267,18 @@ export function findFixRecord(registry: FixRegistry, id: string): FixRecord | un
|
|
|
160
267
|
return undefined
|
|
161
268
|
}
|
|
162
269
|
|
|
163
|
-
/**
|
|
270
|
+
/**
|
|
271
|
+
* All fix records for a file, in chronological order.
|
|
272
|
+
* Both sides are canonicalized with {@link normalizeProjectPath} so a record
|
|
273
|
+
* persisted as `./README.md` (pre-normalization builds) still matches a
|
|
274
|
+
* query for `README.md` and vice versa.
|
|
275
|
+
*/
|
|
164
276
|
export function recordsForFile(registry: FixRegistry, file: string): FixRecord[] {
|
|
277
|
+
const want = normalizeProjectPath(file)
|
|
165
278
|
const out: FixRecord[] = []
|
|
166
279
|
for (const round of registry.rounds) {
|
|
167
280
|
for (const r of round.records) {
|
|
168
|
-
if (r.finding.file ===
|
|
281
|
+
if (normalizeProjectPath(r.finding.file) === want && r.success) out.push(r)
|
|
169
282
|
}
|
|
170
283
|
}
|
|
171
284
|
return out
|
|
@@ -251,6 +364,66 @@ export function resolveProjectFile(projectRoot: string, file: string): { ok: tru
|
|
|
251
364
|
return { ok: true, resolved }
|
|
252
365
|
}
|
|
253
366
|
|
|
367
|
+
/**
|
|
368
|
+
* Canonicalize a model-supplied relative path ONCE at argument intake.
|
|
369
|
+
*
|
|
370
|
+
* Strips a leading `./`, collapses duplicate separators, and resolves `.` /
|
|
371
|
+
* `..` segments lexically so the containment check, the protected-path glob,
|
|
372
|
+
* the persisted FixRecord, and every later diff/rollback lookup all compare
|
|
373
|
+
* the SAME string — a raw `./README.md` must not dodge a `README.md` veto
|
|
374
|
+
* glob, and `src/../README.md` must record as `README.md`.
|
|
375
|
+
* Absolute paths and `..` escapes survive normalization and are rejected by
|
|
376
|
+
* {@link resolveProjectFile} afterwards. Pure, exported for unit tests.
|
|
377
|
+
*/
|
|
378
|
+
export function normalizeProjectPath(file: string): string {
|
|
379
|
+
// An empty intake must stay empty (node's normalize('') === '.') so
|
|
380
|
+
// resolveProjectFile still rejects it instead of resolving to the root.
|
|
381
|
+
if (typeof file !== 'string' || file === '') return ''
|
|
382
|
+
return normalize(file)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Validate a registry-recorded backup path before it is read (iterate_diff)
|
|
387
|
+
* or used to overwrite a project file (iterate_rollback).
|
|
388
|
+
*
|
|
389
|
+
* The registry is a plain JSON file on disk: a hand-edited/corrupt record can
|
|
390
|
+
* point `backupPath` anywhere, turning diff into an arbitrary-file read and
|
|
391
|
+
* rollback into arbitrary-content injection. A backup must stay inside the
|
|
392
|
+
* project's fixes directory — lexically AND via realpath when it exists (so a
|
|
393
|
+
* symlink planted inside `.iterate/fixes` cannot smuggle content either).
|
|
394
|
+
*/
|
|
395
|
+
export function resolveBackupPath(
|
|
396
|
+
projectRoot: string,
|
|
397
|
+
backupPath: string,
|
|
398
|
+
): { ok: true; resolved: string } | { ok: false; reason: string } {
|
|
399
|
+
if (typeof backupPath !== 'string' || backupPath.trim().length === 0) {
|
|
400
|
+
return { ok: false, reason: 'backup path is missing from the fix record' }
|
|
401
|
+
}
|
|
402
|
+
const fixes = fixesDir(projectRoot)
|
|
403
|
+
// Relative entries are anchored at the project root (never the process cwd)
|
|
404
|
+
// so `../../etc/passwd` fails the containment check below.
|
|
405
|
+
const resolved = isAbsolute(backupPath) ? normalize(backupPath) : join(projectRoot, backupPath)
|
|
406
|
+
const prefix = fixes.endsWith(sep) ? fixes : fixes + sep
|
|
407
|
+
if (resolved !== fixes && !resolved.startsWith(prefix)) {
|
|
408
|
+
return { ok: false, reason: `backup path escapes the fixes directory: ${backupPath}` }
|
|
409
|
+
}
|
|
410
|
+
if (existsSync(resolved)) {
|
|
411
|
+
let realFixes: string
|
|
412
|
+
let real: string
|
|
413
|
+
try {
|
|
414
|
+
realFixes = realpathSync(fixes)
|
|
415
|
+
real = realpathSync(resolved)
|
|
416
|
+
} catch {
|
|
417
|
+
return { ok: false, reason: `failed to resolve real path for backup containment check: ${backupPath}` }
|
|
418
|
+
}
|
|
419
|
+
const realPrefix = realFixes.endsWith(sep) ? realFixes : realFixes + sep
|
|
420
|
+
if (real !== realFixes && !real.startsWith(realPrefix)) {
|
|
421
|
+
return { ok: false, reason: `backup path escapes the fixes directory (symlink): ${backupPath}` }
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return { ok: true, resolved }
|
|
425
|
+
}
|
|
426
|
+
|
|
254
427
|
// ─── Shared execute helpers ──────────────────────────────────────────────────
|
|
255
428
|
|
|
256
429
|
/**
|
|
@@ -384,8 +557,14 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
384
557
|
const maxLines = config.atomic?.max_lines ?? 20
|
|
385
558
|
const maxAdjacentMethods = config.atomic?.max_adjacent_methods ?? 3
|
|
386
559
|
|
|
387
|
-
|
|
388
|
-
|
|
560
|
+
// Canonicalize ONCE at intake: `./README.md` and `src/../README.md`
|
|
561
|
+
// must behave exactly like `README.md` downstream — the protected-path
|
|
562
|
+
// glob, the fix id, the persisted FixRecord, and later diff/rollback
|
|
563
|
+
// lookups all compare this normalized form (escapes/absolute paths
|
|
564
|
+
// survive normalization and are rejected by resolveProjectFile).
|
|
565
|
+
const rawFile = typeof args.file === 'string' ? args.file : ''
|
|
566
|
+
if (!rawFile) return { ok: false, error: 'file is required' }
|
|
567
|
+
const file = normalizeProjectPath(rawFile)
|
|
389
568
|
if (typeof args.content !== 'string') return { ok: false, error: 'content must be a string' }
|
|
390
569
|
if (args.content.length > MAX_FIX_CONTENT_CHARS) {
|
|
391
570
|
return {
|
|
@@ -396,21 +575,23 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
396
575
|
if (typeof args.round !== 'number' || !Number.isInteger(args.round) || args.round < 1) {
|
|
397
576
|
return { ok: false, error: 'round must be a positive integer' }
|
|
398
577
|
}
|
|
399
|
-
const
|
|
400
|
-
if (!
|
|
578
|
+
const rawFinding = args.finding as unknown as ReviewFinding | undefined
|
|
579
|
+
if (!rawFinding || typeof rawFinding !== 'object') {
|
|
401
580
|
return { ok: false, error: 'finding must be an object' }
|
|
402
581
|
}
|
|
403
|
-
if (typeof
|
|
582
|
+
if (typeof rawFinding.file !== 'string' || rawFinding.file.trim().length === 0) {
|
|
404
583
|
return { ok: false, error: 'finding.file must be a non-empty string' }
|
|
405
584
|
}
|
|
406
|
-
if (typeof
|
|
585
|
+
if (typeof rawFinding.dimension !== 'string' || rawFinding.dimension.trim().length === 0) {
|
|
407
586
|
return { ok: false, error: 'finding.dimension must be a non-empty string' }
|
|
408
587
|
}
|
|
409
588
|
// The finding must reference the file being fixed — the fix id and the
|
|
410
589
|
// rollback/diff target are derived from finding.file, so a mismatch
|
|
411
|
-
// would back up/restore the WRONG file.
|
|
590
|
+
// would back up/restore the WRONG file. Both sides are compared in
|
|
591
|
+
// their NORMALIZED form (`./src/a.ts` and `src/a.ts` are the same fix).
|
|
592
|
+
const finding: ReviewFinding = { ...rawFinding, file: normalizeProjectPath(rawFinding.file) }
|
|
412
593
|
if (finding.file !== file) {
|
|
413
|
-
return { ok: false, error: `finding.file ("${
|
|
594
|
+
return { ok: false, error: `finding.file ("${rawFinding.file}") must match the file being fixed ("${rawFile}")` }
|
|
414
595
|
}
|
|
415
596
|
// Full finding validation, mirroring the review schema: malformed
|
|
416
597
|
// findings would produce lossy registry/log entries and a degraded id.
|
|
@@ -452,6 +633,16 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
452
633
|
}
|
|
453
634
|
|
|
454
635
|
const id = fixId(finding)
|
|
636
|
+
const timestamp = new Date().toISOString()
|
|
637
|
+
|
|
638
|
+
// Cross-process read-modify-write guard: the registry read (dup check)
|
|
639
|
+
// and the record write form ONE window — two plugin processes racing
|
|
640
|
+
// here could each read the old registry and silently drop the other's
|
|
641
|
+
// update. The shared advisory lock (src/file-lock.ts) serializes the
|
|
642
|
+
// window; the decision-log append runs after it is released.
|
|
643
|
+
const applied = withProjectLock(projectRoot, 'fix-registry', ():
|
|
644
|
+
| { ok: false; error: string; id?: string }
|
|
645
|
+
| { ok: true; record: FixRecord; backupPath: string } => {
|
|
455
646
|
const registry = readRegistry(projectRoot)
|
|
456
647
|
if (findFixRecord(registry, id)) {
|
|
457
648
|
return { ok: false, error: `finding already fixed this run (id: ${id})`, id }
|
|
@@ -474,7 +665,8 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
474
665
|
// Personalization guards (SKILL.md Phase 2): protected_paths veto the
|
|
475
666
|
// fix outright; forbidden_fixes veto fix approaches appearing in the
|
|
476
667
|
// new content. Both are security-relevant, so they are enforced here
|
|
477
|
-
// in the tool, not left to the model.
|
|
668
|
+
// in the tool, not left to the model. The glob runs against the
|
|
669
|
+
// NORMALIZED `file`, so `./README.md` cannot dodge a `README.md` veto.
|
|
478
670
|
const pers = config.personalization as
|
|
479
671
|
| { protected_paths?: unknown; forbidden_fixes?: unknown }
|
|
480
672
|
| undefined
|
|
@@ -495,7 +687,6 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
495
687
|
}
|
|
496
688
|
}
|
|
497
689
|
|
|
498
|
-
const timestamp = new Date().toISOString()
|
|
499
690
|
const backupPath = fixBackupPath(projectRoot, id, timestamp)
|
|
500
691
|
try {
|
|
501
692
|
mkdirSync(fixesDir(projectRoot), { recursive: true })
|
|
@@ -539,6 +730,11 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
539
730
|
}
|
|
540
731
|
return { ok: false, error: `failed to write fix registry: ${String(err)} (file restored from backup)` }
|
|
541
732
|
}
|
|
733
|
+
return { ok: true, record, backupPath }
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
if (!applied.ok) return applied
|
|
737
|
+
const { record, backupPath } = applied
|
|
542
738
|
|
|
543
739
|
const logRes = appendDecisionEntry(projectRoot, {
|
|
544
740
|
timestamp,
|
|
@@ -629,17 +825,24 @@ export function registerDiffTool(ctx: { tools: { register: (def: ReturnType<type
|
|
|
629
825
|
if (!resolved.ok) return { ok: false, error: resolved.reason }
|
|
630
826
|
const projectRoot = resolved.root
|
|
631
827
|
const registry = readRegistry(projectRoot)
|
|
632
|
-
const file = typeof args.file === 'string' && args.file.trim()
|
|
828
|
+
const file = typeof args.file === 'string' && args.file.trim()
|
|
829
|
+
? normalizeProjectPath(args.file)
|
|
830
|
+
: undefined
|
|
633
831
|
|
|
634
832
|
if (file) {
|
|
635
833
|
const records = recordsForFile(registry, file)
|
|
636
834
|
const first = records[0]
|
|
637
835
|
if (!first) return { ok: false, error: `no fixes recorded for ${file}` }
|
|
836
|
+
// Containment: the registry is on-disk JSON that a hand edit can
|
|
837
|
+
// point anywhere — a tampered backupPath must never turn this
|
|
838
|
+
// read-only diff into an arbitrary-file read.
|
|
839
|
+
const backup = resolveBackupPath(projectRoot, first.backupPath)
|
|
840
|
+
if (!backup.ok) return { ok: false, error: `invalid backup for ${file}: ${backup.reason}` }
|
|
638
841
|
const current = readProjectFile(projectRoot, file)
|
|
639
842
|
if (!current.ok) return { ok: false, error: current.reason }
|
|
640
843
|
let original = ''
|
|
641
844
|
try {
|
|
642
|
-
original = readFileSync(
|
|
845
|
+
original = readFileSync(backup.resolved, 'utf-8')
|
|
643
846
|
} catch (err) {
|
|
644
847
|
return { ok: false, error: `backup missing for ${file}: ${String(err)}` }
|
|
645
848
|
}
|
|
@@ -742,10 +945,40 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
|
|
|
742
945
|
const id = typeof args.id === 'string' ? args.id : ''
|
|
743
946
|
if (!id) return { ok: false, error: 'id is required' }
|
|
744
947
|
|
|
948
|
+
// One locked read-modify-write window (see src/file-lock.ts): the
|
|
949
|
+
// registry read, the file restore, and the record removal must not
|
|
950
|
+
// interleave with another process's fix/rollback or the update is lost.
|
|
951
|
+
const restored = withProjectLock(projectRoot, 'fix-registry', ():
|
|
952
|
+
| { ok: false; error: string }
|
|
953
|
+
| { ok: true; record: FixRecord } => {
|
|
745
954
|
const registry = readRegistry(projectRoot)
|
|
746
955
|
const record = findFixRecord(registry, id)
|
|
747
956
|
if (!record) return { ok: false, error: `fix not found: ${id}` }
|
|
748
|
-
|
|
957
|
+
|
|
958
|
+
// LIFO safety: restoring an OLDER backup would silently destroy every
|
|
959
|
+
// NEWER successful fix to the same file — their records would stay
|
|
960
|
+
// success:true while the bytes they wrote are gone. Refuse and name
|
|
961
|
+
// the clobbered ids so the caller can roll back in reverse order first.
|
|
962
|
+
const fileRecords = recordsForFile(registry, record.finding.file)
|
|
963
|
+
const targetIdx = fileRecords.findIndex((r) => r.id === id)
|
|
964
|
+
const newer = targetIdx >= 0
|
|
965
|
+
? fileRecords.slice(targetIdx + 1)
|
|
966
|
+
: fileRecords.filter((r) => r.id !== id && r.timestamp > record.timestamp)
|
|
967
|
+
if (newer.length > 0) {
|
|
968
|
+
const clobbered = newer.map((r) => r.id).join(', ')
|
|
969
|
+
return {
|
|
970
|
+
ok: false,
|
|
971
|
+
error: `rollback refused: fix ${id} is older than fix(es) ${clobbered} on ${record.finding.file} — ` +
|
|
972
|
+
'restoring its backup would destroy them. Roll back the later fix(es) first (LIFO order).',
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// Containment: the registry is on-disk JSON that a hand edit can
|
|
977
|
+
// point anywhere — a tampered backupPath must never inject arbitrary
|
|
978
|
+
// content into the project file.
|
|
979
|
+
const backup = resolveBackupPath(projectRoot, record.backupPath)
|
|
980
|
+
if (!backup.ok) return { ok: false, error: `invalid backup for fix ${id}: ${backup.reason}` }
|
|
981
|
+
if (!existsSync(backup.resolved)) {
|
|
749
982
|
return { ok: false, error: `backup missing for fix ${id}` }
|
|
750
983
|
}
|
|
751
984
|
|
|
@@ -754,7 +987,7 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
|
|
|
754
987
|
try {
|
|
755
988
|
// Atomic restore: never leave a truncated source file if we crash
|
|
756
989
|
// mid-restore (matches the writeTextAtomic guarantee used by apply).
|
|
757
|
-
writeTextAtomic(target.resolved, readFileSync(
|
|
990
|
+
writeTextAtomic(target.resolved, readFileSync(backup.resolved, 'utf-8'))
|
|
758
991
|
} catch (err) {
|
|
759
992
|
return { ok: false, error: `failed to restore backup: ${String(err)}` }
|
|
760
993
|
}
|
|
@@ -765,6 +998,11 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
|
|
|
765
998
|
} catch (err) {
|
|
766
999
|
return { ok: false, error: `failed to update fix registry: ${String(err)}` }
|
|
767
1000
|
}
|
|
1001
|
+
return { ok: true, record }
|
|
1002
|
+
})
|
|
1003
|
+
|
|
1004
|
+
if (!restored.ok) return restored
|
|
1005
|
+
const record = restored.record
|
|
768
1006
|
|
|
769
1007
|
const logRes = appendDecisionEntry(projectRoot, {
|
|
770
1008
|
timestamp: new Date().toISOString(),
|
package/src/tools/history.ts
CHANGED
|
@@ -30,18 +30,30 @@ export function clampHistoryLimit(limit: number | undefined): number {
|
|
|
30
30
|
* Filter + cap decision-log entries. Pure, unit-tested.
|
|
31
31
|
* Returns the newest `limit` matching entries plus the total match count
|
|
32
32
|
* (before the cap), so callers can tell when the result was truncated.
|
|
33
|
+
*
|
|
34
|
+
* `round` keeps only entries logged for that round; `file` keeps only entries
|
|
35
|
+
* whose `data.file` names that fixed file. Invalid `round`/`file` inputs are
|
|
36
|
+
* ignored (same convention as `type`/`since`), never treated as a match-nothing
|
|
37
|
+
* filter by accident.
|
|
33
38
|
*/
|
|
34
39
|
export function filterDecisionEntries(
|
|
35
40
|
entries: DecisionLogEntry[],
|
|
36
|
-
opts: { type?: unknown; since?: unknown; limit?: unknown },
|
|
41
|
+
opts: { type?: unknown; since?: unknown; limit?: unknown; round?: unknown; file?: unknown },
|
|
37
42
|
): { entries: DecisionLogEntry[]; filteredCount: number; limit: number } {
|
|
38
43
|
const type = typeof opts.type === 'string' && opts.type ? opts.type : undefined
|
|
39
44
|
const since = typeof opts.since === 'string' && opts.since ? opts.since : undefined
|
|
45
|
+
const round =
|
|
46
|
+
typeof opts.round === 'number' && Number.isInteger(opts.round) && opts.round >= 0
|
|
47
|
+
? opts.round
|
|
48
|
+
: undefined
|
|
49
|
+
const file = typeof opts.file === 'string' && opts.file ? opts.file : undefined
|
|
40
50
|
const limit = clampHistoryLimit(opts.limit as number | undefined)
|
|
41
51
|
|
|
42
52
|
const matching = (Array.isArray(entries) ? entries : []).filter((e) => {
|
|
43
53
|
if (type && e.type !== type) return false
|
|
44
54
|
if (since && e.timestamp <= since) return false
|
|
55
|
+
if (round !== undefined && e.round !== round) return false
|
|
56
|
+
if (file && (!e.data || e.data.file !== file)) return false
|
|
45
57
|
return true
|
|
46
58
|
})
|
|
47
59
|
return {
|
|
@@ -51,20 +63,53 @@ export function filterDecisionEntries(
|
|
|
51
63
|
}
|
|
52
64
|
}
|
|
53
65
|
|
|
54
|
-
/**
|
|
55
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Per-round fix counts + totals from a fix registry. Pure, unit-tested.
|
|
68
|
+
*
|
|
69
|
+
* `opts.round` narrows the view to one round. `opts.file` narrows each round to
|
|
70
|
+
* the records that fixed that file: the stored per-round counts cover every
|
|
71
|
+
* file and cannot be subsetted, so a file-scoped summary RECOMPUTES the counts
|
|
72
|
+
* from the kept records (mirroring `recomputeRoundCounts`), and rounds left
|
|
73
|
+
* without any matching record are dropped entirely — an all-zero placeholder
|
|
74
|
+
* round would falsely suggest the file was touched that round.
|
|
75
|
+
*/
|
|
76
|
+
export function summarizeFixRegistry(
|
|
77
|
+
registry: FixRegistry,
|
|
78
|
+
opts: { round?: unknown; file?: unknown } = {},
|
|
79
|
+
): {
|
|
56
80
|
totalFixed: number
|
|
57
81
|
totalFailed: number
|
|
58
82
|
roundCount: number
|
|
59
83
|
rounds: { round: number; fixedCount: number; failedCount: number }[]
|
|
60
84
|
} {
|
|
61
|
-
const
|
|
62
|
-
round
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
85
|
+
const roundFilter =
|
|
86
|
+
typeof opts.round === 'number' && Number.isInteger(opts.round) && opts.round >= 0
|
|
87
|
+
? opts.round
|
|
88
|
+
: undefined
|
|
89
|
+
const fileFilter = typeof opts.file === 'string' && opts.file ? opts.file : undefined
|
|
90
|
+
|
|
91
|
+
const rounds = (registry.rounds ?? [])
|
|
92
|
+
.filter((r) => roundFilter === undefined || r.round === roundFilter)
|
|
93
|
+
.flatMap((r) => {
|
|
94
|
+
if (fileFilter === undefined) {
|
|
95
|
+
// Coerce defensively so a hand-edited registry round missing either count
|
|
96
|
+
// can never propagate NaN into the integer output fields.
|
|
97
|
+
return [{
|
|
98
|
+
round: r.round,
|
|
99
|
+
fixedCount: Number(r.fixedCount) || 0,
|
|
100
|
+
failedCount: Number(r.failedCount) || 0,
|
|
101
|
+
}]
|
|
102
|
+
}
|
|
103
|
+
const kept = (r.records ?? []).filter((rec) => rec && rec.finding && rec.finding.file === fileFilter)
|
|
104
|
+
if (kept.length === 0) return []
|
|
105
|
+
// Recompute from the kept records: a rolled-back (success:false) record
|
|
106
|
+
// counts as failed, matching the registry's own recomputeRoundCounts.
|
|
107
|
+
return [{
|
|
108
|
+
round: r.round,
|
|
109
|
+
fixedCount: kept.filter((rec) => rec.success).length,
|
|
110
|
+
failedCount: kept.filter((rec) => !rec.success).length,
|
|
111
|
+
}]
|
|
112
|
+
})
|
|
68
113
|
return {
|
|
69
114
|
totalFixed: rounds.reduce((s, r) => s + r.fixedCount, 0),
|
|
70
115
|
totalFailed: rounds.reduce((s, r) => s + r.failedCount, 0),
|
|
@@ -75,8 +120,9 @@ export function summarizeFixRegistry(registry: FixRegistry): {
|
|
|
75
120
|
|
|
76
121
|
/**
|
|
77
122
|
* Register the `iterate_history` tool.
|
|
78
|
-
* Reads the decision log (optionally filtered by type / since /
|
|
79
|
-
* fix-registry summary
|
|
123
|
+
* Reads the decision log (optionally filtered by type / since / round / file /
|
|
124
|
+
* limit) and a fix-registry summary (scoped by the same round / file filters).
|
|
125
|
+
* Read-only; never modifies the filesystem.
|
|
80
126
|
*/
|
|
81
127
|
export function registerHistoryTool(ctx: { tools: { register: (def: ReturnType<typeof defineTool>) => void } }): void {
|
|
82
128
|
ctx.tools.register(
|
|
@@ -87,7 +133,8 @@ export function registerHistoryTool(ctx: { tools: { register: (def: ReturnType<t
|
|
|
87
133
|
isConcurrencySafe: () => true,
|
|
88
134
|
description:
|
|
89
135
|
'Read the iteration history: decision-log entries (optionally filtered by entry `type`, `since` ' +
|
|
90
|
-
'timestamp, and a `limit`) plus a summary of the fix registry (per-round
|
|
136
|
+
'timestamp, `round`, fixed `file`, and a `limit`) plus a summary of the fix registry (per-round ' +
|
|
137
|
+
'fixed/failed counts, scoped by the same round/file filters). ' +
|
|
91
138
|
'Read-only — use it to review what the run did, audit a log, or inspect fixes.',
|
|
92
139
|
parameters: {
|
|
93
140
|
type: {
|
|
@@ -100,6 +147,18 @@ export function registerHistoryTool(ctx: { tools: { register: (def: ReturnType<t
|
|
|
100
147
|
type: 'string',
|
|
101
148
|
description: 'Optional ISO timestamp; only entries AFTER this timestamp are returned.',
|
|
102
149
|
},
|
|
150
|
+
round: {
|
|
151
|
+
type: 'integer',
|
|
152
|
+
description:
|
|
153
|
+
'Optional round number; only decision-log entries logged for this round are returned, ' +
|
|
154
|
+
'and the fix summary is scoped to this round.',
|
|
155
|
+
},
|
|
156
|
+
file: {
|
|
157
|
+
type: 'string',
|
|
158
|
+
description:
|
|
159
|
+
'Optional fixed-file path; only decision-log entries whose `data.file` matches are returned, ' +
|
|
160
|
+
'and the fix summary counts only fixes applied to that file.',
|
|
161
|
+
},
|
|
103
162
|
limit: {
|
|
104
163
|
type: 'integer',
|
|
105
164
|
description: `Max entries to return (default: ${DEFAULT_LIMIT}, cap: ${MAX_LIMIT}). Returns the newest window in chronological order.`,
|
|
@@ -148,9 +207,9 @@ export function registerHistoryTool(ctx: { tools: { register: (def: ReturnType<t
|
|
|
148
207
|
|
|
149
208
|
const { entries, filteredCount, limit } = filterDecisionEntries(
|
|
150
209
|
readDecisionEntries(projectRoot),
|
|
151
|
-
{ type: args.type, since: args.since, limit: args.limit },
|
|
210
|
+
{ type: args.type, since: args.since, limit: args.limit, round: args.round, file: args.file },
|
|
152
211
|
)
|
|
153
|
-
const fixes = summarizeFixRegistry(readRegistry(projectRoot))
|
|
212
|
+
const fixes = summarizeFixRegistry(readRegistry(projectRoot), { round: args.round, file: args.file })
|
|
154
213
|
|
|
155
214
|
return {
|
|
156
215
|
ok: true,
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/tools/present.ts — shared `presentResult` helpers for the iterate tools (#12).
|
|
3
|
+
*
|
|
4
|
+
* dsh presents a completed call through `ToolDefinition.presentResult(args,
|
|
5
|
+
* result)` when the tool supplies one; the return value is a `ToolResultView`
|
|
6
|
+
* (a `card`-tagged render intent from `@deepseek-ai/dsh-tools`). These helpers
|
|
7
|
+
* read ONLY the durable result projection handed to the presenter
|
|
8
|
+
* (`result.content` / `result.isError`) — they never re-execute anything, so a
|
|
9
|
+
* live call and a session-log replay produce the identical card.
|
|
10
|
+
*
|
|
11
|
+
* House rules for every presenter built on this module:
|
|
12
|
+
* - Pure / replay-safe: depend on `args` + `result` only.
|
|
13
|
+
* - Defensive: any shape it cannot read returns `undefined`, which makes the
|
|
14
|
+
* UI fall back to the default presentation (pending title + raw result)
|
|
15
|
+
* instead of a wrong or half-guessed card.
|
|
16
|
+
* - No invented copy: card text is derived from the render output the tool
|
|
17
|
+
* itself produced.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** The content-block subset a presenter inspects (`ContentBlock` is structural). */
|
|
21
|
+
interface RenderedBlock {
|
|
22
|
+
type?: unknown
|
|
23
|
+
text?: unknown
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Concatenated text of a completed call's rendered content, or `undefined`
|
|
28
|
+
* when there is none. Failure results are never summarized — the presenter
|
|
29
|
+
* declines the card and the UI shows the raw error.
|
|
30
|
+
*/
|
|
31
|
+
export function renderedText(result: { content?: unknown; isError?: unknown }): string | undefined {
|
|
32
|
+
if (!result || result.isError === true || !Array.isArray(result.content)) return undefined
|
|
33
|
+
const parts: string[] = []
|
|
34
|
+
for (const block of result.content as RenderedBlock[]) {
|
|
35
|
+
if (block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') {
|
|
36
|
+
parts.push(block.text)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return parts.length > 0 ? parts.join('\n') : undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* First non-empty line of the rendered content — the card headline for a
|
|
44
|
+
* `presentResult` (#12). Long lines are clamped so a card header stays a
|
|
45
|
+
* header. `undefined` (failure / no text) means "decline the card".
|
|
46
|
+
*/
|
|
47
|
+
export function resultHeadline(
|
|
48
|
+
result: { content?: unknown; isError?: unknown },
|
|
49
|
+
maxLength = 160,
|
|
50
|
+
): string | undefined {
|
|
51
|
+
const text = renderedText(result)
|
|
52
|
+
if (!text) return undefined
|
|
53
|
+
const line = text.split('\n').find((l) => l.trim().length > 0)
|
|
54
|
+
if (!line) return undefined
|
|
55
|
+
const trimmed = line.trim()
|
|
56
|
+
return trimmed.length > maxLength ? `${trimmed.slice(0, maxLength - 1)}…` : trimmed
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Parse a tool's rendered content as JSON — for tools whose `render` emits
|
|
61
|
+
* `JSON.stringify(value, null, 2)` verbatim (review, checkpoint). Returns a
|
|
62
|
+
* plain record, or `undefined` when the content is not an object-shaped JSON
|
|
63
|
+
* document (the presenter then declines the card rather than guess).
|
|
64
|
+
*/
|
|
65
|
+
export function parseRenderedJson(result: { content?: unknown; isError?: unknown }): Record<string, unknown> | undefined {
|
|
66
|
+
const text = renderedText(result)
|
|
67
|
+
if (!text) return undefined
|
|
68
|
+
let parsed: unknown
|
|
69
|
+
try {
|
|
70
|
+
parsed = JSON.parse(text)
|
|
71
|
+
} catch {
|
|
72
|
+
return undefined
|
|
73
|
+
}
|
|
74
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
75
|
+
? (parsed as Record<string, unknown>)
|
|
76
|
+
: undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Narrow an unknown value to a plain record (arrays/null/primitives → undefined). */
|
|
80
|
+
export function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
81
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
82
|
+
? (value as Record<string, unknown>)
|
|
83
|
+
: undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Finite-number narrow for defensively reading fields off a parsed result. */
|
|
87
|
+
export function asNumber(value: unknown): number | undefined {
|
|
88
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
|
89
|
+
}
|