iterate-plugin 2.4.0 → 2.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.
@@ -3,18 +3,24 @@
3
3
  *
4
4
  * This teaches the model how to write a correct `workflow` script that
5
5
  * performs the iterate autonomous closed-loop (or dry-run pure review),
6
- * using the 5 registered tools via subagents.
6
+ * using the registered tools via subagents.
7
7
  */
8
8
 
9
9
  export const ITERATE_SKILL_PROMPT = `
10
10
  ## Iterate Workflow (autonomous code iteration)
11
11
 
12
12
  You have the iterate plugin installed, which registers these tools:
13
- - \`iterate_config\` — read iterate.config.yaml (dimensions, validation commands, personalization)
13
+ - \`iterate_config\` — read iterate.config.yaml (dimensions, validation commands, personalization) or write a validated partial update (operation:"write", with automatic backup + rollback)
14
14
  - \`iterate_validate\` — run a whitelisted validation command
15
- - \`iterate_decision_log\` — append to the decision log
15
+ - \`iterate_decision_log\` — append to the decision log, or read entries back for review
16
16
  - \`iterate_context\` — read SKILL.md / ITERATE.md project context
17
17
  - \`iterate_review\` — deterministic review engine: \`plan\` builds the review plan; \`aggregate\` dedupes/merges findings and computes convergence. Purely computational.
18
+ - \`iterate_triage\` — manage "known_intentional" entries in the config (list / apply, with dedupe + backup + rollback)
19
+ - \`iterate_fix\` — apply ONE atomic fix: backs up the file, enforces the atomic max_lines threshold, writes the new content, and records the fix (id + diff summary) in \`.iterate/fixes/registry.json\`
20
+ - \`iterate_diff\` — show the accumulated diff for a fixed file (vs its original backup) or a per-file summary of all fixes
21
+ - \`iterate_rollback\` — revert a fix by id: restore the file from its backup, remove the fix from the registry, log a \`revert\` entry. Use when a round's validation fails
22
+ - \`iterate_checkpoint\` — save / load / clear an iteration checkpoint (\`.iterate/checkpoint.json\`) so a long run can resume where it left off
23
+ - \`iterate_status\` — summarize the current run: mode, round, fixes applied, architectural remaining, decision-log size, checkpoint presence
18
24
 
19
25
  ### When to use
20
26
  When the user asks to review or iterate on the project (e.g. "review this project", "iterate on error handling", "check the codebase for issues", "dry-run review", "反复审查"), run an iterate **workflow** by calling the \`workflow\` tool.
@@ -121,11 +127,20 @@ Key rules for dry-run:
121
127
  - Only a single \`report\` entry may be appended to the decision log; nothing else is written.
122
128
 
123
129
  ### Normal-mode workflow (autonomous closed loop)
124
- Set \`args.mode = "normal"\`. Loop: plan → parallel review ×N → fix atomic issues → validate → loop → auto-stop when zero findings remain.
130
+ Set \`args.mode = "normal"\`. Loop: resume → plan → parallel review ×N → atomic fixes via \`iterate_fix\` → validate → rollback on failure → checkpoint → loop → auto-stop when zero findings remain.
125
131
  Canonical script — reproduce this structure exactly (adjust dims via the plan):
126
132
 
127
133
  \`\`\`js
128
134
  // args = { mode: "normal", maxRounds? }
135
+ phase('resume')
136
+ // If a previous run was interrupted, resume from its checkpoint instead of restarting.
137
+ const ckRes = await agent(
138
+ 'Call iterate_checkpoint({ operation: "load" }) and return the checkpoint JSON.',
139
+ { label: 'checkpoint:load' }
140
+ )
141
+ const checkpoint = (ckRes && ckRes.checkpoint) ? ckRes.checkpoint : null
142
+ const startRound = (checkpoint && typeof checkpoint.round === 'number') ? checkpoint.round + 1 : 1
143
+
129
144
  phase('plan')
130
145
  const configRes = await agent(
131
146
  'Call iterate_config({ validate: true }) and return the config JSON.',
@@ -144,12 +159,14 @@ const dims = plan.dimensions.map(d => d.id)
144
159
  const maxRounds = plan.maxReviewRounds
145
160
  const rounds = [] // findings per review round (each on the then-current code state)
146
161
  const architectural = [] // findings deliberately left unfixed (reported at the end)
147
- let fixedCount = 0
162
+ let fixedCount = (checkpoint && typeof checkpoint.fixedCount === 'number') ? checkpoint.fixedCount : 0
148
163
  let converged = false
164
+ let abortedByValidation = false
165
+ let failedCommands = []
149
166
 
150
167
  phase('loop')
151
- for (let r = 1; r <= maxRounds; r++) {
152
- log('round ' + r + ' of ' + maxRounds + ' — review current state, fix atomics, validate')
168
+ for (let r = startRound; r <= maxRounds; r++) {
169
+ log('round ' + r + ' of ' + maxRounds + ' — review current state, fix atomics via iterate_fix, validate')
153
170
  const raw = await parallel(dims.map(dim => () => agent(
154
171
  'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed). ' +
155
172
  'Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + '\\nReturn the findings JSON object.',
@@ -167,18 +184,34 @@ for (let r = 1; r <= maxRounds; r++) {
167
184
  const atomic = findings.filter(f => f.is_atomic === true)
168
185
  const remaining = findings.filter(f => f.is_atomic !== true)
169
186
 
187
+ const roundFixIds = []
170
188
  if (atomic.length > 0) {
171
- // Group atomic fixes by file. All edits to the SAME file are applied by a
172
- // single fixer agent (serial within a file), avoiding concurrent-write
173
- // races; different files still run in parallel.
189
+ // Group atomic fixes by file. One fixer agent handles a whole file serially
190
+ // calling iterate_fix per finding (the ONLY sanctioned writer), then
191
+ // iterate_diff to verify so the same file is never edited concurrently;
192
+ // different files still run in parallel.
174
193
  const byFile = {}
175
194
  atomic.forEach(f => { (byFile[f.file] = byFile[f.file] || []).push(f) })
176
- await parallel(Object.keys(byFile).map(file => () => agent(
177
- 'Apply ALL of these fixes to ' + file + ' in ONE pass with the smallest possible changes ' +
178
- '(each <= ' + atomicMaxLines + ' lines, single function). ' + JSON.stringify(byFile[file]) + '. Verify the edit locally before finishing.',
179
- { label: 'fix:' + file, phase: 'fix' }
195
+ const fixRes = await parallel(Object.keys(byFile).map(file => () => agent(
196
+ 'Apply the fixes for ' + file + ' using iterate_fix. For EACH finding in this list, ' +
197
+ 'read the current file, compute the edited full content (change <= ' + atomicMaxLines + ' lines), and call ' +
198
+ 'iterate_fix({ file: "' + file + '", content: <full new file content>, finding: <that finding>, round: ' + r + ' }). ' +
199
+ 'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file: "' + file + '" }) to verify the accumulated diff. ' +
200
+ 'Findings: ' + JSON.stringify(byFile[file]) + '. Return the array of {id, ok, error} per iterate_fix call.',
201
+ { label: 'fix:' + file, phase: 'fix', schema: {
202
+ type: 'object', additionalProperties: false,
203
+ properties: {
204
+ fixes: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { id: { type: 'string' }, ok: { type: 'boolean' }, error: { type: 'string' } }, required: ['id', 'ok'] } }
205
+ },
206
+ required: ['fixes'] } }
180
207
  )))
181
- fixedCount += atomic.length
208
+ for (const res of fixRes) {
209
+ if (res && Array.isArray(res.fixes)) {
210
+ for (const fx of res.fixes) {
211
+ if (fx && fx.ok === true) { fixedCount += 1; roundFixIds.push(fx.id) }
212
+ }
213
+ }
214
+ }
182
215
  }
183
216
 
184
217
  // Cross-round dedupe of architectural findings before accumulating.
@@ -188,16 +221,51 @@ for (let r = 1; r <= maxRounds; r++) {
188
221
  if (seenKeys.indexOf(key) < 0) { architectural.push(f); seenKeys.push(key) }
189
222
  }
190
223
 
191
- await agent(
192
- 'Call iterate_validate for each command in iterate.config.yaml validation.commands and return all {command, exitCode} results.',
193
- { label: 'validate:r' + r, phase: 'validate' }
224
+ // Validate every configured command; on ANY failure roll back this round's fixes.
225
+ const valRes = await agent(
226
+ 'Read iterate.config.yaml validation.commands, then call iterate_validate({ command: <cmd> }) for EACH configured command ' +
227
+ '(one tool call per command). Return all results as {command, exitCode} entries.',
228
+ { label: 'validate:r' + r, phase: 'validate', schema: {
229
+ type: 'object', additionalProperties: false,
230
+ properties: {
231
+ results: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { command: { type: 'string' }, exitCode: { type: 'integer' } }, required: ['command', 'exitCode'] } }
232
+ },
233
+ required: ['results'] } }
194
234
  )
235
+ failedCommands = (valRes && Array.isArray(valRes.results)) ? valRes.results.filter(v => v.exitCode !== 0).map(v => v.command) : []
236
+ if (failedCommands.length > 0) {
237
+ log('round ' + r + ' validation FAILED on: ' + failedCommands.join(', ') + ' — rolling back this round')
238
+ abortedByValidation = true
239
+ if (roundFixIds.length > 0) {
240
+ await agent(
241
+ 'Call iterate_rollback({ id: <id> }) for EACH of these fix ids (one call per id): ' + JSON.stringify(roundFixIds) + '. Return the array of {id, ok, error}.',
242
+ { label: 'rollback:r' + r, phase: 'rollback', schema: {
243
+ type: 'object', additionalProperties: false,
244
+ properties: {
245
+ results: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { id: { type: 'string' }, ok: { type: 'boolean' }, error: { type: 'string' } }, required: ['id', 'ok'] } }
246
+ },
247
+ required: ['results'] } }
248
+ )
249
+ }
250
+ await agent(
251
+ 'Call iterate_decision_log({operation:"append", type:"round_failed", round:' + r + ', data:{failedCommands:' + JSON.stringify(failedCommands) + ', rolledBack:' + roundFixIds.length + '}})',
252
+ { label: 'log:failed:r' + r }
253
+ )
254
+ break
255
+ }
256
+
195
257
  await agent(
196
258
  'Call iterate_decision_log({operation:"append", type:"review_result", round:' + r +
197
259
  ', data:{atomic:' + atomic.length + ', architectural:' + remaining.length + ', fixedSoFar:' + fixedCount + '}})',
198
260
  { label: 'log:r' + r }
199
261
  )
200
262
 
263
+ // Persist progress so an interrupted run can resume from the next round.
264
+ await agent(
265
+ 'Call iterate_checkpoint({ operation: "save", mode: "normal", round:' + r + ', maxRounds:' + maxRounds + ', fixedCount:' + fixedCount + ', architecturalCount:' + architectural.length + ', findings:' + JSON.stringify(architectural) + ' }) and return the checkpoint JSON.',
266
+ { label: 'checkpoint:save:r' + r }
267
+ )
268
+
201
269
  if (atomic.length === 0 && remaining.length === 0) {
202
270
  log('round ' + r + ' found nothing to fix — converged')
203
271
  converged = true
@@ -211,25 +279,50 @@ await agent(
211
279
  ', data:{mode:"normal", fixed:' + fixedCount + ', architectural:' + architectural.length + '}})',
212
280
  { label: 'report:log' }
213
281
  )
282
+ const statusRes = await agent(
283
+ 'Call iterate_status() and return the status JSON.',
284
+ { label: 'status:final' }
285
+ )
286
+ const status = (statusRes && statusRes.ok) ? statusRes : null
287
+ if (!abortedByValidation) {
288
+ // Iteration finished cleanly → clear the checkpoint so the next run starts fresh.
289
+ await agent(
290
+ 'Call iterate_checkpoint({ operation: "clear" }) and return {ok, existed}.',
291
+ { label: 'checkpoint:clear' }
292
+ )
293
+ }
214
294
  return {
215
295
  mode: 'normal',
216
296
  goal: plan.goal,
217
297
  roundsExecuted: rounds.length,
218
298
  maxRounds: maxRounds,
219
299
  converged: converged,
300
+ abortedByValidation: abortedByValidation,
301
+ failedCommands: failedCommands,
220
302
  findingsFixed: fixedCount,
221
303
  remainingArchitecturalCount: architectural.length,
222
- remainingArchitectural: architectural
304
+ remainingArchitectural: architectural,
305
+ status: status ? {
306
+ currentRound: status.currentRound,
307
+ totalRounds: status.totalRounds,
308
+ fixedCount: status.fixedCount,
309
+ architecturalCount: status.architecturalCount,
310
+ findingsCount: status.findingsCount,
311
+ hasCheckpoint: status.hasCheckpoint
312
+ } : null
223
313
  }
224
314
  \`\`\`
225
315
 
226
316
  Key rules for normal mode:
227
- - Fixers are the ONLY agents allowed to write files; reviewers read only. Architectural findings are reported, never auto-fixed.
317
+ - Fixers are the ONLY agents allowed to write files, and they must go through \`iterate_fix\` — never edit files directly. That is what gives every change a backup, a diff, and a rollback path. Reviewers read only. Architectural findings are reported, never auto-fixed.
228
318
  - Aggregate the current round deterministically (\`report.findings\`) before fixing, so fixes act on deduped/filtered/sorted findings.
229
- - Apply atomic fixes **per file**: one fixer agent handles all findings for a given file serially, so the same file is never edited concurrently; different files are fixed in parallel.
230
- - Validate after every round of fixes; validation results are logged, not silently dropped.
319
+ - Apply atomic fixes **per file**: one fixer agent handles all findings for a given file serially (so the same file is never edited concurrently); different files are fixed in parallel.
320
+ - **Resume**: load the checkpoint first; if a previous run left one, continue from \`checkpoint.round + 1\` (its \`fixedCount\` and deduped \`findings\` are carried forward).
321
+ - **Validate after every round** of fixes; on ANY validation failure, roll back the round's fixes via \`iterate_rollback\` and stop (the checkpoint is left in place so the run can be resumed).
322
+ - **Checkpoint after every round**; clear it only when the iteration completes cleanly.
231
323
  - Stop when a round produces nothing to fix (converged) or maxReviewRounds is reached.
232
- - Every round and the final report go to the append-only decision log.
324
+ - Every round, every rollback, and the final report go to the append-only decision log.
325
+ - Close with \`iterate_status\` metrics and surface the convergence indicators (fixed count, remaining architectural count, abort reason) in the final summary.
233
326
 
234
327
  ### Finding schema (for reviewer agents)
235
328
  { "dimension": string, "file": string (relative path), "line": number (optional),
@@ -242,4 +335,4 @@ Atomic = is_atomic true (single file, single function, ≤ config.atomic.max_lin
242
335
  Always pass \`meta: { name: "iterate", description: "Autonomous iterate loop" }\`.
243
336
 
244
337
  Always end with a clear summary: total findings, count by severity, fixes applied (normal) or convergence stats (dry-run), and remaining architectural findings.
245
- `
338
+ `
@@ -0,0 +1,285 @@
1
+ /**
2
+ * src/tools/checkpoint.ts — iteration checkpoint + status tools.
3
+ *
4
+ * iterate_checkpoint — save / load / clear a resume checkpoint so a long
5
+ * iteration can continue where it left off.
6
+ * iterate_status — summarize the current iteration state from the
7
+ * decision log, fix registry, and checkpoint.
8
+ *
9
+ * Checkpoint layout: `.iterate/checkpoint.json`.
10
+ */
11
+
12
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
13
+ import { defineTool } from '@deepseek-ai/dsh-tools'
14
+ import type { JsonValue } from '@deepseek-ai/dsh-session'
15
+ import { resolveProjectRoot } from '../config-loader.ts'
16
+ import { checkpointPath, iterateDir } from '../paths.ts'
17
+ import { readRegistry } from './fix.ts'
18
+ import { readDecisionEntries } from './decision-log.ts'
19
+ import type { IterationCheckpoint, IterationStatus } from '../types.ts'
20
+
21
+ // ─── Pure helpers (exported for unit tests) ─────────────────────────────────
22
+
23
+ /** Read a checkpoint from disk (missing/corrupt → null). */
24
+ export function readCheckpoint(projectRoot: string): IterationCheckpoint | null {
25
+ const file = checkpointPath(projectRoot)
26
+ if (!existsSync(file)) return null
27
+ try {
28
+ const parsed = JSON.parse(readFileSync(file, 'utf-8')) as IterationCheckpoint
29
+ if (!parsed || typeof parsed !== 'object') return null
30
+ if (parsed.mode !== 'dry-run' && parsed.mode !== 'normal') return null
31
+ if (typeof parsed.round !== 'number') return null
32
+ return parsed
33
+ } catch {
34
+ return null
35
+ }
36
+ }
37
+
38
+ /** Validate a checkpoint payload (returns error string or null). */
39
+ export function validateCheckpoint(input: {
40
+ mode: unknown
41
+ round: unknown
42
+ maxRounds: unknown
43
+ fixedCount: unknown
44
+ architecturalCount: unknown
45
+ }): string | null {
46
+ if (input.mode !== 'dry-run' && input.mode !== 'normal') {
47
+ return 'mode must be "dry-run" or "normal"'
48
+ }
49
+ if (typeof input.round !== 'number' || !Number.isInteger(input.round) || input.round < 0) {
50
+ return 'round must be a non-negative integer'
51
+ }
52
+ if (typeof input.maxRounds !== 'number' || !Number.isInteger(input.maxRounds) || input.maxRounds < 1) {
53
+ return 'maxRounds must be a positive integer'
54
+ }
55
+ if (typeof input.fixedCount !== 'number' || !Number.isInteger(input.fixedCount) || input.fixedCount < 0) {
56
+ return 'fixedCount must be a non-negative integer'
57
+ }
58
+ if (typeof input.architecturalCount !== 'number' || !Number.isInteger(input.architecturalCount) || input.architecturalCount < 0) {
59
+ return 'architecturalCount must be a non-negative integer'
60
+ }
61
+ return null
62
+ }
63
+
64
+ /**
65
+ * Compute a status summary from the runtime artifacts.
66
+ * Pure (no I/O) — all reads are injected, so it is unit-testable.
67
+ */
68
+ export function computeStatus(input: {
69
+ checkpoint: IterationCheckpoint | null
70
+ decisionEntries: { timestamp: string; type: string; round?: number; data?: Record<string, unknown> }[]
71
+ fixRegistry: { rounds: { round: number; fixedCount: number; failedCount: number }[] }
72
+ }): IterationStatus {
73
+ const checkpoint = input.checkpoint
74
+ const entries = input.decisionEntries
75
+ const registry = input.fixRegistry
76
+
77
+ const lastEntry = entries.length > 0 ? entries[entries.length - 1] : null
78
+ const lastUpdated = lastEntry?.timestamp ?? checkpoint?.updatedAt ?? null
79
+
80
+ // Round = checkpoint.round (explicit) or max round seen in the decision log.
81
+ let currentRound = checkpoint?.round ?? 0
82
+ if (!checkpoint) {
83
+ for (const e of entries) {
84
+ if (typeof e.round === 'number' && e.round > currentRound) currentRound = e.round
85
+ }
86
+ }
87
+
88
+ const totalRounds = checkpoint?.maxRounds ?? currentRound
89
+ const registryFixed = registry.rounds.reduce((sum, r) => sum + r.fixedCount, 0)
90
+ const failedCount = registry.rounds.reduce((sum, r) => sum + r.failedCount, 0)
91
+ // When a checkpoint exists, its snapshot fields are authoritative for resume
92
+ // (fixedCount / architecturalCount / findings); otherwise derive from the
93
+ // live fix registry and decision log.
94
+ const fixedCount = checkpoint ? checkpoint.fixedCount : registryFixed
95
+ const architecturalCount = checkpoint?.architecturalCount ?? 0
96
+
97
+ return {
98
+ mode: checkpoint?.mode ?? null,
99
+ currentRound,
100
+ totalRounds,
101
+ fixedCount,
102
+ architecturalCount,
103
+ findingsCount: checkpoint?.findings.length ?? 0,
104
+ totalDecisionLogEntries: entries.length,
105
+ hasCheckpoint: checkpoint !== null,
106
+ checkpoint,
107
+ lastUpdated,
108
+ }
109
+ }
110
+
111
+ // ─── iterate_checkpoint ──────────────────────────────────────────────────────
112
+
113
+ /**
114
+ * Register the `iterate_checkpoint` tool.
115
+ * Saves progress so the orchestrator can resume a long iteration.
116
+ */
117
+ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnType<typeof defineTool>) => void } }): void {
118
+ ctx.tools.register(
119
+ defineTool({
120
+ name: 'iterate_checkpoint',
121
+ description:
122
+ 'Save / load / clear the iteration checkpoint. The workflow saves a checkpoint at the start of ' +
123
+ 'each round (so a long run can resume) and clears it when the iteration completes.',
124
+ parameters: {
125
+ operation: {
126
+ type: 'string',
127
+ required: true,
128
+ description: '"save" to persist the current progress, "load" to read it back, "clear" to remove it.',
129
+ enum: ['save', 'load', 'clear'],
130
+ },
131
+ mode: { type: 'string', description: 'Required for save: "dry-run" or "normal".' },
132
+ round: { type: 'integer', description: 'Required for save: current round number (0 = none started).' },
133
+ maxRounds: { type: 'integer', description: 'Required for save: total round cap.' },
134
+ fixedCount: { type: 'integer', description: 'Required for save: number of fixes applied so far.' },
135
+ architecturalCount: { type: 'integer', description: 'Required for save: architectural findings left unfixed.' },
136
+ findings: { type: 'json', description: 'Optional for save: the current deduped findings to resume from.' },
137
+ path: { type: 'string', description: 'Project root directory (default: current working directory).' },
138
+ },
139
+
140
+ output: {
141
+ schema: {
142
+ type: 'object',
143
+ additionalProperties: false,
144
+ properties: {
145
+ operation: { type: 'string', required: true },
146
+ ok: { type: 'boolean', required: true },
147
+ checkpoint: { type: 'json' },
148
+ existed: { type: 'boolean' },
149
+ error: { type: 'string' },
150
+ },
151
+ },
152
+ render: (_args, value) => [
153
+ { type: 'text', text: JSON.stringify(value, null, 2) },
154
+ ],
155
+ },
156
+
157
+ async execute(args) {
158
+ const resolved = resolveProjectRoot(args.path)
159
+ if (!resolved.ok) return { operation: args.operation, ok: false, error: resolved.reason }
160
+ const projectRoot = resolved.root
161
+
162
+ if (args.operation === 'load') {
163
+ const checkpoint = readCheckpoint(projectRoot)
164
+ return { operation: 'load', ok: true, checkpoint: (checkpoint as unknown as JsonValue | null) ?? undefined }
165
+ }
166
+
167
+ if (args.operation === 'clear') {
168
+ const existed = existsSync(checkpointPath(projectRoot))
169
+ if (existed) {
170
+ try { rmSync(checkpointPath(projectRoot), { force: true }) } catch (err) {
171
+ return { operation: 'clear', ok: false, existed, error: `failed to clear checkpoint: ${String(err)}` }
172
+ }
173
+ }
174
+ return { operation: 'clear', ok: true, existed }
175
+ }
176
+
177
+ if (args.operation === 'save') {
178
+ const invalid = validateCheckpoint({
179
+ mode: args.mode,
180
+ round: args.round,
181
+ maxRounds: args.maxRounds,
182
+ fixedCount: args.fixedCount,
183
+ architecturalCount: args.architecturalCount,
184
+ })
185
+ if (invalid) return { operation: 'save', ok: false, error: invalid }
186
+ const checkpoint: IterationCheckpoint = {
187
+ mode: args.mode as 'dry-run' | 'normal',
188
+ round: args.round as number,
189
+ maxRounds: args.maxRounds as number,
190
+ fixedCount: args.fixedCount as number,
191
+ architecturalCount: args.architecturalCount as number,
192
+ findings: (Array.isArray(args.findings) ? args.findings : []) as unknown as IterationCheckpoint['findings'],
193
+ startedAt: readCheckpoint(projectRoot)?.startedAt ?? new Date().toISOString(),
194
+ updatedAt: new Date().toISOString(),
195
+ }
196
+ try {
197
+ mkdirSync(iterateDir(projectRoot), { recursive: true })
198
+ writeFileSync(checkpointPath(projectRoot), JSON.stringify(checkpoint, null, 2), 'utf-8')
199
+ } catch (err) {
200
+ return { operation: 'save', ok: false, error: `failed to write checkpoint: ${String(err)}` }
201
+ }
202
+ return { operation: 'save', ok: true, checkpoint: checkpoint as unknown as JsonValue }
203
+ }
204
+
205
+ return { operation: args.operation, ok: false, error: 'unknown operation. Use "save", "load", or "clear".' }
206
+ },
207
+ }),
208
+ )
209
+ }
210
+
211
+ // ─── iterate_status ──────────────────────────────────────────────────────────
212
+
213
+ /**
214
+ * Register the `iterate_status` tool.
215
+ * Summarizes the current iteration state (mode, round, fixed count, findings).
216
+ */
217
+ export function registerStatusTool(ctx: { tools: { register: (def: ReturnType<typeof defineTool>) => void } }): void {
218
+ ctx.tools.register(
219
+ defineTool({
220
+ name: 'iterate_status',
221
+ description:
222
+ 'Summarize the current iterate run: mode, current round vs total, fixes applied, architectural ' +
223
+ 'findings remaining, decision-log size, and whether a resume checkpoint exists.',
224
+ parameters: {
225
+ path: { type: 'string', description: 'Project root directory (default: current working directory).' },
226
+ },
227
+
228
+ output: {
229
+ schema: {
230
+ type: 'object',
231
+ additionalProperties: false,
232
+ properties: {
233
+ ok: { type: 'boolean', required: true },
234
+ mode: { type: 'string' },
235
+ currentRound: { type: 'integer' },
236
+ totalRounds: { type: 'integer' },
237
+ fixedCount: { type: 'integer' },
238
+ architecturalCount: { type: 'integer' },
239
+ findingsCount: { type: 'integer' },
240
+ totalDecisionLogEntries: { type: 'integer' },
241
+ hasCheckpoint: { type: 'boolean' },
242
+ lastUpdated: { type: 'string' },
243
+ error: { type: 'string' },
244
+ },
245
+ },
246
+ render: (_args, value) => {
247
+ if (!value.ok) return [{ type: 'text', text: `status failed: ${value.error}` }]
248
+ const lines = [
249
+ `Mode: ${value.mode ?? 'none'}`,
250
+ `Round: ${value.currentRound} / ${value.totalRounds}`,
251
+ `Fixed: ${value.fixedCount} · Architectural remaining: ${value.architecturalCount}`,
252
+ `Findings in checkpoint: ${value.findingsCount}`,
253
+ `Decision-log entries: ${value.totalDecisionLogEntries}`,
254
+ `Checkpoint: ${value.hasCheckpoint ? 'yes' : 'no'}`,
255
+ value.lastUpdated ? `Last updated: ${value.lastUpdated}` : '',
256
+ ]
257
+ return [{ type: 'text', text: lines.filter(Boolean).join('\n') }]
258
+ },
259
+ },
260
+
261
+ async execute(args) {
262
+ const resolved = resolveProjectRoot(args.path)
263
+ if (!resolved.ok) return { ok: false, error: resolved.reason }
264
+ const projectRoot = resolved.root
265
+ const status = computeStatus({
266
+ checkpoint: readCheckpoint(projectRoot),
267
+ decisionEntries: readDecisionEntries(projectRoot),
268
+ fixRegistry: readRegistry(projectRoot),
269
+ })
270
+ return {
271
+ ok: true,
272
+ mode: status.mode ?? undefined,
273
+ currentRound: status.currentRound,
274
+ totalRounds: status.totalRounds,
275
+ fixedCount: status.fixedCount,
276
+ architecturalCount: status.architecturalCount,
277
+ findingsCount: status.findingsCount,
278
+ totalDecisionLogEntries: status.totalDecisionLogEntries,
279
+ hasCheckpoint: status.hasCheckpoint,
280
+ lastUpdated: status.lastUpdated ?? undefined,
281
+ }
282
+ },
283
+ }),
284
+ )
285
+ }
@@ -1,10 +1,18 @@
1
+ import { join } from 'node:path'
1
2
  import { defineTool } from '@deepseek-ai/dsh-tools'
2
3
  import type { JsonValue } from '@deepseek-ai/dsh-session'
3
4
  import { loadEffectiveConfig, validateConfig, resolveProjectRoot } from '../config-loader.ts'
5
+ import {
6
+ applyConfigUpdates,
7
+ readRawConfig,
8
+ validateConfigUpdates,
9
+ writeConfigFile,
10
+ } from '../config-write.ts'
4
11
 
5
12
  /**
6
13
  * Register the `iterate_config` tool.
7
- * Reads and returns the iterate.config.yaml configuration.
14
+ * Reads and returns the iterate.config.yaml configuration, or writes a
15
+ * validated partial update back to it (with backup + rollback).
8
16
  * Model-facing: returns JSON with the full config, a specific section, or validation errors.
9
17
  */
10
18
  export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<typeof defineTool>) => void } }): void {
@@ -12,11 +20,17 @@ export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<ty
12
20
  defineTool({
13
21
  name: 'iterate_config',
14
22
  description:
15
- 'Read the iterate.config.yaml configuration from the project root. ' +
23
+ 'Read or update the iterate.config.yaml configuration from the project root. ' +
16
24
  'Returns the full parsed config, a specific section, or validation errors. ' +
17
- 'Use this to discover available dimensions, validation commands, git settings, and personalization rules.',
25
+ 'Use this to discover available dimensions, validation commands, git settings, and personalization rules, ' +
26
+ 'or to write back validated changes (goal, dimensions, max_rounds, review, atomic, validation, git, etc.).',
18
27
 
19
28
  parameters: {
29
+ operation: {
30
+ type: 'string',
31
+ description: 'Default "read". "write" validates and applies a partial config update (backed up first).',
32
+ enum: ['read', 'write'],
33
+ },
20
34
  path: {
21
35
  type: 'string',
22
36
  description: 'Project root directory (default: current working directory).',
@@ -30,6 +44,13 @@ export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<ty
30
44
  type: 'boolean',
31
45
  description: 'If true, validate the config schema and return any missing fields.',
32
46
  },
47
+ updates: {
48
+ type: 'json',
49
+ description:
50
+ 'For operation "write": a partial config object to merge in, e.g. ' +
51
+ '{"goal":"...","dimensions":["correctness","security"],"max_rounds":5}. ' +
52
+ 'Supported keys: goal, language, dimensions, max_rounds, review, atomic, git, validation, personalization, onboarding.',
53
+ },
33
54
  },
34
55
 
35
56
  output: {
@@ -44,6 +65,9 @@ export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<ty
44
65
  data: { type: 'json' },
45
66
  config: { type: 'json' },
46
67
  availableSections: { type: 'array', items: { type: 'string' } },
68
+ operation: { type: 'string' },
69
+ ok: { type: 'boolean' },
70
+ backupPath: { type: 'string' },
47
71
  error: { type: 'string' },
48
72
  },
49
73
  },
@@ -58,9 +82,43 @@ export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<ty
58
82
  return { found: false, error: resolved.reason }
59
83
  }
60
84
  const projectRoot = resolved.root
61
- // Effective config = defaults (Master) merged with any project-root
62
- // overrides. Never null: a project without a config file runs on the
63
- // built-in defaults, so the workflow stays usable out of the box.
85
+
86
+ // ── Write operation ────────────────────────────────────────────────
87
+ if (args.operation === 'write') {
88
+ const updates = args.updates as Record<string, unknown> | undefined
89
+ const updateErrors = validateConfigUpdates(updates ?? {})
90
+ if (updateErrors.length > 0) {
91
+ return { operation: 'write', ok: false, found: false, errors: updateErrors }
92
+ }
93
+ let base: Record<string, unknown>
94
+ try {
95
+ base = readRawConfig(join(projectRoot, 'iterate.config.yaml'))
96
+ } catch (err) {
97
+ return { operation: 'write', ok: false, found: false, error: `failed to read config: ${String(err)}` }
98
+ }
99
+ const merged = applyConfigUpdates(base, updates ?? {})
100
+ const schemaErrors = validateConfig(merged)
101
+ if (schemaErrors.length > 0) {
102
+ return {
103
+ operation: 'write',
104
+ ok: false,
105
+ found: false,
106
+ errors: schemaErrors.map((e) => `missing/required field: ${e}`),
107
+ }
108
+ }
109
+ const result = writeConfigFile(projectRoot, merged)
110
+ if (!result.ok) return { operation: 'write', ok: false, found: false, error: result.error }
111
+ const { config } = loadEffectiveConfig(projectRoot)
112
+ return {
113
+ operation: 'write',
114
+ ok: true,
115
+ found: true,
116
+ backupPath: result.backupPath ?? undefined,
117
+ config: config as unknown as JsonValue,
118
+ }
119
+ }
120
+
121
+ // ── Read operations (original behavior) ────────────────────────────
64
122
  const { config, source } = loadEffectiveConfig(projectRoot)
65
123
  const hasOverride = source === 'override'
66
124
 
@@ -23,7 +23,7 @@ function logPath(projectRoot: string): string {
23
23
  * Append one entry to the decision log (JSONL format).
24
24
  * Returns the entry count after appending.
25
25
  */
26
- function appendEntry(projectRoot: string, entry: DecisionLogEntry): { count: number; path: string } {
26
+ export function appendDecisionEntry(projectRoot: string, entry: DecisionLogEntry): { count: number; path: string } {
27
27
  const filePath = logPath(projectRoot)
28
28
  const line = JSON.stringify(entry) + '\n'
29
29
  appendFileSync(filePath, line, 'utf-8')
@@ -41,7 +41,7 @@ function appendEntry(projectRoot: string, entry: DecisionLogEntry): { count: num
41
41
  /**
42
42
  * Read all entries from the decision log.
43
43
  */
44
- function readEntries(projectRoot: string): DecisionLogEntry[] {
44
+ export function readDecisionEntries(projectRoot: string): DecisionLogEntry[] {
45
45
  const filePath = join(projectRoot, LOG_DIR, LOG_FILE)
46
46
  if (!existsSync(filePath)) return []
47
47
  try {
@@ -134,7 +134,7 @@ export function registerDecisionLogTool(ctx: { tools: { register: (def: ReturnTy
134
134
  const projectRoot = resolved.root
135
135
 
136
136
  if (args.operation === 'read') {
137
- const entries = readEntries(projectRoot)
137
+ const entries = readDecisionEntries(projectRoot)
138
138
  return {
139
139
  operation: 'read',
140
140
  entryCount: entries.length,
@@ -158,7 +158,7 @@ export function registerDecisionLogTool(ctx: { tools: { register: (def: ReturnTy
158
158
  data: (args.data as Record<string, unknown>) ?? {},
159
159
  }
160
160
 
161
- const result = appendEntry(projectRoot, entry)
161
+ const result = appendDecisionEntry(projectRoot, entry)
162
162
  return {
163
163
  operation: 'append',
164
164
  success: true,