iterate-plugin 3.4.1 → 3.5.1
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 +4 -4
- package/README.zh-CN.md +4 -4
- package/dist/atomic-fs.js +77 -0
- package/dist/config-write.js +3 -2
- package/dist/live.js +10 -4
- package/dist/skill-prompt.js +2 -2
- package/dist/tools/checkpoint.js +31 -11
- package/dist/tools/config.js +14 -0
- package/dist/tools/context.js +3 -0
- package/dist/tools/decision-log.js +25 -10
- package/dist/tools/defense-events.js +3 -0
- package/dist/tools/defense-store.js +6 -2
- package/dist/tools/experience-bank.js +42 -3
- package/dist/tools/experience-store.js +30 -2
- package/dist/tools/fix.js +40 -4
- package/dist/tools/history.js +3 -0
- package/dist/tools/prune.js +50 -6
- package/dist/tools/quality-gate.js +24 -4
- package/dist/tools/quality-store.js +20 -1
- package/dist/tools/transcript.js +38 -5
- package/dist/tools/triage.js +6 -2
- package/dist/tools/validate.js +32 -3
- package/package.json +5 -2
- package/src/atomic-fs.ts +83 -0
- package/src/config-write.ts +3 -2
- package/src/live.ts +10 -4
- package/src/skill-prompt.ts +2 -2
- package/src/tools/checkpoint.ts +37 -11
- package/src/tools/config.ts +14 -0
- package/src/tools/context.ts +3 -0
- package/src/tools/decision-log.ts +35 -10
- package/src/tools/defense-events.ts +3 -0
- package/src/tools/defense-store.ts +6 -2
- package/src/tools/experience-bank.ts +43 -3
- package/src/tools/experience-store.ts +32 -2
- package/src/tools/fix.ts +39 -4
- package/src/tools/history.ts +3 -0
- package/src/tools/prune.ts +51 -6
- package/src/tools/quality-gate.ts +25 -4
- package/src/tools/quality-store.ts +21 -1
- package/src/tools/transcript.ts +39 -5
- package/src/tools/triage.ts +6 -2
- package/src/tools/validate.ts +32 -3
- package/src/types.ts +2 -0
package/src/tools/prune.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* - Fix backups left over from old rounds (backups whose fix-id no longer
|
|
12
12
|
* appears in the registry).
|
|
13
13
|
* - Empty fix rounds (rounds with 0 records).
|
|
14
|
+
* - Stray temp files left behind by a crashed atomic write (see
|
|
15
|
+
* `isPrunableTemp` for the recognized naming conventions).
|
|
14
16
|
*
|
|
15
17
|
* Security model:
|
|
16
18
|
* - Only operates under the resolved project `.iterate/` directory.
|
|
@@ -18,11 +20,12 @@
|
|
|
18
20
|
* - Each deletion is logged to the decision log (when not dry-run).
|
|
19
21
|
*/
|
|
20
22
|
|
|
21
|
-
import { existsSync, readdirSync,
|
|
23
|
+
import { existsSync, readdirSync, rmSync, unlinkSync } from 'node:fs'
|
|
22
24
|
import { join } from 'node:path'
|
|
23
25
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
24
26
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
25
27
|
import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
28
|
+
import { writeTextAtomic, writeJsonAtomic } from '../atomic-fs.ts'
|
|
26
29
|
import { readDecisionEntries, appendDecisionEntry } from './decision-log.ts'
|
|
27
30
|
import { readRegistry, removeRecord, recomputeRoundCounts } from './fix.ts'
|
|
28
31
|
import { iterateDir, fixesDir, checkpointPath, fixRegistryPath } from '../paths.ts'
|
|
@@ -48,6 +51,26 @@ export function cutoffTimestamp(retainDays: number): string {
|
|
|
48
51
|
return d.toISOString()
|
|
49
52
|
}
|
|
50
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Whether a file name inside `.iterate/` is a prunable leftover temp file.
|
|
56
|
+
*
|
|
57
|
+
* Recognized conventions (all written to the SAME directory as their target,
|
|
58
|
+
* so they can never be confused with real state files):
|
|
59
|
+
* - `.<name>.tmp-<pid>-<rand>` — current atomic-fs.ts temp prefix;
|
|
60
|
+
* - `.tmp-<pid>-<rand>` — legacy temp prefix (older plugin builds);
|
|
61
|
+
* - `<name>.tmp` — legacy transcript.ts / live.ts temp suffix.
|
|
62
|
+
*
|
|
63
|
+
* Pure and exported for unit tests. A live writer's temp file is only visible
|
|
64
|
+
* to it (unique name), so deleting any match is always safe.
|
|
65
|
+
*/
|
|
66
|
+
export function isPrunableTemp(name: string): boolean {
|
|
67
|
+
if (typeof name !== 'string' || name.length === 0 || name === '.' || name === '..') return false
|
|
68
|
+
if (name.startsWith('.tmp-')) return true
|
|
69
|
+
if (name.endsWith('.tmp')) return true
|
|
70
|
+
// .<basename>.tmp-<pid>-<rand>: starts with a dot and contains ".tmp-" after it.
|
|
71
|
+
return name.startsWith('.') && name.includes('.tmp-')
|
|
72
|
+
}
|
|
73
|
+
|
|
51
74
|
/**
|
|
52
75
|
* Inspect the runtime state and report what would be pruned.
|
|
53
76
|
* Pure (no deletions). Returns a structured report.
|
|
@@ -59,6 +82,7 @@ export function inspectPrune(
|
|
|
59
82
|
oldLogEntries: number
|
|
60
83
|
hasCheckpoint: boolean
|
|
61
84
|
staleBackups: string[]
|
|
85
|
+
staleTemps: string[]
|
|
62
86
|
emptyRounds: number[]
|
|
63
87
|
totalLogEntries: number
|
|
64
88
|
registryRounds: number
|
|
@@ -100,10 +124,18 @@ export function inspectPrune(
|
|
|
100
124
|
.filter((r) => r.records.length === 0)
|
|
101
125
|
.map((r) => r.round)
|
|
102
126
|
|
|
127
|
+
// 5. Stray atomic-write temp files left behind by a crashed writer. The
|
|
128
|
+
// unique prefix makes them recognizable and safe to delete — no live writer
|
|
129
|
+
// ever reads another writer's temp file.
|
|
130
|
+
const staleTemps = existsSync(iterateDir(projectRoot))
|
|
131
|
+
? readdirSync(iterateDir(projectRoot)).filter((f) => isPrunableTemp(f)).sort()
|
|
132
|
+
: []
|
|
133
|
+
|
|
103
134
|
return {
|
|
104
135
|
oldLogEntries,
|
|
105
136
|
hasCheckpoint,
|
|
106
137
|
staleBackups,
|
|
138
|
+
staleTemps,
|
|
107
139
|
emptyRounds,
|
|
108
140
|
totalLogEntries: entries.length,
|
|
109
141
|
registryRounds: registry.rounds.length,
|
|
@@ -122,6 +154,7 @@ export function executePrune(
|
|
|
122
154
|
deletedLogEntries: number
|
|
123
155
|
deletedCheckpoint: boolean
|
|
124
156
|
deletedBackups: string[]
|
|
157
|
+
deletedTemps: string[]
|
|
125
158
|
trimmedEmptyRounds: number
|
|
126
159
|
errors: string[]
|
|
127
160
|
} {
|
|
@@ -130,6 +163,7 @@ export function executePrune(
|
|
|
130
163
|
deletedLogEntries: 0,
|
|
131
164
|
deletedCheckpoint: false,
|
|
132
165
|
deletedBackups: [] as string[],
|
|
166
|
+
deletedTemps: [] as string[],
|
|
133
167
|
trimmedEmptyRounds: 0,
|
|
134
168
|
errors: [] as string[],
|
|
135
169
|
}
|
|
@@ -142,9 +176,7 @@ export function executePrune(
|
|
|
142
176
|
result.deletedLogEntries = entries.length - kept.length
|
|
143
177
|
if (result.deletedLogEntries > 0) {
|
|
144
178
|
const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl')
|
|
145
|
-
|
|
146
|
-
writeFileSync(tmpPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf-8')
|
|
147
|
-
renameSync(tmpPath, logPath)
|
|
179
|
+
writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n')
|
|
148
180
|
}
|
|
149
181
|
} catch (err) {
|
|
150
182
|
result.errors.push(`failed to rewrite decision log: ${String(err)}`)
|
|
@@ -184,13 +216,23 @@ export function executePrune(
|
|
|
184
216
|
rounds: registry.rounds.filter((r) => !emptyRoundNos.has(r.round) || (r.records?.length ?? 0) > 0),
|
|
185
217
|
}
|
|
186
218
|
registry = recomputeRoundCounts(registry)
|
|
187
|
-
|
|
219
|
+
writeJsonAtomic(fixRegistryPath(projectRoot), registry)
|
|
188
220
|
result.trimmedEmptyRounds = report.emptyRounds.length
|
|
189
221
|
} catch (err) {
|
|
190
222
|
result.errors.push(`failed to trim empty rounds: ${String(err)}`)
|
|
191
223
|
}
|
|
192
224
|
}
|
|
193
225
|
|
|
226
|
+
// 5. Remove stray atomic-write temp files.
|
|
227
|
+
for (const tmp of report.staleTemps) {
|
|
228
|
+
try {
|
|
229
|
+
rmSync(join(iterateDir(projectRoot), tmp), { force: true })
|
|
230
|
+
result.deletedTemps.push(tmp)
|
|
231
|
+
} catch (err) {
|
|
232
|
+
result.errors.push(`failed to delete temp file ${tmp}: ${String(err)}`)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
194
236
|
return result
|
|
195
237
|
}
|
|
196
238
|
|
|
@@ -206,7 +248,8 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
|
|
|
206
248
|
description:
|
|
207
249
|
'Inspect or clean up old iterate runtime artifacts (.iterate/). ' +
|
|
208
250
|
'Defaults to dry-run (report-only, no deletion). Pass `dryRun: false` to actually prune. ' +
|
|
209
|
-
'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds
|
|
251
|
+
'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds, ' +
|
|
252
|
+
'and stray temp files left by crashed atomic writes. ' +
|
|
210
253
|
'Each deletion is logged to the decision log.',
|
|
211
254
|
parameters: {
|
|
212
255
|
dryRun: {
|
|
@@ -246,6 +289,7 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
|
|
|
246
289
|
` Decision-log entries to remove: ${report?.oldLogEntries ?? '?'} (of ${report?.totalLogEntries ?? '?'})`,
|
|
247
290
|
` Checkpoint to delete: ${report?.hasCheckpoint ? 'yes' : 'none'}`,
|
|
248
291
|
` Stale backups to delete: ${(report?.staleBackups as string[] | undefined)?.length ?? 0}`,
|
|
292
|
+
` Stray temp files to delete: ${(report?.staleTemps as string[] | undefined)?.length ?? 0}`,
|
|
249
293
|
` Empty rounds to trim: ${(report?.emptyRounds as number[] | undefined)?.length ?? 0}`,
|
|
250
294
|
'',
|
|
251
295
|
'Pass dryRun:false to execute the prune.',
|
|
@@ -257,6 +301,7 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
|
|
|
257
301
|
` Deleted ${result?.deletedLogEntries ?? 0} old log entries.`,
|
|
258
302
|
` Checkpoint deleted: ${result?.deletedCheckpoint ? 'yes' : 'no'}`,
|
|
259
303
|
` Deleted ${(result?.deletedBackups as string[] | undefined)?.length ?? 0} stale backups.`,
|
|
304
|
+
` Deleted ${(result?.deletedTemps as string[] | undefined)?.length ?? 0} stray temp files.`,
|
|
260
305
|
` Trimmed ${result?.trimmedEmptyRounds ?? 0} empty rounds.`,
|
|
261
306
|
]
|
|
262
307
|
const errs = (result?.errors as string[] | undefined) ?? []
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
11
11
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
12
12
|
import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
13
|
-
import { readQualityGate, writeQualityGate, computeQualityGate } from './quality-store.ts'
|
|
13
|
+
import { readQualityGate, writeQualityGate, computeQualityGate, clearQualityGate } from './quality-store.ts'
|
|
14
14
|
import type { QualityGateSnapshot } from '../types.ts'
|
|
15
15
|
|
|
16
16
|
/** Validate a single finding object; returns true when well-formed. */
|
|
@@ -64,17 +64,22 @@ export function registerQualityGateTool(ctx: { tools: { register: (def: ReturnTy
|
|
|
64
64
|
ctx.tools.register(
|
|
65
65
|
defineTool({
|
|
66
66
|
name: 'iterate_quality_gate',
|
|
67
|
+
// `read` never writes; `compute` persists the snapshot → only read
|
|
68
|
+
// joins a parallel dispatch group.
|
|
69
|
+
isConcurrencySafe: (args) => (args as { operation?: unknown }).operation === 'read',
|
|
67
70
|
description:
|
|
68
71
|
'Query or write the quality gate status: dimension convergence rates, verification pass rates, ' +
|
|
69
72
|
'and overall PASS/FAIL status. ' +
|
|
70
73
|
'Operation "read" (default) returns the persisted machine-readable quality certificate. ' +
|
|
71
74
|
'Operation "compute" computes a fresh snapshot from this round\'s findings/validation results, ' +
|
|
72
|
-
'persists it to .iterate/quality-gate.json, and returns it.'
|
|
75
|
+
'persists it to .iterate/quality-gate.json, and returns it. ' +
|
|
76
|
+
'Operation "clear" removes the persisted certificate (use it to reset a stale gate before ' +
|
|
77
|
+
'starting a fresh iteration).',
|
|
73
78
|
parameters: {
|
|
74
79
|
operation: {
|
|
75
80
|
type: 'string',
|
|
76
|
-
description: 'Operation: read (load persisted certificate)
|
|
77
|
-
enum: ['read', 'compute'],
|
|
81
|
+
description: 'Operation: read (load persisted certificate), compute (recompute + persist), or clear (remove the persisted certificate). Default: read.',
|
|
82
|
+
enum: ['read', 'compute', 'clear'],
|
|
78
83
|
},
|
|
79
84
|
dimensions: {
|
|
80
85
|
type: 'array',
|
|
@@ -142,6 +147,9 @@ export function registerQualityGateTool(ctx: { tools: { register: (def: ReturnTy
|
|
|
142
147
|
if (operation === 'compute') {
|
|
143
148
|
lines.push('', 'Quality gate snapshot computed and persisted.')
|
|
144
149
|
}
|
|
150
|
+
if (operation === 'clear') {
|
|
151
|
+
lines.push('', 'Quality gate certificate cleared — the gate will be recomputed on the next iteration.')
|
|
152
|
+
}
|
|
145
153
|
return [{ type: 'text', text: lines.join('\n') }]
|
|
146
154
|
},
|
|
147
155
|
},
|
|
@@ -153,6 +161,19 @@ export function registerQualityGateTool(ctx: { tools: { register: (def: ReturnTy
|
|
|
153
161
|
|
|
154
162
|
const operation = typeof args.operation === 'string' ? args.operation : 'read'
|
|
155
163
|
|
|
164
|
+
if (operation === 'clear') {
|
|
165
|
+
const result = clearQualityGate(projectRoot)
|
|
166
|
+
if (!result.ok) {
|
|
167
|
+
return { ok: false, kind: 'quality_gate', operation: 'clear', error: result.error }
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
ok: true,
|
|
171
|
+
kind: 'quality_gate',
|
|
172
|
+
operation: 'clear',
|
|
173
|
+
snapshot: readQualityGate(projectRoot) as unknown as JsonValue,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
156
177
|
if (operation === 'compute') {
|
|
157
178
|
const dimensions = Array.isArray(args.dimensions)
|
|
158
179
|
? args.dimensions.filter((d): d is string => typeof d === 'string' && d.length > 0)
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import * as fs from 'node:fs'
|
|
10
10
|
import * as path from 'node:path'
|
|
11
|
+
import { writeJsonAtomic } from '../atomic-fs.ts'
|
|
11
12
|
import type { QualityGateSnapshot, QualityGateDimension } from '../types.ts'
|
|
12
13
|
|
|
13
14
|
const QUALITY_GATE_FILE = 'quality-gate.json'
|
|
@@ -108,13 +109,32 @@ export function writeQualityGate(
|
|
|
108
109
|
if (!fs.existsSync(dirPath)) {
|
|
109
110
|
fs.mkdirSync(dirPath, { recursive: true })
|
|
110
111
|
}
|
|
111
|
-
|
|
112
|
+
writeJsonAtomic(filePath, snapshot)
|
|
112
113
|
} catch (err) {
|
|
113
114
|
return { ok: false, error: `unable to write ${filePath}: ${String(err)}` }
|
|
114
115
|
}
|
|
115
116
|
return { ok: true }
|
|
116
117
|
}
|
|
117
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Clear the persisted quality gate certificate (`.iterate/quality-gate.json`).
|
|
121
|
+
* Returns whether a file existed and was removed, or a structured error when
|
|
122
|
+
* the removal fails — a stale FAIL certificate must never silently persist.
|
|
123
|
+
*/
|
|
124
|
+
export function clearQualityGate(
|
|
125
|
+
projectRoot: string,
|
|
126
|
+
): { ok: true; existed: boolean } | { ok: false; error: string } {
|
|
127
|
+
const filePath = path.join(projectRoot, '.iterate', QUALITY_GATE_FILE)
|
|
128
|
+
const existed = fs.existsSync(filePath)
|
|
129
|
+
if (!existed) return { ok: true, existed: false }
|
|
130
|
+
try {
|
|
131
|
+
fs.rmSync(filePath, { force: true })
|
|
132
|
+
} catch (err) {
|
|
133
|
+
return { ok: false, error: `unable to remove ${filePath}: ${String(err)}` }
|
|
134
|
+
}
|
|
135
|
+
return { ok: true, existed: true }
|
|
136
|
+
}
|
|
137
|
+
|
|
118
138
|
/**
|
|
119
139
|
* Compute the convergence rate for a dimension.
|
|
120
140
|
*
|
package/src/tools/transcript.ts
CHANGED
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
22
|
-
import { mkdir, readFile
|
|
22
|
+
import { mkdir, readFile } from 'node:fs/promises'
|
|
23
23
|
import { existsSync } from 'node:fs'
|
|
24
|
+
import { writeTextAtomicAsync } from '../atomic-fs.ts'
|
|
24
25
|
import { dirname } from 'node:path'
|
|
25
26
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
26
27
|
import {
|
|
@@ -107,6 +108,9 @@ export function registerTranscriptTool(ctx: {
|
|
|
107
108
|
ctx.tools.register(
|
|
108
109
|
defineTool({
|
|
109
110
|
name: 'iterate_transcript',
|
|
111
|
+
// `read` never writes; `capture`/`nudge` persist transcript state → only
|
|
112
|
+
// read joins a parallel dispatch group.
|
|
113
|
+
isConcurrencySafe: (args) => (args as { operation?: unknown }).operation === 'read',
|
|
110
114
|
description:
|
|
111
115
|
'Runtime-observatory transcript for the iterate workflow. ' +
|
|
112
116
|
'`read` returns the current persisted transcript manifest (per-reviewer threads, ' +
|
|
@@ -332,10 +336,40 @@ function rehydrateBuilder(manifest: TranscriptManifest, approval: 'ask' | 'deny'
|
|
|
332
336
|
return builder
|
|
333
337
|
}
|
|
334
338
|
|
|
335
|
-
/** Atomically persist a manifest (
|
|
339
|
+
/** Atomically persist a manifest (unique temp + rename) under `.iterate/`. */
|
|
336
340
|
async function persist(file: string, manifest: TranscriptManifest): Promise<void> {
|
|
337
341
|
await mkdir(dirname(file), { recursive: true })
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
342
|
+
await writeTextAtomicAsync(file, JSON.stringify(manifest, null, 2))
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Mark one applied fix as rolled back in the PERSISTED transcript, so the
|
|
347
|
+
* client observatory (F4 fix/rollback tab) reflects the reversal instead of
|
|
348
|
+
* showing the fix as still "成功" after a rollback.
|
|
349
|
+
*
|
|
350
|
+
* Best-effort and fail-safe: a missing/corrupt transcript is ignored, and a
|
|
351
|
+
* persistence failure never throws into the caller (rollback itself is the
|
|
352
|
+
* primary operation; the transcript is a secondary UI projection).
|
|
353
|
+
*
|
|
354
|
+
* @returns true when a transcript existed and was updated.
|
|
355
|
+
*/
|
|
356
|
+
export async function markFixRolledBackInTranscript(
|
|
357
|
+
projectRoot: string,
|
|
358
|
+
id: string,
|
|
359
|
+
): Promise<boolean> {
|
|
360
|
+
const file = transcriptPath(projectRoot)
|
|
361
|
+
if (!existsSync(file)) return false
|
|
362
|
+
try {
|
|
363
|
+
const parsed = JSON.parse(await readFile(file, 'utf-8')) as unknown as TranscriptManifest
|
|
364
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.fixes)) return false
|
|
365
|
+
const { config } = loadEffectiveConfig(projectRoot)
|
|
366
|
+
const approval = config.observatory?.approval ?? 'ask'
|
|
367
|
+
const builder = rehydrateBuilder(parsed, approval)
|
|
368
|
+
builder.markFixRolledBack(id)
|
|
369
|
+
await persist(file, builder.serialize())
|
|
370
|
+
return true
|
|
371
|
+
} catch {
|
|
372
|
+
// Never let the secondary transcript write break the rollback flow.
|
|
373
|
+
return false
|
|
374
|
+
}
|
|
341
375
|
}
|
package/src/tools/triage.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { copyFileSync, existsSync, readFileSync, rmSync
|
|
1
|
+
import { copyFileSync, existsSync, readFileSync, rmSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
4
4
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
5
5
|
import yaml from 'js-yaml'
|
|
6
6
|
import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
7
|
+
import { writeTextAtomic } from '../atomic-fs.ts'
|
|
7
8
|
import type { KnownIntentional } from '../types.ts'
|
|
8
9
|
|
|
9
10
|
const CONFIG_FILE = 'iterate.config.yaml'
|
|
@@ -229,7 +230,7 @@ function applyEntries(
|
|
|
229
230
|
|
|
230
231
|
const yamlText = yaml.dump(nextConfig, { noRefs: true })
|
|
231
232
|
try {
|
|
232
|
-
|
|
233
|
+
writeTextAtomic(configPath, yamlText)
|
|
233
234
|
} catch (err) {
|
|
234
235
|
// Rollback: restore the backup, or REMOVE the file we just created when
|
|
235
236
|
// there was no prior config — an empty file left behind would poison all
|
|
@@ -268,6 +269,9 @@ export function registerTriageTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
268
269
|
ctx.tools.register(
|
|
269
270
|
defineTool({
|
|
270
271
|
name: 'iterate_triage',
|
|
272
|
+
// `list` is a pure config read; `apply` rewrites iterate.config.yaml →
|
|
273
|
+
// only list joins a parallel dispatch group.
|
|
274
|
+
isConcurrencySafe: (args) => (args as { operation?: unknown }).operation === 'list',
|
|
271
275
|
description:
|
|
272
276
|
'Manage `personalization.known_intentional` entries in iterate.config.yaml. ' +
|
|
273
277
|
'Use `apply` to write back triage verdicts (entries where the reviewer said "known intentional") so ' +
|
package/src/tools/validate.ts
CHANGED
|
@@ -28,11 +28,18 @@ export function clampTimeout(ms: number | undefined): number {
|
|
|
28
28
|
/**
|
|
29
29
|
* Run a single shell command with timeout and return structured results.
|
|
30
30
|
* Pure function (no side effects beyond the exec call).
|
|
31
|
+
*
|
|
32
|
+
* `signal` (the caller's `exec.signal` per the dsh tools contract) is forwarded
|
|
33
|
+
* to the child process so a cancelled tool call kills the running command
|
|
34
|
+
* instead of pinning the dispatch open until the timeout elapses. The result
|
|
35
|
+
* distinguishes "cancelled by the caller" (`canceled: true`) from "ran past
|
|
36
|
+
* `timeoutMs`" (`timedOut: true`).
|
|
31
37
|
*/
|
|
32
|
-
async function runCommand(
|
|
38
|
+
export async function runCommand(
|
|
33
39
|
command: string,
|
|
34
40
|
cwd: string,
|
|
35
41
|
timeoutMs: number,
|
|
42
|
+
signal?: AbortSignal,
|
|
36
43
|
): Promise<ValidationResult> {
|
|
37
44
|
const start = performance.now()
|
|
38
45
|
return new Promise<ValidationResult>((resolve) => {
|
|
@@ -43,6 +50,7 @@ async function runCommand(
|
|
|
43
50
|
timeout: timeoutMs,
|
|
44
51
|
maxBuffer: 10 * 1024 * 1024, // 10 MB
|
|
45
52
|
env: { ...process.env, PAGER: 'cat' },
|
|
53
|
+
signal,
|
|
46
54
|
},
|
|
47
55
|
(error, stdout, stderr) => {
|
|
48
56
|
const durationMs = Math.round(performance.now() - start)
|
|
@@ -50,12 +58,16 @@ async function runCommand(
|
|
|
50
58
|
// cannot be spawned Node sets error.code to a STRING ('ENOENT' etc).
|
|
51
59
|
// Coerce to a number so the integer output schema is never violated.
|
|
52
60
|
const exitCode = typeof error?.code === 'number' ? error.code : (error ? 1 : 0)
|
|
61
|
+
const canceled = signal?.aborted === true
|
|
53
62
|
resolve({
|
|
54
63
|
command,
|
|
55
64
|
exitCode,
|
|
56
65
|
stdout: stdout ?? '',
|
|
57
66
|
stderr: stderr ?? '',
|
|
58
|
-
|
|
67
|
+
// A caller cancellation also kills the child (killed === true), but
|
|
68
|
+
// that is not a timeout — only report timedOut for the deadline path.
|
|
69
|
+
timedOut: error?.killed === true && !canceled,
|
|
70
|
+
canceled,
|
|
59
71
|
durationMs,
|
|
60
72
|
})
|
|
61
73
|
},
|
|
@@ -72,6 +84,18 @@ export function registerValidateTool(ctx: { tools: { register: (def: ReturnType<
|
|
|
72
84
|
ctx.tools.register(
|
|
73
85
|
defineTool({
|
|
74
86
|
name: 'iterate_validate',
|
|
87
|
+
// Pending-call card: render the pending validation as a terminal card.
|
|
88
|
+
// Pure — derived from args only; no `cwd` (the UI bridge resolves the
|
|
89
|
+
// relative command against the session workspace).
|
|
90
|
+
presentCall: (args) => {
|
|
91
|
+
const a = args as { command?: unknown }
|
|
92
|
+
if (typeof a.command !== 'string' || a.command.length === 0) return undefined
|
|
93
|
+
return {
|
|
94
|
+
card: 'terminal',
|
|
95
|
+
title: a.command,
|
|
96
|
+
description: 'Run a preconfigured iterate validation command (exact match on validation.commands).',
|
|
97
|
+
}
|
|
98
|
+
},
|
|
75
99
|
description:
|
|
76
100
|
'Run a validation command that is PRECONFIGURED in iterate.config.yaml `validation.commands`. ' +
|
|
77
101
|
'The command must exactly match one of the configured commands (they are the only ones the user trusts). ' +
|
|
@@ -105,6 +129,7 @@ export function registerValidateTool(ctx: { tools: { register: (def: ReturnType<
|
|
|
105
129
|
stdout: { type: 'string', required: true },
|
|
106
130
|
stderr: { type: 'string', required: true },
|
|
107
131
|
timedOut: { type: 'boolean', required: true },
|
|
132
|
+
canceled: { type: 'boolean', required: true },
|
|
108
133
|
durationMs: { type: 'integer', required: true },
|
|
109
134
|
rejectReason: { type: 'string' },
|
|
110
135
|
},
|
|
@@ -118,6 +143,7 @@ export function registerValidateTool(ctx: { tools: { register: (def: ReturnType<
|
|
|
118
143
|
`Exit code: ${value.exitCode}`,
|
|
119
144
|
`Duration: ${value.durationMs}ms`,
|
|
120
145
|
value.timedOut ? '⚠ Timed out' : '',
|
|
146
|
+
value.canceled ? '⚠ Cancelled before completion' : '',
|
|
121
147
|
'',
|
|
122
148
|
value.stdout ? `[stdout]\n${value.stdout}` : '',
|
|
123
149
|
value.stderr ? `[stderr]\n${value.stderr}` : '',
|
|
@@ -139,6 +165,7 @@ export function registerValidateTool(ctx: { tools: { register: (def: ReturnType<
|
|
|
139
165
|
stdout: '',
|
|
140
166
|
stderr: '',
|
|
141
167
|
timedOut: false,
|
|
168
|
+
canceled: false,
|
|
142
169
|
durationMs: 0,
|
|
143
170
|
rejectReason: resolved.reason,
|
|
144
171
|
}
|
|
@@ -161,6 +188,7 @@ export function registerValidateTool(ctx: { tools: { register: (def: ReturnType<
|
|
|
161
188
|
stdout: '',
|
|
162
189
|
stderr: '',
|
|
163
190
|
timedOut: false,
|
|
191
|
+
canceled: false,
|
|
164
192
|
durationMs: 0,
|
|
165
193
|
rejectReason:
|
|
166
194
|
(source === 'defaults'
|
|
@@ -177,6 +205,7 @@ export function registerValidateTool(ctx: { tools: { register: (def: ReturnType<
|
|
|
177
205
|
stdout: '',
|
|
178
206
|
stderr: '',
|
|
179
207
|
timedOut: false,
|
|
208
|
+
canceled: false,
|
|
180
209
|
durationMs: 0,
|
|
181
210
|
rejectReason:
|
|
182
211
|
`Command must exactly match a command predefined in iterate.config.yaml validation.commands. ` +
|
|
@@ -184,7 +213,7 @@ export function registerValidateTool(ctx: { tools: { register: (def: ReturnType<
|
|
|
184
213
|
}
|
|
185
214
|
}
|
|
186
215
|
|
|
187
|
-
const result = await runCommand(args.command, projectRoot, timeout)
|
|
216
|
+
const result = await runCommand(args.command, projectRoot, timeout, exec.signal)
|
|
188
217
|
return {
|
|
189
218
|
allowed: true,
|
|
190
219
|
...result,
|