iterate-plugin 3.5.3 → 3.5.4
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/session-hooks.js +48 -4
- package/dist/skill-prompt.js +63 -29
- package/dist/tools/context.js +5 -1
- 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/prune.js +168 -14
- package/dist/tools/review.js +17 -3
- package/dist/tools/transcript.js +28 -6
- package/dist/tools/triage.js +35 -2
- package/dist/transcript.js +10 -2
- package/lib/client.js +37 -11
- package/lib/parse.js +24 -2
- package/package.json +9 -7
- package/src/client/index.ts +23 -3
- package/src/session-hooks.ts +50 -4
- package/src/skill-prompt.ts +63 -29
- package/src/tools/context.ts +5 -1
- 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/prune.ts +179 -14
- package/src/tools/review.ts +16 -3
- package/src/tools/transcript.ts +26 -5
- package/src/tools/triage.ts +36 -2
- package/src/transcript.ts +9 -2
- package/src/types.ts +2 -0
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.
|
|
@@ -28,14 +32,27 @@ import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
|
28
32
|
import { writeTextAtomic, writeJsonAtomic } from '../atomic-fs.ts'
|
|
29
33
|
import { readDecisionEntries, appendDecisionEntry } 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,45 @@ 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
|
+
for (let attempt = 0; attempt < MAX_LOG_REWRITE_RETRIES; attempt++) {
|
|
268
|
+
try {
|
|
269
|
+
const entries = readDecisionEntries(projectRoot)
|
|
270
|
+
const kept = entries.filter((e) => e.timestamp >= cutoff)
|
|
271
|
+
const deleted = entries.length - kept.length
|
|
272
|
+
if (deleted === 0) return { deleted: 0 }
|
|
273
|
+
writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n')
|
|
274
|
+
// A concurrent appender may have landed new lines after our read. If the
|
|
275
|
+
// log grew during the write window, re-read and prune again instead of
|
|
276
|
+
// accepting a lost audit trail.
|
|
277
|
+
const after = readDecisionEntries(projectRoot)
|
|
278
|
+
if (after.length <= kept.length) return { deleted }
|
|
279
|
+
} catch (err) {
|
|
280
|
+
return { deleted: 0, error: `failed to rewrite decision log: ${String(err)}` }
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return { deleted: 0, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Execute the prune: apply every cleanable item reported by `inspectPrune`.
|
|
288
|
+
* Every step is individually hedged so a single disk failure degrades to a
|
|
289
|
+
* reported error rather than aborting the whole cleanup.
|
|
290
|
+
*/
|
|
160
291
|
export function executePrune(
|
|
161
292
|
projectRoot: string,
|
|
162
293
|
retainDays: number,
|
|
@@ -167,6 +298,8 @@ export function executePrune(
|
|
|
167
298
|
deletedBackups: string[]
|
|
168
299
|
deletedTemps: string[]
|
|
169
300
|
trimmedEmptyRounds: number
|
|
301
|
+
deletedExperiences: number
|
|
302
|
+
deletedDefenseEvents: number
|
|
170
303
|
errors: string[]
|
|
171
304
|
} {
|
|
172
305
|
const cutoff = cutoffTimestamp(retainDays)
|
|
@@ -176,22 +309,19 @@ export function executePrune(
|
|
|
176
309
|
deletedBackups: [] as string[],
|
|
177
310
|
deletedTemps: [] as string[],
|
|
178
311
|
trimmedEmptyRounds: 0,
|
|
312
|
+
deletedExperiences: 0,
|
|
313
|
+
deletedDefenseEvents: 0,
|
|
179
314
|
errors: [] as string[],
|
|
180
315
|
}
|
|
181
316
|
|
|
182
317
|
// 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
|
|
318
|
+
// (temp + rename) so a crash mid-write can never truncate the log; the
|
|
319
|
+
// bounded compare-and-append loop guards against a concurrent appender
|
|
320
|
+
// slipping a fresh audit line into the read-before-rewrite window.
|
|
321
|
+
{
|
|
322
|
+
const { deleted, error } = rewriteDecisionLogKeepingRecent(projectRoot, cutoff)
|
|
323
|
+
result.deletedLogEntries = deleted
|
|
324
|
+
if (error) result.errors.push(error)
|
|
195
325
|
}
|
|
196
326
|
|
|
197
327
|
// 2. Remove checkpoint.
|
|
@@ -244,6 +374,34 @@ export function executePrune(
|
|
|
244
374
|
}
|
|
245
375
|
}
|
|
246
376
|
|
|
377
|
+
// 6. Trim the experience bank to its entry cap (newest wins).
|
|
378
|
+
if (report.experienceOversize > 0) {
|
|
379
|
+
try {
|
|
380
|
+
const bank = readExperienceBank(projectRoot)
|
|
381
|
+
const { bank: swept, removed } = sweepExperienceBank(bank, MAX_EXPERIENCE_ENTRIES)
|
|
382
|
+
if (removed > 0) {
|
|
383
|
+
writeExperienceBank(projectRoot, swept)
|
|
384
|
+
result.deletedExperiences = removed
|
|
385
|
+
}
|
|
386
|
+
} catch (err) {
|
|
387
|
+
result.errors.push(`failed to trim experience bank: ${String(err)}`)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// 7. Drop defense events older than retainDays (recounted on write).
|
|
392
|
+
if (report.staleDefenseEvents > 0) {
|
|
393
|
+
try {
|
|
394
|
+
const stream = readDefenseEvents(projectRoot)
|
|
395
|
+
const { stream: swept, removed } = sweepDefenseEvents(stream, cutoff)
|
|
396
|
+
if (removed > 0) {
|
|
397
|
+
writeDefenseEvents(projectRoot, swept)
|
|
398
|
+
result.deletedDefenseEvents = removed
|
|
399
|
+
}
|
|
400
|
+
} catch (err) {
|
|
401
|
+
result.errors.push(`failed to sweep defense events: ${String(err)}`)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
247
405
|
return result
|
|
248
406
|
}
|
|
249
407
|
|
|
@@ -260,7 +418,8 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
|
|
|
260
418
|
'Inspect or clean up old iterate runtime artifacts (.iterate/). ' +
|
|
261
419
|
'Defaults to dry-run (report-only, no deletion). Pass `dryRun: false` to actually prune. ' +
|
|
262
420
|
'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds, ' +
|
|
263
|
-
'
|
|
421
|
+
'stray temp files left by crashed atomic writes, experience-bank entries beyond the entry cap, ' +
|
|
422
|
+
'and stale defense events. ' +
|
|
264
423
|
'Each deletion is logged to the decision log.',
|
|
265
424
|
parameters: {
|
|
266
425
|
dryRun: {
|
|
@@ -302,6 +461,8 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
|
|
|
302
461
|
` Stale backups to delete: ${(report?.staleBackups as string[] | undefined)?.length ?? 0}`,
|
|
303
462
|
` Stray temp files to delete: ${(report?.staleTemps as string[] | undefined)?.length ?? 0}`,
|
|
304
463
|
` Empty rounds to trim: ${(report?.emptyRounds as number[] | undefined)?.length ?? 0}`,
|
|
464
|
+
` Experience entries to drop (over ${report?.totalExperiences ?? '?'} cap): ${report?.experienceOversize ?? 0} of ${report?.totalExperiences ?? '?'}`,
|
|
465
|
+
` Stale defense events to remove: ${report?.staleDefenseEvents ?? 0} (of ${report?.totalDefenseEvents ?? '?'})`,
|
|
305
466
|
'',
|
|
306
467
|
'Pass dryRun:false to execute the prune.',
|
|
307
468
|
]
|
|
@@ -314,6 +475,8 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
|
|
|
314
475
|
` Deleted ${(result?.deletedBackups as string[] | undefined)?.length ?? 0} stale backups.`,
|
|
315
476
|
` Deleted ${(result?.deletedTemps as string[] | undefined)?.length ?? 0} stray temp files.`,
|
|
316
477
|
` Trimmed ${result?.trimmedEmptyRounds ?? 0} empty rounds.`,
|
|
478
|
+
` Trimmed ${result?.deletedExperiences ?? 0} experience entries (over the entry cap).`,
|
|
479
|
+
` Removed ${result?.deletedDefenseEvents ?? 0} stale defense events.`,
|
|
317
480
|
]
|
|
318
481
|
const errs = (result?.errors as string[] | undefined) ?? []
|
|
319
482
|
if (errs.length > 0) {
|
|
@@ -356,6 +519,8 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
|
|
|
356
519
|
deletedCheckpoint: result.deletedCheckpoint,
|
|
357
520
|
deletedBackups: result.deletedBackups.length,
|
|
358
521
|
trimmedEmptyRounds: result.trimmedEmptyRounds,
|
|
522
|
+
deletedExperiences: result.deletedExperiences,
|
|
523
|
+
deletedDefenseEvents: result.deletedDefenseEvents,
|
|
359
524
|
},
|
|
360
525
|
})
|
|
361
526
|
|
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). */
|
|
@@ -248,6 +278,10 @@ function applyEntries(
|
|
|
248
278
|
}
|
|
249
279
|
}
|
|
250
280
|
|
|
281
|
+
// Success: bound the accumulation of timestamped backups so a long-lived
|
|
282
|
+
// project never collects an unbounded pile of config snapshots.
|
|
283
|
+
if (backupPath) pruneOldConfigBackups(configPath)
|
|
284
|
+
|
|
251
285
|
return { ok: true, added, skipped, count: merged.length, configPath, backupPath }
|
|
252
286
|
}
|
|
253
287
|
|
package/src/transcript.ts
CHANGED
|
@@ -142,6 +142,7 @@ export class ReviewTranscriptBuilder {
|
|
|
142
142
|
private round = 0
|
|
143
143
|
private maxRounds = 0
|
|
144
144
|
private active = true
|
|
145
|
+
private stoppedReason: string | null = null
|
|
145
146
|
private readonly rounds: LiveRound[] = []
|
|
146
147
|
private readonly convergence: number[] = []
|
|
147
148
|
private readonly globalFindings: TranscriptFinding[] = []
|
|
@@ -203,9 +204,14 @@ export class ReviewTranscriptBuilder {
|
|
|
203
204
|
this.touch()
|
|
204
205
|
}
|
|
205
206
|
|
|
206
|
-
/**
|
|
207
|
-
|
|
207
|
+
/**
|
|
208
|
+
* End the run (stops the "active" pulsing in the UI). `reason` records WHY
|
|
209
|
+
* it ended, so a run stopped by max-rounds or validation is never mistaken
|
|
210
|
+
* for a live run NOR for a clean convergence.
|
|
211
|
+
*/
|
|
212
|
+
finish(reason?: string): void {
|
|
208
213
|
this.active = false
|
|
214
|
+
if (typeof reason === 'string' && reason.trim()) this.stoppedReason = reason.trim()
|
|
209
215
|
this.touch()
|
|
210
216
|
}
|
|
211
217
|
|
|
@@ -443,6 +449,7 @@ export class ReviewTranscriptBuilder {
|
|
|
443
449
|
checkpoint: this.checkpoint,
|
|
444
450
|
timeline: this.timeline,
|
|
445
451
|
nudge: this.nudge,
|
|
452
|
+
stoppedReason: this.stoppedReason,
|
|
446
453
|
approval: {
|
|
447
454
|
active: this.approval !== 'allow',
|
|
448
455
|
policy: this.approval,
|
package/src/types.ts
CHANGED
|
@@ -349,6 +349,8 @@ export interface TranscriptManifest {
|
|
|
349
349
|
}
|
|
350
350
|
/** v3.0: task_mode indicator from harness status */
|
|
351
351
|
taskMode?: 'code' | 'iterate' | null
|
|
352
|
+
/** Why the run ended: converged / max_rounds_reached / aborted_by_validation. */
|
|
353
|
+
stoppedReason?: string | null
|
|
352
354
|
}
|
|
353
355
|
|
|
354
356
|
// ─── v3.0: Quality Gate ──────────────────────────────────────────────────────
|