iterate-plugin 3.5.3 → 3.5.5
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/dist/atomic-fs.js +22 -4
- package/dist/config-loader.js +8 -3
- package/dist/config-write.js +50 -9
- package/dist/evidence.js +28 -1
- package/dist/git-scope.js +14 -5
- package/dist/jobs.js +15 -1
- package/dist/live.js +39 -22
- package/dist/meta-review.js +2 -2
- package/dist/review-scope.js +6 -2
- package/dist/review.js +7 -6
- package/dist/session-hooks.js +48 -4
- package/dist/skill-prompt.js +66 -32
- package/dist/tools/context.js +5 -1
- package/dist/tools/decision-log.js +142 -17
- package/dist/tools/defense-events.js +7 -3
- package/dist/tools/defense-store.js +14 -7
- package/dist/tools/experience-bank.js +6 -3
- package/dist/tools/experience-store.js +24 -12
- package/dist/tools/fix.js +24 -3
- package/dist/tools/prune.js +185 -17
- package/dist/tools/review.js +17 -3
- package/dist/tools/transcript.js +28 -6
- package/dist/tools/triage.js +38 -4
- package/dist/transcript.js +68 -34
- package/lib/client.js +41 -11
- package/lib/parse.js +30 -2
- package/package.json +10 -7
- package/src/atomic-fs.ts +20 -4
- package/src/client/index.ts +23 -3
- package/src/config-loader.ts +8 -3
- package/src/config-write.ts +59 -9
- package/src/evidence.ts +29 -1
- package/src/git-scope.ts +14 -3
- package/src/jobs.ts +15 -1
- package/src/live.ts +41 -22
- package/src/meta-review.ts +2 -2
- package/src/review-scope.ts +6 -2
- package/src/review.ts +7 -6
- package/src/session-hooks.ts +50 -4
- package/src/skill-prompt.ts +66 -32
- package/src/tools/context.ts +5 -1
- package/src/tools/decision-log.ts +136 -16
- package/src/tools/defense-events.ts +7 -3
- package/src/tools/defense-store.ts +14 -7
- package/src/tools/experience-bank.ts +6 -3
- package/src/tools/experience-store.ts +24 -12
- package/src/tools/fix.ts +25 -3
- package/src/tools/prune.ts +194 -17
- package/src/tools/review.ts +16 -3
- package/src/tools/transcript.ts +26 -5
- package/src/tools/triage.ts +39 -4
- package/src/transcript.ts +68 -31
- package/src/types.ts +2 -0
|
@@ -9,6 +9,7 @@ import * as fs from 'node:fs'
|
|
|
9
9
|
import * as path from 'node:path'
|
|
10
10
|
import { iterateDir } from '../paths.ts'
|
|
11
11
|
import { writeJsonAtomic } from '../atomic-fs.ts'
|
|
12
|
+
import { hashString } from './fix.ts'
|
|
12
13
|
import type { ExperienceBank, ExperienceEntry } from '../types.ts'
|
|
13
14
|
|
|
14
15
|
const EXPERIENCE_FILE = 'experience.json'
|
|
@@ -38,12 +39,15 @@ function stringArray(v: unknown): string[] {
|
|
|
38
39
|
* throw or emit NaN. Returns null for non-object entries; every required field
|
|
39
40
|
* gets a safe default.
|
|
40
41
|
*/
|
|
41
|
-
function normalizeEntry(raw: unknown): ExperienceEntry | null {
|
|
42
|
+
function normalizeEntry(raw: unknown, index: number): ExperienceEntry | null {
|
|
42
43
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
|
43
44
|
const e = raw as Record<string, unknown>
|
|
44
45
|
const pattern = typeof e.pattern === 'string' ? e.pattern : ''
|
|
45
46
|
const dimension = typeof e.dimension === 'string' ? e.dimension : ''
|
|
46
|
-
|
|
47
|
+
// Deterministic id (content + position based) so a read→write cycle never
|
|
48
|
+
// churns random ids on entries that were hand-edited without one.
|
|
49
|
+
const derivedId = `exp-${hashString(`${index}|${pattern}|${dimension}`)}`
|
|
50
|
+
const id = typeof e.id === 'string' && e.id ? e.id : derivedId
|
|
47
51
|
if (!pattern && !dimension) return null
|
|
48
52
|
const hitCount = typeof e.hitCount === 'number' && Number.isFinite(e.hitCount) ? e.hitCount : 0
|
|
49
53
|
return {
|
|
@@ -70,7 +74,7 @@ export function readExperienceBank(projectRoot: string): ExperienceBank {
|
|
|
70
74
|
const parsed = JSON.parse(content) as Partial<ExperienceBank>
|
|
71
75
|
if (parsed && Array.isArray(parsed.entries)) {
|
|
72
76
|
const entries = parsed.entries
|
|
73
|
-
.map(normalizeEntry)
|
|
77
|
+
.map((raw, i) => normalizeEntry(raw, i))
|
|
74
78
|
.filter((e): e is ExperienceEntry => e !== null)
|
|
75
79
|
return {
|
|
76
80
|
entries,
|
|
@@ -167,8 +171,11 @@ export type ExperienceEntryInput = Omit<
|
|
|
167
171
|
/**
|
|
168
172
|
* Add or update an experience entry.
|
|
169
173
|
*
|
|
170
|
-
* An entry with an `id` that already exists
|
|
171
|
-
*
|
|
174
|
+
* An entry with an `id` that already exists is treated as an UPDATE + HIT:
|
|
175
|
+
* the caller-supplied fields replace the stored ones and the hitCount is
|
|
176
|
+
* incremented (lastHitAt refreshed) — this fulfills the documented
|
|
177
|
+
* "update a specific entry via add" contract. A NEW entry whose
|
|
178
|
+
* `pattern`+`dimension` pair matches an existing entry is treated as a HIT:
|
|
172
179
|
* the matching entry's hitCount is incremented (lastHitAt refreshed) so
|
|
173
180
|
* repeated encounters of the same pattern do not create duplicates. Otherwise
|
|
174
181
|
* a fresh entry is appended with hitCount 1. Never mutates the input bank.
|
|
@@ -186,11 +193,14 @@ export function upsertExperience(
|
|
|
186
193
|
: bank.entries.find((e) => e.pattern === entry.pattern && e.dimension === entry.dimension)
|
|
187
194
|
|
|
188
195
|
if (existing) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
196
|
+
// Explicit-id updates REPLACE the editable fields (fulfilling the
|
|
197
|
+
// documented "update a specific entry via add" contract); a pattern+
|
|
198
|
+
// dimension HIT only bumps the hit metadata so a repeat encounter never
|
|
199
|
+
// overwrites the curated entry.
|
|
200
|
+
const byId = typeof entry.id === 'string' && entry.id.length > 0
|
|
201
|
+
const updated: ExperienceEntry = byId
|
|
202
|
+
? { ...existing, ...entry, id: existing.id, hitCount: (existing.hitCount ?? 0) + 1, lastHitAt: lastUpdated }
|
|
203
|
+
: { ...existing, hitCount: (existing.hitCount ?? 0) + 1, lastHitAt: lastUpdated }
|
|
194
204
|
return {
|
|
195
205
|
bank: {
|
|
196
206
|
...bank,
|
|
@@ -203,14 +213,16 @@ export function upsertExperience(
|
|
|
203
213
|
}
|
|
204
214
|
}
|
|
205
215
|
|
|
206
|
-
// Add new entry
|
|
216
|
+
// Add new entry. Spread the caller input FIRST so the store-generated
|
|
217
|
+
// `id`/`timestamp`/`hitCount`/`lastHitAt` always win — a hostile or
|
|
218
|
+
// malformed input can never forge its own identity or hit count.
|
|
207
219
|
const id = entry.id || `exp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
208
220
|
const newEntry: ExperienceEntry = {
|
|
221
|
+
...entry,
|
|
209
222
|
id,
|
|
210
223
|
timestamp: lastUpdated,
|
|
211
224
|
hitCount: 1,
|
|
212
225
|
lastHitAt: lastUpdated,
|
|
213
|
-
...entry,
|
|
214
226
|
}
|
|
215
227
|
|
|
216
228
|
return {
|
package/src/tools/fix.ts
CHANGED
|
@@ -366,6 +366,7 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
366
366
|
linesRemoved: { type: 'integer' },
|
|
367
367
|
diffSummary: { type: 'string' },
|
|
368
368
|
backupPath: { type: 'string' },
|
|
369
|
+
warning: { type: 'string' },
|
|
369
370
|
error: { type: 'string' },
|
|
370
371
|
},
|
|
371
372
|
},
|
|
@@ -456,6 +457,17 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
456
457
|
return { ok: false, error: `finding already fixed this run (id: ${id})`, id }
|
|
457
458
|
}
|
|
458
459
|
|
|
460
|
+
// No-op guard: content-identical "fixes" (e.g. a fixer that re-sent the
|
|
461
|
+
// file unchanged) must never burn a backup, a write, or a registry/success
|
|
462
|
+
// record. Placed after the registry check so a re-sent fix of an id that
|
|
463
|
+
// was ALREADY fixed is still reported as "already fixed this run".
|
|
464
|
+
if (added === 0 && removed === 0) {
|
|
465
|
+
return {
|
|
466
|
+
ok: false,
|
|
467
|
+
error: `no changes: the supplied content for ${file} is identical to the current content — apply a real edit`,
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
459
471
|
const target = resolveProjectFile(projectRoot, file)
|
|
460
472
|
if (!target.ok) return { ok: false, error: target.reason }
|
|
461
473
|
|
|
@@ -528,13 +540,16 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
528
540
|
return { ok: false, error: `failed to write fix registry: ${String(err)} (file restored from backup)` }
|
|
529
541
|
}
|
|
530
542
|
|
|
531
|
-
appendDecisionEntry(projectRoot, {
|
|
543
|
+
const logRes = appendDecisionEntry(projectRoot, {
|
|
532
544
|
timestamp,
|
|
533
545
|
round: args.round,
|
|
534
546
|
type: 'atomic_fix',
|
|
535
547
|
data: { id, file, finding: finding.summary, linesAdded: added, linesRemoved: removed },
|
|
536
548
|
})
|
|
537
549
|
|
|
550
|
+
// The fix itself succeeded, but a decision-log write failure would leave
|
|
551
|
+
// the audit trail incomplete — surface it (warning, not fatal) so the
|
|
552
|
+
// model/UI knows the record was not persisted.
|
|
538
553
|
return {
|
|
539
554
|
ok: true,
|
|
540
555
|
id,
|
|
@@ -544,6 +559,7 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
544
559
|
linesRemoved: removed,
|
|
545
560
|
diffSummary: record.diffSummary,
|
|
546
561
|
backupPath,
|
|
562
|
+
...(logRes.error ? { warning: logRes.error } : {}),
|
|
547
563
|
}
|
|
548
564
|
})
|
|
549
565
|
return result
|
|
@@ -710,6 +726,7 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
|
|
|
710
726
|
ok: { type: 'boolean', required: true },
|
|
711
727
|
id: { type: 'string' },
|
|
712
728
|
file: { type: 'string' },
|
|
729
|
+
warning: { type: 'string' },
|
|
713
730
|
error: { type: 'string' },
|
|
714
731
|
},
|
|
715
732
|
},
|
|
@@ -749,7 +766,7 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
|
|
|
749
766
|
return { ok: false, error: `failed to update fix registry: ${String(err)}` }
|
|
750
767
|
}
|
|
751
768
|
|
|
752
|
-
appendDecisionEntry(projectRoot, {
|
|
769
|
+
const logRes = appendDecisionEntry(projectRoot, {
|
|
753
770
|
timestamp: new Date().toISOString(),
|
|
754
771
|
round: record.round,
|
|
755
772
|
type: 'revert',
|
|
@@ -762,7 +779,12 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
|
|
|
762
779
|
// rollback flow.
|
|
763
780
|
await markFixRolledBackInTranscript(projectRoot, id)
|
|
764
781
|
|
|
765
|
-
return {
|
|
782
|
+
return {
|
|
783
|
+
ok: true,
|
|
784
|
+
id,
|
|
785
|
+
file: record.finding.file,
|
|
786
|
+
...(logRes.error ? { warning: logRes.error } : {}),
|
|
787
|
+
}
|
|
766
788
|
},
|
|
767
789
|
}),
|
|
768
790
|
)
|
package/src/tools/prune.ts
CHANGED
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
* - Empty fix rounds (rounds with 0 records).
|
|
14
14
|
* - Stray temp files left behind by a crashed atomic write (see
|
|
15
15
|
* `isPrunableTemp` for the recognized naming conventions).
|
|
16
|
+
* - Experience bank entries beyond `MAX_EXPERIENCE_ENTRIES` (newest kept) —
|
|
17
|
+
* the bank grows across sessions with no natural bound; a sweep caps it.
|
|
18
|
+
* - Defense events older than `retainDays` (stale iteration streams that
|
|
19
|
+
* were never cleared).
|
|
16
20
|
*
|
|
17
21
|
* Security model:
|
|
18
22
|
* - Only operates under the resolved project `.iterate/` directory.
|
|
@@ -26,16 +30,29 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
26
30
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
27
31
|
import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
28
32
|
import { writeTextAtomic, writeJsonAtomic } from '../atomic-fs.ts'
|
|
29
|
-
import { readDecisionEntries, appendDecisionEntry } from './decision-log.ts'
|
|
33
|
+
import { readDecisionEntries, appendDecisionEntry, acquireLogLock } from './decision-log.ts'
|
|
30
34
|
import { readRegistry, removeRecord, recomputeRoundCounts } from './fix.ts'
|
|
35
|
+
import { readExperienceBank, writeExperienceBank } from './experience-store.ts'
|
|
36
|
+
import { readDefenseEvents, writeDefenseEvents } from './defense-store.ts'
|
|
31
37
|
import { iterateDir, fixesDir, checkpointPath, fixRegistryPath } from '../paths.ts'
|
|
32
|
-
import type { FixRegistry } from '../types.ts'
|
|
38
|
+
import type { DefenseEventStream, ExperienceBank, FixRegistry } from '../types.ts'
|
|
33
39
|
|
|
34
40
|
/** Default retention for decision-log entries (in days). */
|
|
35
41
|
const DEFAULT_RETAIN_DAYS = 30
|
|
36
42
|
const MIN_RETAIN_DAYS = 1
|
|
37
43
|
const MAX_RETAIN_DAYS = 365
|
|
38
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Upper bound on experience-bank entries retained by a prune sweep. The bank
|
|
47
|
+
* accumulates curated knowledge across sessions and each entry is small, so a
|
|
48
|
+
* generous cap drops only the oldest tail of a pathologically large bank
|
|
49
|
+
* (never recent learning).
|
|
50
|
+
*/
|
|
51
|
+
export const MAX_EXPERIENCE_ENTRIES = 2000
|
|
52
|
+
|
|
53
|
+
/** How many compare-and-append retries bound the decision-log rewrite race. */
|
|
54
|
+
const MAX_LOG_REWRITE_RETRIES = 3
|
|
55
|
+
|
|
39
56
|
/** Clamp retainDays to a sane range. */
|
|
40
57
|
export function clampRetainDays(days: number | undefined): number {
|
|
41
58
|
if (typeof days !== 'number' || !Number.isInteger(days) || days <= 0) {
|
|
@@ -71,6 +88,62 @@ export function isPrunableTemp(name: string): boolean {
|
|
|
71
88
|
return name.startsWith('.') && name.includes('.tmp-')
|
|
72
89
|
}
|
|
73
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Bound the experience bank to its newest `cap` entries (by timestamp; ties
|
|
93
|
+
* keep their original order via stable sort). Returns the trimmed bank plus
|
|
94
|
+
* how many entries would be dropped. Never mutates the input bank.
|
|
95
|
+
*/
|
|
96
|
+
export function sweepExperienceBank(bank: ExperienceBank, cap: number): { bank: ExperienceBank; removed: number } {
|
|
97
|
+
const size = Number.isFinite(cap) && (cap as number) >= 0 ? Math.floor(cap) : MAX_EXPERIENCE_ENTRIES
|
|
98
|
+
const total = bank.entries.length
|
|
99
|
+
if (total <= size) return { bank, removed: 0 }
|
|
100
|
+
const kept = [...bank.entries]
|
|
101
|
+
.sort((a, b) => (b.timestamp ?? '').localeCompare(a.timestamp ?? ''))
|
|
102
|
+
.slice(0, size)
|
|
103
|
+
// Preserve the original (insertion) order of the surviving entries.
|
|
104
|
+
const keptIds = new Set(kept.map((e) => e.id))
|
|
105
|
+
const ordered = bank.entries.filter((e) => keptIds.has(e.id))
|
|
106
|
+
return {
|
|
107
|
+
bank: { ...bank, entries: ordered, lastUpdated: new Date().toISOString() },
|
|
108
|
+
removed: total - ordered.length,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Drop defense events older than `cutoff` from the stream using an ISO
|
|
114
|
+
* timestamp comparison, recomputing the counts so they can never drift from
|
|
115
|
+
* the surviving events. Never mutates the input stream.
|
|
116
|
+
*/
|
|
117
|
+
export function sweepDefenseEvents(
|
|
118
|
+
stream: DefenseEventStream,
|
|
119
|
+
cutoff: string,
|
|
120
|
+
): { stream: DefenseEventStream; removed: number } {
|
|
121
|
+
const kept = stream.events.filter((e) => e.timestamp >= cutoff)
|
|
122
|
+
if (kept.length === stream.events.length) return { stream, removed: 0 }
|
|
123
|
+
return {
|
|
124
|
+
stream: {
|
|
125
|
+
events: kept,
|
|
126
|
+
lastUpdated: new Date().toISOString(),
|
|
127
|
+
counts: computeDefenseCounts(kept),
|
|
128
|
+
},
|
|
129
|
+
removed: stream.events.length - kept.length,
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Recompute per-type defense counts from a list of events. */
|
|
134
|
+
function computeDefenseCounts(events: DefenseEventStream['events']): DefenseEventStream['counts'] {
|
|
135
|
+
const counts: DefenseEventStream['counts'] = {
|
|
136
|
+
precondition_failed: 0,
|
|
137
|
+
rollback: 0,
|
|
138
|
+
invariant_violated: 0,
|
|
139
|
+
assumption_falsified: 0,
|
|
140
|
+
}
|
|
141
|
+
for (const e of events) {
|
|
142
|
+
if (e.type in counts) counts[e.type]++
|
|
143
|
+
}
|
|
144
|
+
return counts
|
|
145
|
+
}
|
|
146
|
+
|
|
74
147
|
/**
|
|
75
148
|
* Inspect the runtime state and report what would be pruned.
|
|
76
149
|
* Pure (no deletions). Returns a structured report.
|
|
@@ -86,6 +159,10 @@ export function inspectPrune(
|
|
|
86
159
|
emptyRounds: number[]
|
|
87
160
|
totalLogEntries: number
|
|
88
161
|
registryRounds: number
|
|
162
|
+
totalExperiences: number
|
|
163
|
+
experienceOversize: number
|
|
164
|
+
totalDefenseEvents: number
|
|
165
|
+
staleDefenseEvents: number
|
|
89
166
|
} {
|
|
90
167
|
const cutoff = cutoffTimestamp(retainDays)
|
|
91
168
|
|
|
@@ -142,6 +219,17 @@ export function inspectPrune(
|
|
|
142
219
|
}
|
|
143
220
|
const staleTemps = iterateEntries.filter((f) => isPrunableTemp(f)).sort()
|
|
144
221
|
|
|
222
|
+
// 6. Experience-bank oversize (entries beyond the entry cap). The bank grows
|
|
223
|
+
// across sessions with no natural bound — a sweep reports the tail that a
|
|
224
|
+
// prune would drop (newest `MAX_EXPERIENCE_ENTRIES` preserved).
|
|
225
|
+
const bank = readExperienceBank(projectRoot)
|
|
226
|
+
const sweptBank = sweepExperienceBank(bank, MAX_EXPERIENCE_ENTRIES)
|
|
227
|
+
|
|
228
|
+
// 7. Defense events older than retainDays (a previous iteration's stream
|
|
229
|
+
// that was never cleared).
|
|
230
|
+
const defenseStream = readDefenseEvents(projectRoot)
|
|
231
|
+
const sweptDefense = sweepDefenseEvents(defenseStream, cutoff)
|
|
232
|
+
|
|
145
233
|
return {
|
|
146
234
|
oldLogEntries,
|
|
147
235
|
hasCheckpoint,
|
|
@@ -150,6 +238,10 @@ export function inspectPrune(
|
|
|
150
238
|
emptyRounds,
|
|
151
239
|
totalLogEntries: entries.length,
|
|
152
240
|
registryRounds: registry.rounds.length,
|
|
241
|
+
totalExperiences: bank.entries.length,
|
|
242
|
+
experienceOversize: sweptBank.removed,
|
|
243
|
+
totalDefenseEvents: defenseStream.events.length,
|
|
244
|
+
staleDefenseEvents: sweptDefense.removed,
|
|
153
245
|
}
|
|
154
246
|
}
|
|
155
247
|
|
|
@@ -157,6 +249,54 @@ export function inspectPrune(
|
|
|
157
249
|
* Actually prune the runtime artifacts (only called when dryRun=false).
|
|
158
250
|
* Returns a detailed report of what was deleted.
|
|
159
251
|
*/
|
|
252
|
+
/**
|
|
253
|
+
* Rewrite the decision log to the entries younger than `cutoff`.
|
|
254
|
+
*
|
|
255
|
+
* The read-before-rewrite is a TOCTOU hot spot: a concurrent worker can append
|
|
256
|
+
* a fresh audit line between our read and our atomic rewrite, and an atomic
|
|
257
|
+
* rename would silently discard it. So instead of a single read+write we use a
|
|
258
|
+
* bounded compare-and-append loop — after each rewrite we re-read the log and
|
|
259
|
+
* retry whenever the file grew (a concurrent append slipped in). The loop
|
|
260
|
+
* terminates when the file is stable or after `MAX_LOG_REWRITE_RETRIES`, and a
|
|
261
|
+
* failure here is surfaced as a structured error instead of truncating the log.
|
|
262
|
+
*
|
|
263
|
+
* @returns the number of entries removed (best-effort) or 0 on error.
|
|
264
|
+
*/
|
|
265
|
+
function rewriteDecisionLogKeepingRecent(projectRoot: string, cutoff: string): { deleted: number; error?: string } {
|
|
266
|
+
const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl')
|
|
267
|
+
// Take the cross-process log lock for the ENTIRE loop: with the mutex held,
|
|
268
|
+
// no concurrent appender can slip a fresh line into the read-before-rewrite
|
|
269
|
+
// window, so the first attempt is nearly always the only one. (The retry
|
|
270
|
+
// loop stays as defense-in-depth for the lock-unavailable path.)
|
|
271
|
+
const release = acquireLogLock(projectRoot)
|
|
272
|
+
try {
|
|
273
|
+
for (let attempt = 0; attempt < MAX_LOG_REWRITE_RETRIES; attempt++) {
|
|
274
|
+
try {
|
|
275
|
+
const entries = readDecisionEntries(projectRoot)
|
|
276
|
+
const kept = entries.filter((e) => e.timestamp >= cutoff)
|
|
277
|
+
const deleted = entries.length - kept.length
|
|
278
|
+
if (deleted === 0) return { deleted: 0 }
|
|
279
|
+
writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n')
|
|
280
|
+
// A concurrent appender (cross-process, lock-unavailable path) may have
|
|
281
|
+
// landed new lines after our read. If the log grew during the write
|
|
282
|
+
// window, re-read and prune again instead of accepting a lost audit trail.
|
|
283
|
+
const after = readDecisionEntries(projectRoot)
|
|
284
|
+
if (after.length <= kept.length) return { deleted }
|
|
285
|
+
} catch (err) {
|
|
286
|
+
return { deleted: 0, error: `failed to rewrite decision log: ${String(err)}` }
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return { deleted: 0, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` }
|
|
290
|
+
} finally {
|
|
291
|
+
release()
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Execute the prune: apply every cleanable item reported by `inspectPrune`.
|
|
297
|
+
* Every step is individually hedged so a single disk failure degrades to a
|
|
298
|
+
* reported error rather than aborting the whole cleanup.
|
|
299
|
+
*/
|
|
160
300
|
export function executePrune(
|
|
161
301
|
projectRoot: string,
|
|
162
302
|
retainDays: number,
|
|
@@ -167,6 +307,8 @@ export function executePrune(
|
|
|
167
307
|
deletedBackups: string[]
|
|
168
308
|
deletedTemps: string[]
|
|
169
309
|
trimmedEmptyRounds: number
|
|
310
|
+
deletedExperiences: number
|
|
311
|
+
deletedDefenseEvents: number
|
|
170
312
|
errors: string[]
|
|
171
313
|
} {
|
|
172
314
|
const cutoff = cutoffTimestamp(retainDays)
|
|
@@ -176,22 +318,19 @@ export function executePrune(
|
|
|
176
318
|
deletedBackups: [] as string[],
|
|
177
319
|
deletedTemps: [] as string[],
|
|
178
320
|
trimmedEmptyRounds: 0,
|
|
321
|
+
deletedExperiences: 0,
|
|
322
|
+
deletedDefenseEvents: 0,
|
|
179
323
|
errors: [] as string[],
|
|
180
324
|
}
|
|
181
325
|
|
|
182
326
|
// 1. Rewrite the decision log, keeping only recent entries. Atomic
|
|
183
|
-
// (temp + rename) so a crash mid-write can never truncate the log
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n')
|
|
191
|
-
}
|
|
192
|
-
} catch (err) {
|
|
193
|
-
result.errors.push(`failed to rewrite decision log: ${String(err)}`)
|
|
194
|
-
result.deletedLogEntries = 0
|
|
327
|
+
// (temp + rename) so a crash mid-write can never truncate the log; the
|
|
328
|
+
// bounded compare-and-append loop guards against a concurrent appender
|
|
329
|
+
// slipping a fresh audit line into the read-before-rewrite window.
|
|
330
|
+
{
|
|
331
|
+
const { deleted, error } = rewriteDecisionLogKeepingRecent(projectRoot, cutoff)
|
|
332
|
+
result.deletedLogEntries = deleted
|
|
333
|
+
if (error) result.errors.push(error)
|
|
195
334
|
}
|
|
196
335
|
|
|
197
336
|
// 2. Remove checkpoint.
|
|
@@ -244,6 +383,34 @@ export function executePrune(
|
|
|
244
383
|
}
|
|
245
384
|
}
|
|
246
385
|
|
|
386
|
+
// 6. Trim the experience bank to its entry cap (newest wins).
|
|
387
|
+
if (report.experienceOversize > 0) {
|
|
388
|
+
try {
|
|
389
|
+
const bank = readExperienceBank(projectRoot)
|
|
390
|
+
const { bank: swept, removed } = sweepExperienceBank(bank, MAX_EXPERIENCE_ENTRIES)
|
|
391
|
+
if (removed > 0) {
|
|
392
|
+
writeExperienceBank(projectRoot, swept)
|
|
393
|
+
result.deletedExperiences = removed
|
|
394
|
+
}
|
|
395
|
+
} catch (err) {
|
|
396
|
+
result.errors.push(`failed to trim experience bank: ${String(err)}`)
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// 7. Drop defense events older than retainDays (recounted on write).
|
|
401
|
+
if (report.staleDefenseEvents > 0) {
|
|
402
|
+
try {
|
|
403
|
+
const stream = readDefenseEvents(projectRoot)
|
|
404
|
+
const { stream: swept, removed } = sweepDefenseEvents(stream, cutoff)
|
|
405
|
+
if (removed > 0) {
|
|
406
|
+
writeDefenseEvents(projectRoot, swept)
|
|
407
|
+
result.deletedDefenseEvents = removed
|
|
408
|
+
}
|
|
409
|
+
} catch (err) {
|
|
410
|
+
result.errors.push(`failed to sweep defense events: ${String(err)}`)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
247
414
|
return result
|
|
248
415
|
}
|
|
249
416
|
|
|
@@ -260,7 +427,8 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
|
|
|
260
427
|
'Inspect or clean up old iterate runtime artifacts (.iterate/). ' +
|
|
261
428
|
'Defaults to dry-run (report-only, no deletion). Pass `dryRun: false` to actually prune. ' +
|
|
262
429
|
'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds, ' +
|
|
263
|
-
'
|
|
430
|
+
'stray temp files left by crashed atomic writes, experience-bank entries beyond the entry cap, ' +
|
|
431
|
+
'and stale defense events. ' +
|
|
264
432
|
'Each deletion is logged to the decision log.',
|
|
265
433
|
parameters: {
|
|
266
434
|
dryRun: {
|
|
@@ -302,6 +470,8 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
|
|
|
302
470
|
` Stale backups to delete: ${(report?.staleBackups as string[] | undefined)?.length ?? 0}`,
|
|
303
471
|
` Stray temp files to delete: ${(report?.staleTemps as string[] | undefined)?.length ?? 0}`,
|
|
304
472
|
` Empty rounds to trim: ${(report?.emptyRounds as number[] | undefined)?.length ?? 0}`,
|
|
473
|
+
` Experience entries to drop (over ${report?.totalExperiences ?? '?'} cap): ${report?.experienceOversize ?? 0} of ${report?.totalExperiences ?? '?'}`,
|
|
474
|
+
` Stale defense events to remove: ${report?.staleDefenseEvents ?? 0} (of ${report?.totalDefenseEvents ?? '?'})`,
|
|
305
475
|
'',
|
|
306
476
|
'Pass dryRun:false to execute the prune.',
|
|
307
477
|
]
|
|
@@ -314,6 +484,8 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
|
|
|
314
484
|
` Deleted ${(result?.deletedBackups as string[] | undefined)?.length ?? 0} stale backups.`,
|
|
315
485
|
` Deleted ${(result?.deletedTemps as string[] | undefined)?.length ?? 0} stray temp files.`,
|
|
316
486
|
` Trimmed ${result?.trimmedEmptyRounds ?? 0} empty rounds.`,
|
|
487
|
+
` Trimmed ${result?.deletedExperiences ?? 0} experience entries (over the entry cap).`,
|
|
488
|
+
` Removed ${result?.deletedDefenseEvents ?? 0} stale defense events.`,
|
|
317
489
|
]
|
|
318
490
|
const errs = (result?.errors as string[] | undefined) ?? []
|
|
319
491
|
if (errs.length > 0) {
|
|
@@ -344,8 +516,10 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
|
|
|
344
516
|
|
|
345
517
|
const result = executePrune(projectRoot, retainDays, report)
|
|
346
518
|
|
|
347
|
-
// Log the prune to the decision log.
|
|
348
|
-
|
|
519
|
+
// Log the prune to the decision log. The mutations already happened, so
|
|
520
|
+
// a failed log append is reported via `errors` — a silent audit miss
|
|
521
|
+
// (F2) must never pretend every deletion was recorded.
|
|
522
|
+
const logRes = appendDecisionEntry(projectRoot, {
|
|
349
523
|
timestamp: new Date().toISOString(),
|
|
350
524
|
round: 0,
|
|
351
525
|
type: 'decision',
|
|
@@ -356,8 +530,11 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
|
|
|
356
530
|
deletedCheckpoint: result.deletedCheckpoint,
|
|
357
531
|
deletedBackups: result.deletedBackups.length,
|
|
358
532
|
trimmedEmptyRounds: result.trimmedEmptyRounds,
|
|
533
|
+
deletedExperiences: result.deletedExperiences,
|
|
534
|
+
deletedDefenseEvents: result.deletedDefenseEvents,
|
|
359
535
|
},
|
|
360
536
|
})
|
|
537
|
+
if (logRes.error) result.errors.push(logRes.error)
|
|
361
538
|
|
|
362
539
|
return {
|
|
363
540
|
ok: true,
|
package/src/tools/review.ts
CHANGED
|
@@ -265,9 +265,22 @@ export function registerReviewTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
265
265
|
const coverageEnabled = config.reviewer?.coverage_validation !== false
|
|
266
266
|
let coverage: CoverageResult | null = null
|
|
267
267
|
if (coverageEnabled) {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
268
|
+
// Build the assigned inventory the SAME way `plan` builds it: a
|
|
269
|
+
// changed-only scope uses the resolved git-diff file set, and
|
|
270
|
+
// falls back to the FULL walk when git is unavailable or nothing
|
|
271
|
+
// changed (mirrors buildReviewPlan's effective-scope decision), so
|
|
272
|
+
// the coverage ratio reflects the files reviewers were actually
|
|
273
|
+
// told to read — never a vacuous 1.0 on an empty inventory.
|
|
274
|
+
let assigned: string[]
|
|
275
|
+
if (config.review?.scope === 'changed-only') {
|
|
276
|
+
const gitScope = await resolveChangedFiles(projectRoot, config.git?.target_branch ?? 'main')
|
|
277
|
+
assigned =
|
|
278
|
+
gitScope.changedFiles.length > 0
|
|
279
|
+
? collectScopeFiles(projectRoot, { scope: 'changed-only', changedFiles: gitScope.changedFiles })
|
|
280
|
+
: collectScopeFiles(projectRoot, { scope: 'full' })
|
|
281
|
+
} else {
|
|
282
|
+
assigned = collectScopeFiles(projectRoot, { scope: 'full' })
|
|
283
|
+
}
|
|
271
284
|
const readFiles = Array.isArray((source as unknown as { readFiles?: unknown }).readFiles)
|
|
272
285
|
? ((source as unknown as { readFiles?: unknown }).readFiles as string[])
|
|
273
286
|
: null
|
package/src/tools/transcript.ts
CHANGED
|
@@ -160,6 +160,7 @@ export function registerTranscriptTool(ctx: {
|
|
|
160
160
|
description: 'For `capture`: array of applied fixes [{id, file, round, summary, linesAdded, linesRemoved, success}].',
|
|
161
161
|
},
|
|
162
162
|
refReadFiles: { type: 'json', description: 'For `capture`: flat array of all read files across rounds (optional).' },
|
|
163
|
+
stoppedReason: { type: 'string', description: 'For `capture`: why the run ended — "converged" | "max_rounds_reached" | "aborted_by_validation". When omitted, derived from the convergence trend (trailing 0 = converged, otherwise = max_rounds_reached once a round ran).' },
|
|
163
164
|
text: { type: 'string', description: 'For `nudge`: steering text to set (or null to clear).' },
|
|
164
165
|
path: { type: 'string', description: 'Project root directory (default: current working directory).' },
|
|
165
166
|
},
|
|
@@ -231,13 +232,24 @@ export function registerTranscriptTool(ctx: {
|
|
|
231
232
|
}
|
|
232
233
|
// Rehydrating a parsed-but-malformed manifest (e.g. `rounds: [null]`
|
|
233
234
|
// or a non-object) must never crash the nudge — degrade to a fresh
|
|
234
|
-
// builder so the steering text still lands.
|
|
235
|
+
// builder so the steering text still lands. The fallback must keep
|
|
236
|
+
// the ORIGINAL run identity (mode/taskMode/goal/maxRounds) so a
|
|
237
|
+
// stray malformed field can never silently rewrite a dry-run run
|
|
238
|
+
// into a `normal` one.
|
|
239
|
+
const fallbackOpts = {
|
|
240
|
+
project: projectRoot,
|
|
241
|
+
mode: (manifest?.mode === 'dry-run' || manifest?.mode === 'normal' ? manifest.mode : null) as 'dry-run' | 'normal' | null,
|
|
242
|
+
taskMode: (manifest?.taskMode === 'code' || manifest?.taskMode === 'iterate' ? manifest.taskMode : null) as 'code' | 'iterate' | null,
|
|
243
|
+
approval,
|
|
244
|
+
goal: typeof manifest?.goal === 'string' ? manifest.goal : '',
|
|
245
|
+
maxRounds: typeof manifest?.maxRounds === 'number' ? manifest.maxRounds : 0,
|
|
246
|
+
}
|
|
235
247
|
let builder: ReviewTranscriptBuilder
|
|
236
248
|
if (manifest) {
|
|
237
249
|
try {
|
|
238
250
|
builder = rehydrateBuilder(manifest, approval)
|
|
239
251
|
} catch {
|
|
240
|
-
builder = new ReviewTranscriptBuilder(
|
|
252
|
+
builder = new ReviewTranscriptBuilder(fallbackOpts)
|
|
241
253
|
}
|
|
242
254
|
} else {
|
|
243
255
|
builder = new ReviewTranscriptBuilder({ project: projectRoot, mode: 'normal', approval })
|
|
@@ -305,9 +317,18 @@ export function registerTranscriptTool(ctx: {
|
|
|
305
317
|
if (record) builder.fix(record)
|
|
306
318
|
}
|
|
307
319
|
}
|
|
308
|
-
//
|
|
320
|
+
// End state: an explicit stoppedReason wins; otherwise derive it from
|
|
321
|
+
// the trend. A run that settled closes as "converged"; a run that did
|
|
322
|
+
// real work but never trended to 0 closes as "max_rounds_reached";
|
|
323
|
+
// a capture with no rounds stays "active" (nothing recorded yet).
|
|
324
|
+
const explicitReason =
|
|
325
|
+
typeof args.stoppedReason === 'string' && args.stoppedReason.trim()
|
|
326
|
+
? args.stoppedReason.trim()
|
|
327
|
+
: ''
|
|
309
328
|
const last = convergence[convergence.length - 1]
|
|
310
|
-
if (
|
|
329
|
+
if (explicitReason) builder.finish(explicitReason)
|
|
330
|
+
else if (convergence.length > 0 && last === 0) builder.finish('converged')
|
|
331
|
+
else if (roundsExecuted > 0) builder.finish('max_rounds_reached')
|
|
311
332
|
|
|
312
333
|
const persisted = await persistChecked(file, builder.serialize())
|
|
313
334
|
if (!persisted.ok) {
|
|
@@ -353,7 +374,7 @@ function rehydrateBuilder(manifest: TranscriptManifest, approval: 'ask' | 'deny'
|
|
|
353
374
|
if (Array.isArray(manifest.fixes)) for (const fx of manifest.fixes) builder.fix(fx as TranscriptFix)
|
|
354
375
|
if (Array.isArray(manifest.timeline)) for (const e of manifest.timeline) builder.decision(e)
|
|
355
376
|
builder.setNudge(manifest.nudge?.text ?? null)
|
|
356
|
-
if (!manifest.active) builder.finish()
|
|
377
|
+
if (!manifest.active) builder.finish(manifest.stoppedReason ?? undefined)
|
|
357
378
|
return builder
|
|
358
379
|
}
|
|
359
380
|
|
package/src/tools/triage.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { copyFileSync, existsSync, readFileSync, rmSync } from 'node:fs'
|
|
2
|
-
import { join } from 'node:path'
|
|
1
|
+
import { copyFileSync, existsSync, readdirSync, readFileSync, rmSync } from 'node:fs'
|
|
2
|
+
import { join, dirname, basename } from 'node:path'
|
|
3
3
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
4
4
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
5
5
|
import yaml from 'js-yaml'
|
|
@@ -16,6 +16,9 @@ const KNOWN_INTENTIONAL_KEY = 'known_intentional'
|
|
|
16
16
|
/** Max entries per single `apply` call. */
|
|
17
17
|
const MAX_ENTRIES = 500
|
|
18
18
|
|
|
19
|
+
/** How many timestamped config backups are retained (older ones are removed). */
|
|
20
|
+
export const MAX_TRIAGE_BACKUPS = 5
|
|
21
|
+
|
|
19
22
|
/** Whole-file marker line (matches review.ts filterKnownIntentional semantics). */
|
|
20
23
|
const WHOLE_FILE_LINE = 0
|
|
21
24
|
|
|
@@ -171,6 +174,33 @@ export function backupSuffix(now = new Date()): string {
|
|
|
171
174
|
return now.toISOString().replace(/[:.]/g, '-')
|
|
172
175
|
}
|
|
173
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Bound the timestamped config backups: after a fresh one is written, delete
|
|
179
|
+
* every older `config.bak-*` file beyond the newest `keep`. Best-effort — a
|
|
180
|
+
* filesystem failure here must never fail the apply that just succeeded
|
|
181
|
+
* (backups are a safety net, not a requirement).
|
|
182
|
+
* @returns the absolute paths of the backups that were removed.
|
|
183
|
+
*/
|
|
184
|
+
export function pruneOldConfigBackups(configPath: string, keep = MAX_TRIAGE_BACKUPS): string[] {
|
|
185
|
+
const removed: string[] = []
|
|
186
|
+
try {
|
|
187
|
+
const dir = dirname(configPath)
|
|
188
|
+
const prefix = `${basename(configPath)}.bak-`
|
|
189
|
+
const matches = readdirSync(dir)
|
|
190
|
+
.filter((f) => f.startsWith(prefix))
|
|
191
|
+
.sort()
|
|
192
|
+
// Keep the newest `keep`; remove everything older.
|
|
193
|
+
const doomed = matches.slice(0, Math.max(0, matches.length - keep))
|
|
194
|
+
for (const f of doomed) {
|
|
195
|
+
rmSync(join(dir, f), { force: true })
|
|
196
|
+
removed.push(join(dir, f))
|
|
197
|
+
}
|
|
198
|
+
} catch {
|
|
199
|
+
// Best-effort cleanup — never surface a cleanup failure.
|
|
200
|
+
}
|
|
201
|
+
return removed
|
|
202
|
+
}
|
|
203
|
+
|
|
174
204
|
// ─── File I/O ───────────────────────────────────────────────────────────────
|
|
175
205
|
|
|
176
206
|
/** Load the raw config object (empty when the file is missing). */
|
|
@@ -178,8 +208,9 @@ function readConfigFile(configPath: string): Record<string, unknown> {
|
|
|
178
208
|
if (!existsSync(configPath)) return {}
|
|
179
209
|
const content = readFileSync(configPath, 'utf-8')
|
|
180
210
|
const parsed = yaml.load(content)
|
|
181
|
-
if (!parsed || typeof parsed !== 'object') {
|
|
182
|
-
// A config that exists but is
|
|
211
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
212
|
+
// A config that exists but is NOT a YAML mapping (including a YAML list,
|
|
213
|
+
// which `typeof [] === 'object'` alone would accept) must NOT be silently
|
|
183
214
|
// treated as empty: writing over it would destroy user data. Callers
|
|
184
215
|
// surface this as an error and refuse to write.
|
|
185
216
|
throw new Error('existing iterate.config.yaml is not a valid YAML mapping')
|
|
@@ -248,6 +279,10 @@ function applyEntries(
|
|
|
248
279
|
}
|
|
249
280
|
}
|
|
250
281
|
|
|
282
|
+
// Success: bound the accumulation of timestamped backups so a long-lived
|
|
283
|
+
// project never collects an unbounded pile of config snapshots.
|
|
284
|
+
if (backupPath) pruneOldConfigBackups(configPath)
|
|
285
|
+
|
|
251
286
|
return { ok: true, added, skipped, count: merged.length, configPath, backupPath }
|
|
252
287
|
}
|
|
253
288
|
|