iterate-plugin 3.4.0 → 3.5.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.
- package/README.md +24 -9
- package/README.zh-CN.md +19 -9
- package/dist/atomic-fs.js +77 -0
- package/dist/config-write.js +3 -2
- package/dist/git-scope.js +3 -2
- package/dist/live.js +5 -4
- package/dist/skill-prompt.js +16 -5
- package/dist/tools/checkpoint.js +10 -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 +3 -0
- package/dist/tools/experience-store.js +6 -2
- package/dist/tools/fix.js +34 -4
- package/dist/tools/history.js +3 -0
- package/dist/tools/prune.js +50 -6
- package/dist/tools/quality-gate.js +3 -0
- package/dist/tools/quality-store.js +2 -1
- package/dist/tools/transcript.js +7 -5
- package/dist/tools/triage.js +6 -2
- package/dist/tools/validate.js +32 -3
- package/lib/client.js +52 -9
- package/lib/parse.js +53 -6
- package/package.json +4 -2
- package/src/atomic-fs.ts +83 -0
- package/src/client/index.ts +12 -4
- package/src/config-write.ts +3 -2
- package/src/git-scope.ts +3 -2
- package/src/live.ts +5 -4
- package/src/skill-prompt.ts +16 -5
- package/src/tools/checkpoint.ts +10 -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 +3 -0
- package/src/tools/experience-store.ts +6 -2
- package/src/tools/fix.ts +32 -4
- package/src/tools/history.ts +3 -0
- package/src/tools/prune.ts +51 -6
- package/src/tools/quality-gate.ts +3 -0
- package/src/tools/quality-store.ts +2 -1
- package/src/tools/transcript.ts +7 -5
- package/src/tools/triage.ts +6 -2
- package/src/tools/validate.ts +32 -3
- package/src/types.ts +2 -0
package/lib/parse.js
CHANGED
|
@@ -133,12 +133,13 @@ export function countSessionImages(session) {
|
|
|
133
133
|
if (!session || typeof session !== 'object') return 0
|
|
134
134
|
|
|
135
135
|
const ids = new Set()
|
|
136
|
+
const consumed = new Set()
|
|
136
137
|
let count = 0
|
|
137
138
|
|
|
138
139
|
/** @param {unknown} obj */
|
|
139
140
|
const walk = (obj, depth) => {
|
|
140
141
|
if (depth <= 0 || !obj || typeof obj !== 'object') return
|
|
141
|
-
if (seen.has(obj)) return
|
|
142
|
+
if (seen.has(obj) || consumed.has(obj)) return
|
|
142
143
|
seen.add(obj)
|
|
143
144
|
const o = /** @type {Record<string, unknown>} */ (obj)
|
|
144
145
|
|
|
@@ -158,6 +159,9 @@ export function countSessionImages(session) {
|
|
|
158
159
|
} else {
|
|
159
160
|
count += 1
|
|
160
161
|
}
|
|
162
|
+
// The ref node is already counted; mark it consumed so descending into it
|
|
163
|
+
// (its own mediaType/attachmentId keys) does not double-count the image.
|
|
164
|
+
consumed.add(ref)
|
|
161
165
|
}
|
|
162
166
|
|
|
163
167
|
if (Array.isArray(obj)) {
|
|
@@ -364,18 +368,25 @@ function attachLive(manifest, source) {
|
|
|
364
368
|
* manifest buried inside an arbitrary result still surfaces. Never throws.
|
|
365
369
|
*
|
|
366
370
|
* @param {unknown} obj
|
|
371
|
+
* @param {Set<unknown>} [seen]
|
|
372
|
+
* @param {number} [depth]
|
|
367
373
|
* @returns {Record<string, unknown> | null}
|
|
368
374
|
*/
|
|
369
|
-
function extractTranscript(obj) {
|
|
375
|
+
function extractTranscript(obj, seen, depth) {
|
|
376
|
+
if (depth === undefined) depth = 0
|
|
377
|
+
if (depth > 20) return null
|
|
370
378
|
if (typeof obj === 'string') {
|
|
371
379
|
try {
|
|
372
380
|
const parsed = JSON.parse(obj)
|
|
373
|
-
return extractTranscript(parsed)
|
|
381
|
+
return extractTranscript(parsed, seen, depth + 1)
|
|
374
382
|
} catch {
|
|
375
383
|
return null
|
|
376
384
|
}
|
|
377
385
|
}
|
|
378
386
|
if (!obj || typeof obj !== 'object') return null
|
|
387
|
+
if (!seen) seen = new Set()
|
|
388
|
+
if (seen.has(obj)) return null
|
|
389
|
+
seen.add(obj)
|
|
379
390
|
|
|
380
391
|
// Direct manifest.
|
|
381
392
|
if (isTranscriptManifest(obj)) return /** @type {Record<string, unknown>} */ (obj)
|
|
@@ -397,11 +408,11 @@ function extractTranscript(obj) {
|
|
|
397
408
|
if (val !== undefined) {
|
|
398
409
|
if (Array.isArray(val)) {
|
|
399
410
|
for (const item of val) {
|
|
400
|
-
const found = extractTranscript(item)
|
|
411
|
+
const found = extractTranscript(item, seen, depth + 1)
|
|
401
412
|
if (found) return attachLive(found, o)
|
|
402
413
|
}
|
|
403
414
|
} else {
|
|
404
|
-
const found = extractTranscript(val)
|
|
415
|
+
const found = extractTranscript(val, seen, depth + 1)
|
|
405
416
|
if (found) return attachLive(found, o)
|
|
406
417
|
}
|
|
407
418
|
}
|
|
@@ -745,6 +756,29 @@ function latestToolResultNode(session, toolName) {
|
|
|
745
756
|
const messages = safeGet(s, 'messages')
|
|
746
757
|
if (Array.isArray(messages)) {
|
|
747
758
|
const msgs = /** @type {Array<Record<string, unknown>>} */ (messages)
|
|
759
|
+
// Assistant tool-call variant: the tool result may live on the call object
|
|
760
|
+
// inside message.tool_calls rather than on session.toolCalls. Scan every
|
|
761
|
+
// message for a matching call (newest-first) before falling back to the
|
|
762
|
+
// last message's raw content — a conversational closing message must not
|
|
763
|
+
// shadow an earlier message's real tool result.
|
|
764
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
765
|
+
const msg = msgs[i]
|
|
766
|
+
if (!msg) continue
|
|
767
|
+
const calls = safeGet(msg, 'tool_calls')
|
|
768
|
+
if (!Array.isArray(calls)) continue
|
|
769
|
+
const callList = /** @type {Array<Record<string, unknown>>} */ (calls)
|
|
770
|
+
for (let j = callList.length - 1; j >= 0; j--) {
|
|
771
|
+
const call = callList[j]
|
|
772
|
+
if (!call) continue
|
|
773
|
+
const name = String(safeGet(call, 'name') ?? safeGet(call, 'tool') ?? '')
|
|
774
|
+
if (name !== toolName && !name.endsWith(toolName)) continue
|
|
775
|
+
const result = safeGet(call, 'result') ?? safeGet(call, 'response') ?? safeGet(call, 'message')
|
|
776
|
+
if (result !== undefined && result !== null) return result
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
// Fallback: the last message's raw content (pre-existing behavior — a
|
|
780
|
+
// plain conversational string yields null upstream, but an embedded JSON
|
|
781
|
+
// body is still worth surfacing for the deep-scan finders).
|
|
748
782
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
749
783
|
const msg = msgs[i]
|
|
750
784
|
if (!msg) continue
|
|
@@ -1151,6 +1185,7 @@ function computeSummaryFromFindings(findings) {
|
|
|
1151
1185
|
const byDimension = {}
|
|
1152
1186
|
|
|
1153
1187
|
for (const f of findings) {
|
|
1188
|
+
if (!f || typeof f !== 'object') continue
|
|
1154
1189
|
const sev = String(f.severity ?? 'low')
|
|
1155
1190
|
if (sev in counts) counts[sev]++
|
|
1156
1191
|
const dim = String(f.dimension ?? 'unknown')
|
|
@@ -1224,6 +1259,7 @@ export function severityStats(report) {
|
|
|
1224
1259
|
const findings = /** @type {Array<Record<string, unknown>>} */ (report.findings ?? [])
|
|
1225
1260
|
const counts = { critical: 0, high: 0, medium: 0, low: 0 }
|
|
1226
1261
|
for (const f of findings) {
|
|
1262
|
+
if (!f || typeof f !== 'object') continue
|
|
1227
1263
|
const sev = String(f.severity ?? 'low')
|
|
1228
1264
|
if (sev in counts) counts[sev]++
|
|
1229
1265
|
}
|
|
@@ -1243,6 +1279,7 @@ export function groupByDimension(report) {
|
|
|
1243
1279
|
/** @type {Record<string, Array<Record<string, unknown>>>} */
|
|
1244
1280
|
const groups = {}
|
|
1245
1281
|
for (const f of findings) {
|
|
1282
|
+
if (!f || typeof f !== 'object') continue
|
|
1246
1283
|
const dim = String(f.dimension ?? 'unknown')
|
|
1247
1284
|
if (!groups[dim]) groups[dim] = []
|
|
1248
1285
|
groups[dim].push(f)
|
|
@@ -1412,6 +1449,7 @@ export function normalizeFindingFilter(filter) {
|
|
|
1412
1449
|
* @returns {boolean}
|
|
1413
1450
|
*/
|
|
1414
1451
|
export function findingMatches(finding, filter) {
|
|
1452
|
+
if (!finding || typeof finding !== 'object') return false
|
|
1415
1453
|
const f = normalizeFindingFilter(filter)
|
|
1416
1454
|
const sev = String(finding.severity ?? 'low')
|
|
1417
1455
|
if (f.severities.length > 0 && !f.severities.includes(sev)) return false
|
|
@@ -1553,6 +1591,7 @@ export function setAllVerdicts(triageState, verdict, indices) {
|
|
|
1553
1591
|
export function buildRoundHistory(report) {
|
|
1554
1592
|
const rounds = Array.isArray(report.rounds) ? report.rounds : []
|
|
1555
1593
|
return rounds.map((r) => {
|
|
1594
|
+
if (!r || typeof r !== 'object') return { round: 0, count: 0, critical: 0, high: 0, medium: 0, low: 0 }
|
|
1556
1595
|
const rr = /** @type {Record<string, unknown>} */ (r)
|
|
1557
1596
|
const findings = Array.isArray(rr.findings) ? rr.findings : []
|
|
1558
1597
|
const sev = severityStats({ findings })
|
|
@@ -1823,7 +1862,15 @@ export function filterTimelineEntries(entries, opts) {
|
|
|
1823
1862
|
if (type && String(t.type ?? '') !== type) return false
|
|
1824
1863
|
if (round && String(t.round ?? '') !== round) return false
|
|
1825
1864
|
if (q) {
|
|
1826
|
-
|
|
1865
|
+
let dataText = ''
|
|
1866
|
+
try {
|
|
1867
|
+
dataText = JSON.stringify(t.data ?? {})
|
|
1868
|
+
} catch {
|
|
1869
|
+
// A self-referential `data` reference would throw on stringify — degrade
|
|
1870
|
+
// to a string representation rather than crashing the search.
|
|
1871
|
+
dataText = t.data === undefined ? '{}' : String(t.data)
|
|
1872
|
+
}
|
|
1873
|
+
const hay = [String(t.type ?? ''), String(t.round ?? ''), dataText].join(' ').toLowerCase()
|
|
1827
1874
|
if (hay.indexOf(q) < 0) return false
|
|
1828
1875
|
}
|
|
1829
1876
|
return true
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iterate-plugin",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.0",
|
|
4
4
|
"description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness with quality command center and experience bank (v3.3). Features: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus dry-run pure-review mode, quality gate compute/persist, writable experience bank, defense events stream (record + bilingual labels), and native command buttons — with a live quality command center (F8/F9/F10 render real session data), §8 assign-fix instruction, and task_mode indicator wired end to end.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,9 +29,11 @@
|
|
|
29
29
|
},
|
|
30
30
|
"compatibility": {
|
|
31
31
|
"dshReleases": {
|
|
32
|
+
"0.1.1-rc.1": "compatible",
|
|
32
33
|
"0.1.2-alpha.4": "compatible",
|
|
33
34
|
"0.1.2-alpha.5": "compatible",
|
|
34
|
-
"0.1.2-rc.1": "compatible"
|
|
35
|
+
"0.1.2-rc.1": "compatible",
|
|
36
|
+
"0.1.3-alpha.1": "compatible"
|
|
35
37
|
}
|
|
36
38
|
},
|
|
37
39
|
"client": {
|
package/src/atomic-fs.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/atomic-fs.ts — crash-safe file writes for all plugin state.
|
|
3
|
+
*
|
|
4
|
+
* Every JSON/YAML artifact the plugin persists (.iterate/*.json,
|
|
5
|
+
* iterate.config.yaml) is written through here: content goes to a
|
|
6
|
+
* uniquely-named temp file in the SAME directory as the target (rename is
|
|
7
|
+
* only atomic within one filesystem), then is `renameSync`ed over the
|
|
8
|
+
* destination. A crash mid-write can therefore leave a stale temp file
|
|
9
|
+
* behind, but never a truncated / half-written state file — readers
|
|
10
|
+
* (which all tolerate missing/corrupt files) see either the previous or
|
|
11
|
+
* the new content, never a mixture.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { dirname, join } from 'node:path'
|
|
15
|
+
import { renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
16
|
+
import { rename, rm, writeFile } from 'node:fs/promises'
|
|
17
|
+
|
|
18
|
+
/** Best-effort unlink of a leftover temp file (never throws). */
|
|
19
|
+
function cleanupTemp(tmp: string): void {
|
|
20
|
+
try {
|
|
21
|
+
rmSync(tmp, { force: true })
|
|
22
|
+
} catch {
|
|
23
|
+
/* ignore — temp files follow the prunable `.tmp-<pid>-<rand>` convention */
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Best-effort async unlink of a leftover temp file (never throws). */
|
|
28
|
+
async function cleanupTempAsync(tmp: string): Promise<void> {
|
|
29
|
+
try {
|
|
30
|
+
await rm(tmp, { force: true })
|
|
31
|
+
} catch {
|
|
32
|
+
/* ignore — temp files follow the prunable `.tmp-<pid>-<rand>` convention */
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Unique temp file path for `filePath`, in the same directory, following the
|
|
38
|
+
* prunable `.<basename>.tmp-<pid>-<random>` convention (see prune.ts
|
|
39
|
+
* `isPrunableTemp`). The pid+random suffix makes concurrent writers safe.
|
|
40
|
+
*/
|
|
41
|
+
function tempPathFor(filePath: string): string {
|
|
42
|
+
return join(
|
|
43
|
+
dirname(filePath),
|
|
44
|
+
`.${filePath.split('/').pop() ?? 'file'}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`,
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Write `data` to `filePath` atomically (temp file + rename, same dir).
|
|
50
|
+
* Temp files follow the `<name>.tmp-<pid>-<random>` convention also used by
|
|
51
|
+
* prune.ts / checkpoint.ts so they are recognizable and prunable.
|
|
52
|
+
* Throws on failure (callers own error reporting — they map failures to
|
|
53
|
+
* structured tool results).
|
|
54
|
+
*/
|
|
55
|
+
export function writeTextAtomic(filePath: string, data: string): void {
|
|
56
|
+
const tmp = tempPathFor(filePath)
|
|
57
|
+
writeFileSync(tmp, data, 'utf-8')
|
|
58
|
+
try {
|
|
59
|
+
renameSync(tmp, filePath)
|
|
60
|
+
} catch (err) {
|
|
61
|
+
// Best-effort temp cleanup so a failed write never litters the directory.
|
|
62
|
+
cleanupTemp(tmp)
|
|
63
|
+
throw err
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Async variant of {@link writeTextAtomic} (same temp naming convention). */
|
|
68
|
+
export async function writeTextAtomicAsync(filePath: string, data: string): Promise<void> {
|
|
69
|
+
const tmp = tempPathFor(filePath)
|
|
70
|
+
await writeFile(tmp, data, 'utf-8')
|
|
71
|
+
try {
|
|
72
|
+
await rename(tmp, filePath)
|
|
73
|
+
} catch (err) {
|
|
74
|
+
// Best-effort temp cleanup so a failed write never litters the directory.
|
|
75
|
+
await cleanupTempAsync(tmp)
|
|
76
|
+
throw err
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** JSON.stringify (2-space indent) + atomic write in one call. */
|
|
81
|
+
export function writeJsonAtomic(filePath: string, value: unknown): void {
|
|
82
|
+
writeTextAtomic(filePath, JSON.stringify(value, null, 2))
|
|
83
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -61,7 +61,6 @@ import {
|
|
|
61
61
|
batchSetVerdict,
|
|
62
62
|
setAllVerdicts,
|
|
63
63
|
buildRoundHistory,
|
|
64
|
-
buildFindingTrend,
|
|
65
64
|
computeTrendMetrics,
|
|
66
65
|
trendMax,
|
|
67
66
|
buildCompletionSummary,
|
|
@@ -1226,7 +1225,10 @@ function TriagePanel(props: SlotProps) {
|
|
|
1226
1225
|
|
|
1227
1226
|
// v3.0: Command handlers for native action buttons
|
|
1228
1227
|
const doApproveArchitecturalFix = () => {
|
|
1229
|
-
|
|
1228
|
+
// Architectural findings exceed the atomic threshold, so "approving" one
|
|
1229
|
+
// means telling the model to apply it as a deliberate forced change via
|
|
1230
|
+
// iterate_fix with force:true (is_architectural is not a real parameter).
|
|
1231
|
+
const cmd = '请调用 `iterate_fix`,对本项目剩余架构型 finding 生成修复内容并设置 force: true 应用(架构修复允许超过 atomic 阈值)'
|
|
1230
1232
|
copyText(cmd).then((ok) => {
|
|
1231
1233
|
if (ok) {
|
|
1232
1234
|
setCmdCopied('approve-arch')
|
|
@@ -1246,7 +1248,10 @@ function TriagePanel(props: SlotProps) {
|
|
|
1246
1248
|
}
|
|
1247
1249
|
|
|
1248
1250
|
const doRollbackToCheckpoint = () => {
|
|
1249
|
-
|
|
1251
|
+
// iterate_checkpoint has NO rollback operation (save/load/resume/clear
|
|
1252
|
+
// only); reverting applied fixes is done via iterate_rollback by fix id,
|
|
1253
|
+
// so the paste-able instruction must point there instead.
|
|
1254
|
+
const cmd = '请回滚上一轮迭代的修复:调用 `iterate_history` 查看本轮 fix id,再用 `iterate_rollback` 逐个撤销这些修复'
|
|
1250
1255
|
copyText(cmd).then((ok) => {
|
|
1251
1256
|
if (ok) {
|
|
1252
1257
|
setCmdCopied('rollback')
|
|
@@ -2267,7 +2272,10 @@ function ObservatoryPanel(props: SlotProps) {
|
|
|
2267
2272
|
const sorted = filtered.slice()
|
|
2268
2273
|
const rows = sorted.map((t, i) => {
|
|
2269
2274
|
const k = `f7-${i}`
|
|
2270
|
-
|
|
2275
|
+
let dataText = ''
|
|
2276
|
+
if (t.data && typeof t.data === 'object') {
|
|
2277
|
+
try { dataText = JSON.stringify(t.data) } catch { dataText = String(t.data) }
|
|
2278
|
+
}
|
|
2271
2279
|
return React.createElement('div', { key: k, className: 'iterate-obs-row', style: { flexDirection: 'column', alignItems: 'flex-start' } },
|
|
2272
2280
|
React.createElement('div', { className: 'iterate-obs-bar', style: { width: '100%' } },
|
|
2273
2281
|
React.createElement('span', { className: 'iterate-obs-chip' }, String(t.type || '?')),
|
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/git-scope.ts
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import { execFile } from 'node:child_process'
|
|
25
25
|
import { existsSync, statSync } from 'node:fs'
|
|
26
|
-
import { join } from 'node:path'
|
|
26
|
+
import { join, sep } from 'node:path'
|
|
27
27
|
|
|
28
28
|
/** A resolved changed-only scope result. */
|
|
29
29
|
export interface GitScopeResult {
|
|
@@ -113,10 +113,11 @@ export function parseChangedFiles(stdout: string): string[] {
|
|
|
113
113
|
*/
|
|
114
114
|
export function filterExistingFiles(root: string, files: string[]): string[] {
|
|
115
115
|
const out: string[] = []
|
|
116
|
+
const rootPrefix = root.endsWith(sep) ? root : root + sep
|
|
116
117
|
for (const rel of files) {
|
|
117
118
|
if (rel.startsWith('/') || rel.includes('\0')) continue
|
|
118
119
|
const candidate = join(root, rel)
|
|
119
|
-
if (!candidate.startsWith(
|
|
120
|
+
if (!candidate.startsWith(rootPrefix) && candidate !== root) continue
|
|
120
121
|
try {
|
|
121
122
|
if (existsSync(candidate) && statSync(candidate).isFile()) out.push(rel)
|
|
122
123
|
} catch {
|
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'
|
|
@@ -136,9 +137,9 @@ export async function appendLive(projectRoot: string, entry: LiveActivityEntry):
|
|
|
136
137
|
const raw = await readFile(file, 'utf-8')
|
|
137
138
|
const lines = raw.split('\n').filter(Boolean)
|
|
138
139
|
const tail = lines.slice(-LIVE_MAX_ENTRIES)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
await
|
|
140
|
+
// Atomic rewrite (unique temp + rename) so a crash mid-trim never
|
|
141
|
+
// truncates the live feed; the temp file is prunable by iterate_prune.
|
|
142
|
+
await writeTextAtomicAsync(file, tail.join('\n') + '\n')
|
|
142
143
|
}
|
|
143
144
|
await appendFile(file, line, 'utf-8')
|
|
144
145
|
} catch {
|
package/src/skill-prompt.ts
CHANGED
|
@@ -143,9 +143,20 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
143
143
|
// Feed the DEDUPED + already-filtered set back (not raw findings) so the known
|
|
144
144
|
// list stays bounded and reviewers never see the same issue twice.
|
|
145
145
|
if (agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
146
|
+
// A round counts as "converged" ONLY when the aggregate accepted it
|
|
147
|
+
// (schema-valid) AND it genuinely found zero NEW findings. If reviewers all
|
|
148
|
+
// failed or their output stayed schema-invalid after retries, the round is
|
|
149
|
+
// INCONCLUSIVE — never report an empty-but-broken round as converged (an
|
|
150
|
+
// invalid/failed round must fail the run, not masquerade as a clean pass).
|
|
151
|
+
if (!schemaInvalid) {
|
|
152
|
+
const newCount =
|
|
153
|
+
agg && agg.report && agg.report.convergence
|
|
154
|
+
? agg.report.convergence.findingsByRound[r - 1]
|
|
155
|
+
: thisRound.findings.length
|
|
156
|
+
if (newCount === 0) {
|
|
157
|
+
log('round ' + r + ' found 0 new findings — converged')
|
|
158
|
+
break
|
|
159
|
+
}
|
|
149
160
|
}
|
|
150
161
|
}
|
|
151
162
|
|
|
@@ -238,7 +249,7 @@ if (checkpoint) {
|
|
|
238
249
|
|
|
239
250
|
phase('plan')
|
|
240
251
|
const configRes = await agent(
|
|
241
|
-
'Call iterate_config({
|
|
252
|
+
'Call iterate_config({}) and return the config JSON.',
|
|
242
253
|
Object.assign({ label: 'config:read' }, backend)
|
|
243
254
|
)
|
|
244
255
|
const cfg = (configRes && configRes.config) ? configRes.config : null
|
|
@@ -421,7 +432,7 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
421
432
|
Object.assign({ label: 'checkpoint:save:r' + r }, backend)
|
|
422
433
|
)
|
|
423
434
|
|
|
424
|
-
if (atomic.length === 0 && remaining.length === 0) {
|
|
435
|
+
if (atomic.length === 0 && remaining.length === 0 && !schemaInvalid) {
|
|
425
436
|
log('round ' + r + ' found nothing to fix — converged')
|
|
426
437
|
converged = true
|
|
427
438
|
break
|
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.',
|
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
|
}
|
|
@@ -72,6 +72,9 @@ 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"). ' +
|