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/config-write.ts
CHANGED
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
* config, always back up before writing, roll back on failure.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { copyFileSync, existsSync, readFileSync, rmSync
|
|
13
|
+
import { copyFileSync, existsSync, readFileSync, rmSync } from 'node:fs'
|
|
14
14
|
import { join } from 'node:path'
|
|
15
15
|
import yaml from 'js-yaml'
|
|
16
|
+
import { writeTextAtomic } from './atomic-fs.ts'
|
|
16
17
|
|
|
17
18
|
/** Config file name (must match config-loader). */
|
|
18
19
|
export const CONFIG_FILE = 'iterate.config.yaml'
|
|
@@ -194,7 +195,7 @@ export function writeConfigFile(
|
|
|
194
195
|
}
|
|
195
196
|
|
|
196
197
|
try {
|
|
197
|
-
|
|
198
|
+
writeTextAtomic(configPath, yaml.dump(config, { noRefs: true }))
|
|
198
199
|
} catch (err) {
|
|
199
200
|
let rollbackError = ''
|
|
200
201
|
try {
|
package/src/live.ts
CHANGED
|
@@ -24,8 +24,9 @@
|
|
|
24
24
|
* or crash a tool call.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import { mkdir, readFile,
|
|
27
|
+
import { mkdir, readFile, stat, appendFile } from 'node:fs/promises'
|
|
28
28
|
import { existsSync } from 'node:fs'
|
|
29
|
+
import { writeTextAtomicAsync } from './atomic-fs.ts'
|
|
29
30
|
import { join } from 'node:path'
|
|
30
31
|
import type { Context } from '@deepseek-ai/cordis'
|
|
31
32
|
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
|
@@ -106,6 +107,11 @@ export function classifyTool(
|
|
|
106
107
|
iterate_status: 'info',
|
|
107
108
|
iterate_config: 'info',
|
|
108
109
|
iterate_context: 'info',
|
|
110
|
+
// v3.0 quality command-center tools — surface their activity in the live
|
|
111
|
+
// feed too (previously these three never produced a live entry).
|
|
112
|
+
iterate_experience: 'info',
|
|
113
|
+
iterate_quality_gate: 'info',
|
|
114
|
+
iterate_defense_events: 'info',
|
|
109
115
|
}
|
|
110
116
|
const type = records[name]
|
|
111
117
|
if (!type) return null
|
|
@@ -136,9 +142,9 @@ export async function appendLive(projectRoot: string, entry: LiveActivityEntry):
|
|
|
136
142
|
const raw = await readFile(file, 'utf-8')
|
|
137
143
|
const lines = raw.split('\n').filter(Boolean)
|
|
138
144
|
const tail = lines.slice(-LIVE_MAX_ENTRIES)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
await
|
|
145
|
+
// Atomic rewrite (unique temp + rename) so a crash mid-trim never
|
|
146
|
+
// truncates the live feed; the temp file is prunable by iterate_prune.
|
|
147
|
+
await writeTextAtomicAsync(file, tail.join('\n') + '\n')
|
|
142
148
|
}
|
|
143
149
|
await appendFile(file, line, 'utf-8')
|
|
144
150
|
} catch {
|
package/src/skill-prompt.ts
CHANGED
|
@@ -24,8 +24,8 @@ You have the iterate plugin installed, which registers these tools:
|
|
|
24
24
|
- \`iterate_history\` — inspect the runtime state in detail: decision-log entries and applied fixes (optionally scoped to a round or a fixed file)
|
|
25
25
|
- \`iterate_prune\` — remove stale runtime artifacts (\`.iterate/\` entries). Defaults to a read-only dry-run that reports what WOULD be removed; pass \`dryRun:false\` to actually prune.
|
|
26
26
|
- \`iterate_transcript\` — runtime observatory file (\`.iterate/transcript.json\`). \`read\` fetches the persisted manifest including any steering \`nudge\` for this run's reviewers; \`capture\` (call once after the final report) persists the per-reviewer threads, convergence trend, findings, fixes, checkpoint, and timeline so the client observatory panel reflects the run; \`nudge\` sets/clears steering text the next round's reviewers read. Purely local, never touches source files.
|
|
27
|
-
- \`iterate_experience\` — experience bank (\`.iterate/experience.json\`): \`list\`/\`search\`/\`get\` recall verified fixes and patterns from past sessions (read the bank before fixing so proven fixes are applied first); \`add\` records a new verified fix — re-adding the same pattern+dimension bumps its hit count instead of duplicating it.
|
|
28
|
-
- \`iterate_quality_gate\` — quality certificate: \`read\` loads the persisted dimension convergence rates / verification pass rate / PASS-FAIL status; \`compute\` recomputes a fresh snapshot from this round's findings + validation results (supply \`findingsByRound\` for real convergence) and persists it to \`.iterate/quality-gate.json
|
|
27
|
+
- \`iterate_experience\` — experience bank (\`.iterate/experience.json\`): \`list\`/\`search\`/\`get\` recall verified fixes and patterns from past sessions (read the bank before fixing so proven fixes are applied first); \`add\` records a new verified fix — re-adding the same pattern+dimension bumps its hit count instead of duplicating it; \`remove\` deletes a stale or incorrect entry by \`id\` so bad experiences never resurface.
|
|
28
|
+
- \`iterate_quality_gate\` — quality certificate: \`read\` loads the persisted dimension convergence rates / verification pass rate / PASS-FAIL status; \`compute\` recomputes a fresh snapshot from this round's findings + validation results (supply \`findingsByRound\` for real convergence) and persists it to \`.iterate/quality-gate.json\`; \`clear\` removes the persisted certificate to reset a stale gate before a fresh iteration.
|
|
29
29
|
- \`iterate_defense_events\` — defense event stream (\`.iterate/defense-events.json\`): \`list\`/\`counts\` review precondition failures, rollbacks, invariant violations, and falsified assumptions; \`record\` logs a new event when a defense fires. Human-readable labels follow the project \`language\` (en/zh).
|
|
30
30
|
|
|
31
31
|
### When to use
|
package/src/tools/checkpoint.ts
CHANGED
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
* Checkpoint layout: `.iterate/checkpoint.json`.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { existsSync, mkdirSync, readFileSync,
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
|
|
13
13
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
14
14
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
15
15
|
import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
16
|
+
import { writeJsonAtomic } from '../atomic-fs.ts'
|
|
16
17
|
import { checkpointPath, iterateDir, transcriptPath } from '../paths.ts'
|
|
17
18
|
import { readRegistry } from './fix.ts'
|
|
18
19
|
import { readDecisionEntries } from './decision-log.ts'
|
|
@@ -223,10 +224,7 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
|
|
|
223
224
|
}
|
|
224
225
|
try {
|
|
225
226
|
mkdirSync(iterateDir(projectRoot), { recursive: true })
|
|
226
|
-
|
|
227
|
-
const tmpPath = `${cpPath}.tmp-${Date.now()}`
|
|
228
|
-
writeFileSync(tmpPath, JSON.stringify(resumed, null, 2), 'utf-8')
|
|
229
|
-
renameSync(tmpPath, cpPath)
|
|
227
|
+
writeJsonAtomic(checkpointPath(projectRoot), resumed)
|
|
230
228
|
} catch (err) {
|
|
231
229
|
return { operation: 'resume', ok: false, error: `failed to persist resumed checkpoint: ${String(err)}` }
|
|
232
230
|
}
|
|
@@ -266,12 +264,10 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
|
|
|
266
264
|
}
|
|
267
265
|
try {
|
|
268
266
|
mkdirSync(iterateDir(projectRoot), { recursive: true })
|
|
269
|
-
// Atomic write (temp + rename): a crash
|
|
270
|
-
// the checkpoint and silently lose the
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
writeFileSync(tmpPath, JSON.stringify(checkpoint, null, 2), 'utf-8')
|
|
274
|
-
renameSync(tmpPath, cpPath)
|
|
267
|
+
// Atomic write (temp + rename, via writeJsonAtomic): a crash
|
|
268
|
+
// mid-write must not corrupt the checkpoint and silently lose the
|
|
269
|
+
// interruption state.
|
|
270
|
+
writeJsonAtomic(checkpointPath(projectRoot), checkpoint)
|
|
275
271
|
} catch (err) {
|
|
276
272
|
return { operation: 'save', ok: false, error: `failed to write checkpoint: ${String(err)}` }
|
|
277
273
|
}
|
|
@@ -294,6 +290,9 @@ export function registerStatusTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
294
290
|
ctx.tools.register(
|
|
295
291
|
defineTool({
|
|
296
292
|
name: 'iterate_status',
|
|
293
|
+
// Read-only aggregation of on-disk state → safe to join a parallel
|
|
294
|
+
// dispatch group alongside other read-only sibling calls.
|
|
295
|
+
isConcurrencySafe: () => true,
|
|
297
296
|
description:
|
|
298
297
|
'Summarize the current iterate run: mode, current round vs total, fixes applied, architectural ' +
|
|
299
298
|
'findings remaining, decision-log size, and whether a resume checkpoint exists.',
|
|
@@ -336,6 +335,33 @@ export function registerStatusTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
336
335
|
`Checkpoint: ${value.hasCheckpoint ? 'yes' : 'no'}${value.interrupted ? ' (interrupted — resumable)' : ''}${value.resumeCount ? ` · resumed ${value.resumeCount}x` : ''}`,
|
|
337
336
|
value.lastUpdated ? `Last updated: ${value.lastUpdated}` : '',
|
|
338
337
|
]
|
|
338
|
+
// v3.0 quality command-center summaries (surfaced here so a single
|
|
339
|
+
// status read reports the whole run state in text too).
|
|
340
|
+
const gate = value.qualityGate as QualityGateSnapshot | null | undefined
|
|
341
|
+
if (gate) {
|
|
342
|
+
lines.push(
|
|
343
|
+
`Quality gate: ${gate.overallStatus.toUpperCase()} · score ${gate.overallScore} · ` +
|
|
344
|
+
`verification ${gate.passedChecks}/${gate.totalChecks} passed` +
|
|
345
|
+
(gate.failReason ? ` · ${gate.failReason}` : ''),
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
const exp = value.experienceBank as { totalEntries?: number; totalHits?: number } | null | undefined
|
|
349
|
+
if (exp) {
|
|
350
|
+
lines.push(
|
|
351
|
+
`Experience bank: ${exp.totalEntries ?? 0} entries · ${exp.totalHits ?? 0} cumulative hits`,
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
const def = value.defenseEvents as { totalEvents?: number; counts?: Record<DefenseEventType, number> } | null | undefined
|
|
355
|
+
if (def) {
|
|
356
|
+
const counts = def.counts ?? ({} as Record<DefenseEventType, number>)
|
|
357
|
+
const total = Object.values(counts).reduce((s, n) => s + (n || 0), 0)
|
|
358
|
+
lines.push(
|
|
359
|
+
`Defense events: ${def.totalEvents ?? total} recorded` +
|
|
360
|
+
(total > 0
|
|
361
|
+
? ` (precondition_failed:${counts.precondition_failed ?? 0}, rollback:${counts.rollback ?? 0}, invariant_violated:${counts.invariant_violated ?? 0}, assumption_falsified:${counts.assumption_falsified ?? 0})`
|
|
362
|
+
: ''),
|
|
363
|
+
)
|
|
364
|
+
}
|
|
339
365
|
return [{ type: 'text', text: lines.filter(Boolean).join('\n') }]
|
|
340
366
|
},
|
|
341
367
|
},
|
package/src/tools/config.ts
CHANGED
|
@@ -19,6 +19,20 @@ export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
19
19
|
ctx.tools.register(
|
|
20
20
|
defineTool({
|
|
21
21
|
name: 'iterate_config',
|
|
22
|
+
// `read` never writes; `write` persists a config update → only read
|
|
23
|
+
// joins a parallel dispatch group.
|
|
24
|
+
isConcurrencySafe: (args) => (args as { operation?: unknown }).operation !== 'write',
|
|
25
|
+
// Pending-call card: a config read vs a validated write.
|
|
26
|
+
presentCall: (args) => {
|
|
27
|
+
const a = args as { operation?: unknown; section?: unknown }
|
|
28
|
+
const isWrite = a.operation === 'write'
|
|
29
|
+
return {
|
|
30
|
+
card: 'generic',
|
|
31
|
+
title: isWrite ? 'Update iterate.config.yaml' : 'Read iterate.config.yaml',
|
|
32
|
+
kind: isWrite ? 'edit' : 'read',
|
|
33
|
+
...(typeof a.section === 'string' && a.section ? { rawInput: { section: a.section } } : {}),
|
|
34
|
+
}
|
|
35
|
+
},
|
|
22
36
|
description:
|
|
23
37
|
'Read or update the iterate.config.yaml configuration from the project root. ' +
|
|
24
38
|
'Returns the full parsed config, a specific section, or validation errors. ' +
|
package/src/tools/context.ts
CHANGED
|
@@ -178,6 +178,9 @@ export function registerContextTool(ctx: { tools: { register: (def: ReturnType<t
|
|
|
178
178
|
ctx.tools.register(
|
|
179
179
|
defineTool({
|
|
180
180
|
name: 'iterate_context',
|
|
181
|
+
// Read-only (reads SKILL.md / ITERATE.md) → safe to join a parallel
|
|
182
|
+
// dispatch group alongside other read-only sibling calls.
|
|
183
|
+
isConcurrencySafe: () => true,
|
|
181
184
|
description:
|
|
182
185
|
'Read project context files (SKILL.md and/or ITERATE.md). ' +
|
|
183
186
|
'SKILL.md contains the original iterate skill instructions; it is searched in ' +
|
|
@@ -77,30 +77,50 @@ export function appendDecisionEntry(projectRoot: string, entry: DecisionLogEntry
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
|
-
*
|
|
80
|
+
* Detailed log read: valid entries plus the number of corrupt lines that were
|
|
81
|
+
* skipped, so callers can surface silent audit-trail loss to the model/UI.
|
|
82
|
+
*/
|
|
83
|
+
export interface DecisionLogRead {
|
|
84
|
+
entries: DecisionLogEntry[]
|
|
85
|
+
invalidLines: number
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read all entries from the decision log, plus the count of corrupt lines.
|
|
81
90
|
* A single corrupt line (partial write, hand-edit) is SKIPPED, not fatal —
|
|
82
|
-
* one bad line must never empty the whole history for every reader
|
|
91
|
+
* one bad line must never empty the whole history for every reader — but it
|
|
92
|
+
* is counted in `invalidLines` so it never disappears silently.
|
|
83
93
|
*/
|
|
84
|
-
export function
|
|
94
|
+
export function readDecisionLogDetailed(projectRoot: string): DecisionLogRead {
|
|
85
95
|
const filePath = join(projectRoot, LOG_DIR, LOG_FILE)
|
|
86
|
-
if (!existsSync(filePath)) return []
|
|
96
|
+
if (!existsSync(filePath)) return { entries: [], invalidLines: 0 }
|
|
87
97
|
let content: string
|
|
88
98
|
try {
|
|
89
99
|
content = readFileSync(filePath, 'utf-8')
|
|
90
100
|
} catch {
|
|
91
|
-
return []
|
|
101
|
+
return { entries: [], invalidLines: 0 }
|
|
92
102
|
}
|
|
93
|
-
const
|
|
103
|
+
const entries: DecisionLogEntry[] = []
|
|
104
|
+
let invalidLines = 0
|
|
94
105
|
for (const line of content.split('\n')) {
|
|
95
106
|
const trimmed = line.trim()
|
|
96
107
|
if (trimmed.length === 0) continue
|
|
97
108
|
try {
|
|
98
|
-
|
|
109
|
+
entries.push(JSON.parse(trimmed) as DecisionLogEntry)
|
|
99
110
|
} catch {
|
|
100
|
-
|
|
111
|
+
invalidLines += 1
|
|
101
112
|
}
|
|
102
113
|
}
|
|
103
|
-
return
|
|
114
|
+
return { entries, invalidLines }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Read all entries from the decision log.
|
|
119
|
+
* A single corrupt line (partial write, hand-edit) is SKIPPED, not fatal —
|
|
120
|
+
* one bad line must never empty the whole history for every reader.
|
|
121
|
+
*/
|
|
122
|
+
export function readDecisionEntries(projectRoot: string): DecisionLogEntry[] {
|
|
123
|
+
return readDecisionLogDetailed(projectRoot).entries
|
|
104
124
|
}
|
|
105
125
|
|
|
106
126
|
/**
|
|
@@ -112,6 +132,9 @@ export function registerDecisionLogTool(ctx: { tools: { register: (def: ReturnTy
|
|
|
112
132
|
ctx.tools.register(
|
|
113
133
|
defineTool({
|
|
114
134
|
name: 'iterate_decision_log',
|
|
135
|
+
// `read` never writes; `append` extends the audit log → only read joins
|
|
136
|
+
// a parallel dispatch group.
|
|
137
|
+
isConcurrencySafe: (args) => (args as { operation?: unknown }).operation === 'read',
|
|
115
138
|
description:
|
|
116
139
|
'Append-only decision log for the iterate loop. ' +
|
|
117
140
|
'Use `append` to record a round start, review finding, fix, validation result, or decision. ' +
|
|
@@ -164,6 +187,7 @@ export function registerDecisionLogTool(ctx: { tools: { register: (def: ReturnTy
|
|
|
164
187
|
properties: {
|
|
165
188
|
operation: { type: 'string', required: true },
|
|
166
189
|
entryCount: { type: 'integer' },
|
|
190
|
+
invalidLines: { type: 'integer' },
|
|
167
191
|
logPath: { type: 'string' },
|
|
168
192
|
entries: { type: 'json' },
|
|
169
193
|
success: { type: 'boolean' },
|
|
@@ -184,10 +208,11 @@ export function registerDecisionLogTool(ctx: { tools: { register: (def: ReturnTy
|
|
|
184
208
|
const projectRoot = resolved.root
|
|
185
209
|
|
|
186
210
|
if (args.operation === 'read') {
|
|
187
|
-
const entries =
|
|
211
|
+
const { entries, invalidLines } = readDecisionLogDetailed(projectRoot)
|
|
188
212
|
return {
|
|
189
213
|
operation: 'read',
|
|
190
214
|
entryCount: entries.length,
|
|
215
|
+
invalidLines,
|
|
191
216
|
logPath: join(projectRoot, LOG_DIR, LOG_FILE),
|
|
192
217
|
entries: entries as unknown as JsonValue,
|
|
193
218
|
}
|
|
@@ -91,6 +91,9 @@ export function registerDefenseEventsTool(ctx: { tools: { register: (def: Return
|
|
|
91
91
|
ctx.tools.register(
|
|
92
92
|
defineTool({
|
|
93
93
|
name: 'iterate_defense_events',
|
|
94
|
+
// List/counts never write; only `record` persists an event → read shapes
|
|
95
|
+
// join a parallel dispatch group.
|
|
96
|
+
isConcurrencySafe: (args) => (args as { operation?: unknown }).operation !== 'record',
|
|
94
97
|
description:
|
|
95
98
|
'Query or record defense events: precondition failures, rollbacks, invariant violations, ' +
|
|
96
99
|
'and assumption falsifications. ' +
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
import * as fs from 'node:fs'
|
|
9
9
|
import * as path from 'node:path'
|
|
10
|
+
import { iterateDir } from '../paths.ts'
|
|
11
|
+
import { writeJsonAtomic } from '../atomic-fs.ts'
|
|
10
12
|
import type { DefenseEvent, DefenseEventStream, DefenseEventType } from '../types.ts'
|
|
11
13
|
|
|
12
14
|
const DEFENSE_EVENTS_FILE = 'defense-events.json'
|
|
@@ -83,14 +85,16 @@ export function writeDefenseEvents(
|
|
|
83
85
|
projectRoot: string,
|
|
84
86
|
stream: DefenseEventStream,
|
|
85
87
|
): { ok: true } | { ok: false; error: string } {
|
|
86
|
-
const dirPath =
|
|
88
|
+
const dirPath = iterateDir(projectRoot)
|
|
87
89
|
const filePath = path.join(dirPath, DEFENSE_EVENTS_FILE)
|
|
88
90
|
|
|
89
91
|
try {
|
|
90
92
|
if (!fs.existsSync(dirPath)) {
|
|
91
93
|
fs.mkdirSync(dirPath, { recursive: true })
|
|
92
94
|
}
|
|
93
|
-
|
|
95
|
+
// Atomic (temp + rename): a crash mid-write can never leave a truncated
|
|
96
|
+
// defense event stream behind.
|
|
97
|
+
writeJsonAtomic(filePath, stream)
|
|
94
98
|
} catch (err) {
|
|
95
99
|
return { ok: false, error: `unable to write ${filePath}: ${String(err)}` }
|
|
96
100
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
10
10
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
11
11
|
import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
12
|
-
import { readExperienceBank, writeExperienceBank, searchExperienceEntries, upsertExperience } from './experience-store.ts'
|
|
12
|
+
import { readExperienceBank, writeExperienceBank, searchExperienceEntries, upsertExperience, removeExperience } from './experience-store.ts'
|
|
13
13
|
import type { ExperienceEntryInput } from './experience-store.ts'
|
|
14
14
|
import type { ExperienceEntry } from '../types.ts'
|
|
15
15
|
|
|
@@ -72,18 +72,22 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
|
|
|
72
72
|
ctx.tools.register(
|
|
73
73
|
defineTool({
|
|
74
74
|
name: 'iterate_experience',
|
|
75
|
+
// List/search/get never write; only `add` upserts the bank → read shapes
|
|
76
|
+
// join a parallel dispatch group.
|
|
77
|
+
isConcurrencySafe: (args) => (args as { operation?: unknown }).operation !== 'add',
|
|
75
78
|
description:
|
|
76
79
|
'Query or extend the experience bank: browse/search historical fixes and patterns, ' +
|
|
77
80
|
'or record a new verified fix (operation:"add"). ' +
|
|
78
81
|
'List/search/get return matching entries with hit counts, verified fixes, and related context. ' +
|
|
79
82
|
'"add" upserts an experience entry into .iterate/experience.json — a repeat of the same ' +
|
|
80
83
|
'pattern+dimension increments its hit count instead of duplicating it. ' +
|
|
84
|
+
'"remove" deletes an entry by id (useful for pruning stale or incorrect experiences). ' +
|
|
81
85
|
'Use it to remember fixes that worked so future rounds apply them first.',
|
|
82
86
|
parameters: {
|
|
83
87
|
operation: {
|
|
84
88
|
type: 'string',
|
|
85
|
-
description: 'Operation: list (browse all), search (by query), get (by id), add (add a new experience). Default: list.',
|
|
86
|
-
enum: ['list', 'search', 'get', 'add'],
|
|
89
|
+
description: 'Operation: list (browse all), search (by query), get (by id), add (add a new experience), remove (delete by id). Default: list.',
|
|
90
|
+
enum: ['list', 'search', 'get', 'add', 'remove'],
|
|
87
91
|
},
|
|
88
92
|
query: {
|
|
89
93
|
type: 'string',
|
|
@@ -151,6 +155,9 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
|
|
|
151
155
|
`Tags: ${entry.tags.join(', ')}`,
|
|
152
156
|
].join('\n') }]
|
|
153
157
|
}
|
|
158
|
+
if (value.operation === 'remove') {
|
|
159
|
+
return [{ type: 'text', text: `Removed experience entry. Bank now holds ${value.count ?? 0} entries (${value.totalHits ?? 0} cumulative hits).` }]
|
|
160
|
+
}
|
|
154
161
|
if (value.operation === 'get' && value.entry) {
|
|
155
162
|
const entry = value.entry as unknown as ExperienceEntry
|
|
156
163
|
return [{ type: 'text', text: [
|
|
@@ -211,6 +218,39 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
|
|
|
211
218
|
}
|
|
212
219
|
}
|
|
213
220
|
|
|
221
|
+
if (operation === 'remove') {
|
|
222
|
+
const id = typeof args.id === 'string' && args.id ? args.id : ''
|
|
223
|
+
if (!id) {
|
|
224
|
+
return {
|
|
225
|
+
ok: false,
|
|
226
|
+
kind: 'experience',
|
|
227
|
+
operation: 'remove',
|
|
228
|
+
error: 'id is required for remove',
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const bank = readExperienceBank(projectRoot)
|
|
232
|
+
const { bank: next, removed } = removeExperience(bank, id)
|
|
233
|
+
if (!removed) {
|
|
234
|
+
return {
|
|
235
|
+
ok: false,
|
|
236
|
+
kind: 'experience',
|
|
237
|
+
operation: 'remove',
|
|
238
|
+
error: `Experience not found: ${id}`,
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const write = writeExperienceBank(projectRoot, next)
|
|
242
|
+
if (!write.ok) {
|
|
243
|
+
return { ok: false, kind: 'experience', operation: 'remove', error: write.error }
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
ok: true,
|
|
247
|
+
kind: 'experience',
|
|
248
|
+
operation: 'remove',
|
|
249
|
+
count: next.entries.length,
|
|
250
|
+
totalHits: next.totalHits,
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
214
254
|
const bank = readExperienceBank(projectRoot)
|
|
215
255
|
|
|
216
256
|
if (operation === 'get' && typeof args.id === 'string') {
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
import * as fs from 'node:fs'
|
|
9
9
|
import * as path from 'node:path'
|
|
10
|
+
import { iterateDir } from '../paths.ts'
|
|
11
|
+
import { writeJsonAtomic } from '../atomic-fs.ts'
|
|
10
12
|
import type { ExperienceBank, ExperienceEntry } from '../types.ts'
|
|
11
13
|
|
|
12
14
|
const EXPERIENCE_FILE = 'experience.json'
|
|
@@ -45,14 +47,16 @@ export function writeExperienceBank(
|
|
|
45
47
|
projectRoot: string,
|
|
46
48
|
bank: ExperienceBank,
|
|
47
49
|
): { ok: true } | { ok: false; error: string } {
|
|
48
|
-
const dirPath =
|
|
50
|
+
const dirPath = iterateDir(projectRoot)
|
|
49
51
|
const filePath = path.join(dirPath, EXPERIENCE_FILE)
|
|
50
52
|
|
|
51
53
|
try {
|
|
52
54
|
if (!fs.existsSync(dirPath)) {
|
|
53
55
|
fs.mkdirSync(dirPath, { recursive: true })
|
|
54
56
|
}
|
|
55
|
-
|
|
57
|
+
// Atomic (temp + rename): a crash mid-write can never leave a truncated
|
|
58
|
+
// experience bank behind.
|
|
59
|
+
writeJsonAtomic(filePath, bank)
|
|
56
60
|
} catch (err) {
|
|
57
61
|
return { ok: false, error: `unable to write ${filePath}: ${String(err)}` }
|
|
58
62
|
}
|
|
@@ -167,3 +171,29 @@ export function upsertExperience(
|
|
|
167
171
|
entryId: id,
|
|
168
172
|
}
|
|
169
173
|
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Remove an experience entry by id.
|
|
177
|
+
* Returns the resulting bank plus whether an entry was actually removed. An
|
|
178
|
+
* unknown id is a no-op (the caller decides how to surface it). Never mutates
|
|
179
|
+
* the input bank.
|
|
180
|
+
*/
|
|
181
|
+
export function removeExperience(
|
|
182
|
+
bank: ExperienceBank,
|
|
183
|
+
id: string,
|
|
184
|
+
): { bank: ExperienceBank; removed: boolean } {
|
|
185
|
+
const target = typeof id === 'string' && id ? id : ''
|
|
186
|
+
if (!target) return { bank, removed: false }
|
|
187
|
+
const entries = bank.entries.filter((e) => e.id !== target)
|
|
188
|
+
if (entries.length === bank.entries.length) return { bank, removed: false }
|
|
189
|
+
const lastUpdated = new Date().toISOString()
|
|
190
|
+
return {
|
|
191
|
+
bank: {
|
|
192
|
+
...bank,
|
|
193
|
+
entries,
|
|
194
|
+
lastUpdated,
|
|
195
|
+
totalHits: bank.totalHits ?? 0,
|
|
196
|
+
},
|
|
197
|
+
removed: true,
|
|
198
|
+
}
|
|
199
|
+
}
|
package/src/tools/fix.ts
CHANGED
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
* - Atomicity is enforced against `config.atomic.max_lines` unless `force`.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync
|
|
20
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
|
|
21
|
+
import { writeJsonAtomic, writeTextAtomic } from '../atomic-fs.ts'
|
|
21
22
|
import { join, sep } from 'node:path'
|
|
22
23
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
23
24
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
@@ -26,6 +27,7 @@ import { runWithJob } from '../jobs.ts'
|
|
|
26
27
|
import { countTouchedMethods } from '../method-scope.ts'
|
|
27
28
|
import { fixBackupPath, fixRegistryPath, fixesDir } from '../paths.ts'
|
|
28
29
|
import { appendDecisionEntry } from './decision-log.ts'
|
|
30
|
+
import { markFixRolledBackInTranscript } from './transcript.ts'
|
|
29
31
|
import type { FileDiffHunk, FixRecord, FixRegistry, ReviewFinding } from '../types.ts'
|
|
30
32
|
|
|
31
33
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
@@ -277,6 +279,19 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
277
279
|
ctx.tools.register(
|
|
278
280
|
defineTool({
|
|
279
281
|
name: 'iterate_fix',
|
|
282
|
+
// Pending-call card: surface the file about to change as an inline diff.
|
|
283
|
+
// oldText is null — a call-time presenter has no access to the file's
|
|
284
|
+
// prior content (an overwrite, per the presentation contract).
|
|
285
|
+
presentCall: (args) => {
|
|
286
|
+
const a = args as { file?: unknown; content?: unknown }
|
|
287
|
+
if (typeof a.file !== 'string' || typeof a.content !== 'string') return undefined
|
|
288
|
+
return {
|
|
289
|
+
card: 'diff',
|
|
290
|
+
title: `Fix ${a.file}`,
|
|
291
|
+
diffs: [{ path: a.file, oldText: null, newText: a.content }],
|
|
292
|
+
locations: [{ path: a.file }],
|
|
293
|
+
}
|
|
294
|
+
},
|
|
280
295
|
description:
|
|
281
296
|
'Apply ONE atomic fix to a file. Pass the target relative `file`, the finding that motivated ' +
|
|
282
297
|
'the fix, the NEW full `content` of that file (after your edit), and the current `round`. ' +
|
|
@@ -454,7 +469,7 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
454
469
|
}
|
|
455
470
|
|
|
456
471
|
try {
|
|
457
|
-
|
|
472
|
+
writeTextAtomic(target.resolved, args.content)
|
|
458
473
|
} catch (err) {
|
|
459
474
|
return { ok: false, error: `failed to write file: ${String(err)}` }
|
|
460
475
|
}
|
|
@@ -472,7 +487,7 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
472
487
|
}
|
|
473
488
|
const nextRegistry = upsertRecord(registry, record)
|
|
474
489
|
try {
|
|
475
|
-
|
|
490
|
+
writeJsonAtomic(fixRegistryPath(projectRoot), nextRegistry)
|
|
476
491
|
} catch (err) {
|
|
477
492
|
// Registry write failed → the file was already modified but no record
|
|
478
493
|
// exists, so a later rollback/diff could never see it and a retry would
|
|
@@ -524,6 +539,9 @@ export function registerDiffTool(ctx: { tools: { register: (def: ReturnType<type
|
|
|
524
539
|
ctx.tools.register(
|
|
525
540
|
defineTool({
|
|
526
541
|
name: 'iterate_diff',
|
|
542
|
+
// Read-only (never writes project files or plugin state) → safe to join
|
|
543
|
+
// a parallel dispatch group alongside other read-only sibling calls.
|
|
544
|
+
isConcurrencySafe: () => true,
|
|
527
545
|
description:
|
|
528
546
|
'Show the changes made by iterate fixes. With `file`, returns the unified diff of the current ' +
|
|
529
547
|
'file content vs its original (first backup). Without `file`, returns a summary of every fixed file.',
|
|
@@ -627,6 +645,17 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
|
|
|
627
645
|
ctx.tools.register(
|
|
628
646
|
defineTool({
|
|
629
647
|
name: 'iterate_rollback',
|
|
648
|
+
// Pending-call card: which fix is about to be reverted.
|
|
649
|
+
presentCall: (args) => {
|
|
650
|
+
const a = args as { id?: unknown }
|
|
651
|
+
if (typeof a.id !== 'string' || a.id.length === 0) return undefined
|
|
652
|
+
return {
|
|
653
|
+
card: 'generic',
|
|
654
|
+
title: `Rollback fix ${a.id}`,
|
|
655
|
+
kind: 'edit',
|
|
656
|
+
rawInput: { id: a.id },
|
|
657
|
+
}
|
|
658
|
+
},
|
|
630
659
|
description:
|
|
631
660
|
'Revert a previously applied fix. Pass the fix `id` (returned by iterate_fix). ' +
|
|
632
661
|
'The file is restored from the fix backup, the fix is removed from the registry, ' +
|
|
@@ -683,7 +712,7 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
|
|
|
683
712
|
|
|
684
713
|
const nextRegistry = removeRecord(registry, id)
|
|
685
714
|
try {
|
|
686
|
-
|
|
715
|
+
writeJsonAtomic(fixRegistryPath(projectRoot), nextRegistry)
|
|
687
716
|
} catch (err) {
|
|
688
717
|
return { ok: false, error: `failed to update fix registry: ${String(err)}` }
|
|
689
718
|
}
|
|
@@ -695,6 +724,12 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
|
|
|
695
724
|
data: { id, file: record.finding.file, revertedDiff: record.diffSummary },
|
|
696
725
|
})
|
|
697
726
|
|
|
727
|
+
// Mirror the reversal into the persisted observatory transcript (F4) so
|
|
728
|
+
// the client no longer shows a rolled-back fix as "成功". Best-effort:
|
|
729
|
+
// a missing/corrupt transcript is left untouched and never breaks the
|
|
730
|
+
// rollback flow.
|
|
731
|
+
await markFixRolledBackInTranscript(projectRoot, id)
|
|
732
|
+
|
|
698
733
|
return { ok: true, id, file: record.finding.file }
|
|
699
734
|
},
|
|
700
735
|
}),
|
package/src/tools/history.ts
CHANGED
|
@@ -80,6 +80,9 @@ export function registerHistoryTool(ctx: { tools: { register: (def: ReturnType<t
|
|
|
80
80
|
ctx.tools.register(
|
|
81
81
|
defineTool({
|
|
82
82
|
name: 'iterate_history',
|
|
83
|
+
// Read-only audit view over on-disk state → safe to join a parallel
|
|
84
|
+
// dispatch group alongside other read-only sibling calls.
|
|
85
|
+
isConcurrencySafe: () => true,
|
|
83
86
|
description:
|
|
84
87
|
'Read the iteration history: decision-log entries (optionally filtered by entry `type`, `since` ' +
|
|
85
88
|
'timestamp, and a `limit`) plus a summary of the fix registry (per-round fixed/failed counts). ' +
|