iterate-plugin 3.7.0 → 3.9.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 +43 -8
- package/README.zh-CN.md +26 -5
- package/dist/file-lock.js +75 -14
- package/dist/git-scope.js +22 -2
- package/dist/index.js +9 -4
- package/dist/report-render.js +452 -0
- package/dist/session-record.js +246 -0
- package/dist/skill-prompt.js +4 -3
- package/dist/tools/checkpoint.js +24 -17
- package/dist/tools/config.js +42 -33
- package/dist/tools/decision-log.js +13 -93
- package/dist/tools/report.js +239 -0
- package/dist/tools/transcript.js +260 -53
- package/dist/tools/triage.js +52 -45
- package/dist/transcript.js +2 -2
- package/lib/client.js +486 -22
- package/lib/parse.js +29 -6
- package/package.json +38 -13
- package/src/client/i18n.ts +6 -0
- package/src/client/index.ts +66 -4
- package/src/file-lock.ts +84 -14
- package/src/git-scope.ts +21 -2
- package/src/index.ts +9 -4
- package/src/report-render.ts +578 -0
- package/src/session-record.ts +333 -0
- package/src/skill-prompt.ts +4 -3
- package/src/tools/checkpoint.ts +23 -16
- package/src/tools/config.ts +40 -31
- package/src/tools/decision-log.ts +12 -80
- package/src/tools/report.ts +244 -0
- package/src/tools/transcript.ts +271 -52
- package/src/tools/triage.ts +47 -40
- package/src/transcript.ts +2 -2
- package/src/types.ts +1 -1
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/session-record.ts — dsh session-linked run records (dsh ≥ 0.2.1-rc.1).
|
|
3
|
+
*
|
|
4
|
+
* Upstream capability (dsh `0.2.1-rc.1`, released 2026-10-17): the *Session
|
|
5
|
+
* state-record API*. An experimental plugin may append its own record onto a
|
|
6
|
+
* session's append-only log with `appendPluginRecord(session, type, data)` and
|
|
7
|
+
* read it back with `pluginRecordOf(event)` (`@deepseek-ai/dsh-session`). The
|
|
8
|
+
* record is marked `ignorable`, never enters the model-visible surface, and is
|
|
9
|
+
* carried along by fork / resume / format migration.
|
|
10
|
+
*
|
|
11
|
+
* Why the plugin adopts it:
|
|
12
|
+
* The iterate run state has, until now, lived ONLY under `<projectRoot>/.iterate/`.
|
|
13
|
+
* That is correct for normal operation but has two blind spots the session
|
|
14
|
+
* log closes:
|
|
15
|
+
* 1. **Loss recovery** — if `.iterate/` is deleted, moved, or the working
|
|
16
|
+
* directory changes, the observatory goes blank even though the session
|
|
17
|
+
* that produced the run is still open. A compact run record on the
|
|
18
|
+
* session log lets `iterate_transcript read` rehydrate the run summary
|
|
19
|
+
* instead of returning an empty view.
|
|
20
|
+
* 2. **Session anchoring** — run provenance is tied to the *session* that
|
|
21
|
+
* produced it, not only to whatever directory the process happens to be
|
|
22
|
+
* in when someone looks afterwards.
|
|
23
|
+
*
|
|
24
|
+
* Design (fail-safe, never a hard dependency):
|
|
25
|
+
* - The API is loaded lazily via a dynamic `import()` and used structurally
|
|
26
|
+
* (no compile-time coupling to `PluginRecordMap`), so a host whose copy of
|
|
27
|
+
* `@deepseek-ai/dsh-session` predates the API simply reports
|
|
28
|
+
* `supported: false` instead of failing to load the plugin.
|
|
29
|
+
* - Every entry point returns a structured result; nothing throws. Writing a
|
|
30
|
+
* record is best-effort — a run must never fail because the session log
|
|
31
|
+
* could not accept a bookkeeping record.
|
|
32
|
+
* - `normalizeRunRecord` re-validates a restored payload, because a record
|
|
33
|
+
* read from a resumed session carries whatever JSON an earlier build wrote.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** The `plugin:` record type this plugin owns (grammar-checked by dsh). */
|
|
37
|
+
export const ITERATE_RUN_RECORD_TYPE = 'plugin:iterate/run'
|
|
38
|
+
/** Bump when the record payload shape changes incompatibly. */
|
|
39
|
+
export const ITERATE_RUN_RECORD_VERSION = 1
|
|
40
|
+
|
|
41
|
+
/** Compact, lossless-JSON summary of one iterate run at a point in time. */
|
|
42
|
+
export interface IterateRunRecord {
|
|
43
|
+
version: number
|
|
44
|
+
/** Project root the run belongs to (absolute at write time). */
|
|
45
|
+
project: string
|
|
46
|
+
goal: string
|
|
47
|
+
mode: 'dry-run' | 'normal' | null
|
|
48
|
+
taskMode: 'code' | 'iterate' | null
|
|
49
|
+
/** Highest round reached. */
|
|
50
|
+
round: number
|
|
51
|
+
maxRounds: number
|
|
52
|
+
/** False once the run recorded a terminal `stoppedReason`. */
|
|
53
|
+
active: boolean
|
|
54
|
+
stoppedReason: string | null
|
|
55
|
+
/** Per-round NEW-finding counts, newest last (drives the F2 trend on recovery). */
|
|
56
|
+
convergence: number[]
|
|
57
|
+
findings: {
|
|
58
|
+
total: number
|
|
59
|
+
critical: number
|
|
60
|
+
high: number
|
|
61
|
+
medium: number
|
|
62
|
+
low: number
|
|
63
|
+
}
|
|
64
|
+
/** Applied (successful) fixes recorded so far. */
|
|
65
|
+
fixedCount: number
|
|
66
|
+
/** Snapshot of the quality-gate verdict at write time, when known. */
|
|
67
|
+
qualityStatus: 'pass' | 'fail' | 'pending' | null
|
|
68
|
+
/** ISO timestamp the record was written. */
|
|
69
|
+
updatedAt: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A run record read back from a session log, with its log position. */
|
|
73
|
+
export interface StoredRunRecord extends IterateRunRecord {
|
|
74
|
+
/** Session-log sequence number the record occupies. */
|
|
75
|
+
seq: number
|
|
76
|
+
/** Unix epoch milliseconds at append (from the host). */
|
|
77
|
+
time: number
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Minimal structural view of the session-log API we consume. */
|
|
81
|
+
export interface SessionRecordApi {
|
|
82
|
+
appendPluginRecord(session: unknown, type: string, data: unknown): number
|
|
83
|
+
pluginRecordOf(event: unknown): { type: string; seq: number; time: number; data: unknown } | undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Minimal structural view of a dsh Session (only what this module reads). */
|
|
87
|
+
export interface SessionLike {
|
|
88
|
+
ownEvents?(): readonly unknown[]
|
|
89
|
+
snapshotEvents?(): readonly unknown[]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Result of a best-effort record append. */
|
|
93
|
+
export type AppendRecordResult =
|
|
94
|
+
| { ok: true; supported: true; seq: number }
|
|
95
|
+
| { ok: false; supported: boolean; error: string }
|
|
96
|
+
|
|
97
|
+
/** Result of reading the session's iterate run records. */
|
|
98
|
+
export interface ReadRecordsResult {
|
|
99
|
+
supported: boolean
|
|
100
|
+
/** Oldest → newest. */
|
|
101
|
+
records: StoredRunRecord[]
|
|
102
|
+
/** Present only when the read itself failed unexpectedly. */
|
|
103
|
+
error?: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── Pure payload construction / validation ─────────────────────────────────
|
|
107
|
+
|
|
108
|
+
/** Coerce an unknown to a finite non-negative integer, else `fallback`. */
|
|
109
|
+
function nonNegInt(value: unknown, fallback = 0): number {
|
|
110
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback
|
|
111
|
+
const n = Math.floor(value)
|
|
112
|
+
return n >= 0 ? n : fallback
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Extract a string, else `''`. */
|
|
116
|
+
function str(value: unknown): string {
|
|
117
|
+
return typeof value === 'string' ? value : ''
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Extract one of the allowed literal values, else `null`. */
|
|
121
|
+
function oneOf<T extends string>(value: unknown, allowed: readonly T[]): T | null {
|
|
122
|
+
return typeof value === 'string' && (allowed as readonly string[]).includes(value) ? (value as T) : null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Severity counts recovered from an on-disk manifest summary. */
|
|
126
|
+
export interface RunRecordCounts {
|
|
127
|
+
total: number
|
|
128
|
+
critical: number
|
|
129
|
+
high: number
|
|
130
|
+
medium: number
|
|
131
|
+
low: number
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Build a run record from the fields a persisted transcript manifest exposes.
|
|
136
|
+
* Pure: the caller passes already-normalized values, so this is unit-testable
|
|
137
|
+
* without touching disk or a session.
|
|
138
|
+
*/
|
|
139
|
+
export function buildRunRecord(input: {
|
|
140
|
+
project?: unknown
|
|
141
|
+
goal?: unknown
|
|
142
|
+
mode?: unknown
|
|
143
|
+
taskMode?: unknown
|
|
144
|
+
round?: unknown
|
|
145
|
+
maxRounds?: unknown
|
|
146
|
+
active?: unknown
|
|
147
|
+
stoppedReason?: unknown
|
|
148
|
+
convergence?: unknown
|
|
149
|
+
counts?: Partial<RunRecordCounts>
|
|
150
|
+
fixedCount?: unknown
|
|
151
|
+
qualityStatus?: unknown
|
|
152
|
+
now?: string
|
|
153
|
+
}): IterateRunRecord {
|
|
154
|
+
const convergence = Array.isArray(input.convergence)
|
|
155
|
+
? input.convergence
|
|
156
|
+
.filter((n): n is number => typeof n === 'number' && Number.isFinite(n) && n >= 0)
|
|
157
|
+
.slice(-1000)
|
|
158
|
+
: []
|
|
159
|
+
const c = input.counts ?? {}
|
|
160
|
+
const stopped = str(input.stoppedReason)
|
|
161
|
+
return {
|
|
162
|
+
version: ITERATE_RUN_RECORD_VERSION,
|
|
163
|
+
project: str(input.project),
|
|
164
|
+
goal: str(input.goal),
|
|
165
|
+
mode: oneOf(input.mode, ['dry-run', 'normal'] as const),
|
|
166
|
+
taskMode: oneOf(input.taskMode, ['code', 'iterate'] as const),
|
|
167
|
+
round: nonNegInt(input.round),
|
|
168
|
+
maxRounds: nonNegInt(input.maxRounds),
|
|
169
|
+
active: input.active !== false,
|
|
170
|
+
stoppedReason: stopped ? stopped : null,
|
|
171
|
+
convergence,
|
|
172
|
+
findings: {
|
|
173
|
+
total: nonNegInt(c.total),
|
|
174
|
+
critical: nonNegInt(c.critical),
|
|
175
|
+
high: nonNegInt(c.high),
|
|
176
|
+
medium: nonNegInt(c.medium),
|
|
177
|
+
low: nonNegInt(c.low),
|
|
178
|
+
},
|
|
179
|
+
fixedCount: nonNegInt(input.fixedCount),
|
|
180
|
+
qualityStatus: oneOf(input.qualityStatus, ['pass', 'fail', 'pending'] as const),
|
|
181
|
+
updatedAt: str(input.now) || new Date().toISOString(),
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Owner-side validation of a payload read back from a session log.
|
|
187
|
+
* A restored record carries whatever JSON an earlier build of this plugin
|
|
188
|
+
* wrote, so every field is re-checked; anything unrecognizable returns `null`
|
|
189
|
+
* (the caller skips it) rather than surfacing junk to a renderer.
|
|
190
|
+
*/
|
|
191
|
+
export function normalizeRunRecord(data: unknown): IterateRunRecord | null {
|
|
192
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) return null
|
|
193
|
+
const d = data as Record<string, unknown>
|
|
194
|
+
// A record written by a future/incompatible build is skipped rather than
|
|
195
|
+
// coerced: the version gate is the contract that lets the payload evolve.
|
|
196
|
+
if (nonNegInt(d.version) !== ITERATE_RUN_RECORD_VERSION) return null
|
|
197
|
+
const findings = (d.findings && typeof d.findings === 'object' && !Array.isArray(d.findings)
|
|
198
|
+
? (d.findings as Record<string, unknown>)
|
|
199
|
+
: {}) as Record<string, unknown>
|
|
200
|
+
return buildRunRecord({
|
|
201
|
+
project: d.project,
|
|
202
|
+
goal: d.goal,
|
|
203
|
+
mode: d.mode,
|
|
204
|
+
taskMode: d.taskMode,
|
|
205
|
+
round: d.round,
|
|
206
|
+
maxRounds: d.maxRounds,
|
|
207
|
+
active: d.active,
|
|
208
|
+
stoppedReason: d.stoppedReason,
|
|
209
|
+
convergence: d.convergence,
|
|
210
|
+
counts: {
|
|
211
|
+
total: findings.total as number,
|
|
212
|
+
critical: findings.critical as number,
|
|
213
|
+
high: findings.high as number,
|
|
214
|
+
medium: findings.medium as number,
|
|
215
|
+
low: findings.low as number,
|
|
216
|
+
},
|
|
217
|
+
fixedCount: d.fixedCount,
|
|
218
|
+
qualityStatus: d.qualityStatus,
|
|
219
|
+
now: typeof d.updatedAt === 'string' ? d.updatedAt : undefined,
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ─── Session access (lazy, fail-safe) ────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
/** Lazily-resolved production API; `undefined` = not yet attempted. */
|
|
226
|
+
let cachedApi: Promise<SessionRecordApi | null> | undefined
|
|
227
|
+
/** Test override; when set (including `null`) the dynamic import is bypassed. */
|
|
228
|
+
let testApiOverride: SessionRecordApi | null | undefined
|
|
229
|
+
|
|
230
|
+
/** Resolve the `@deepseek-ai/dsh-session` record API, or `null` when absent. */
|
|
231
|
+
export function loadSessionRecordApi(): Promise<SessionRecordApi | null> {
|
|
232
|
+
if (testApiOverride !== undefined) return Promise.resolve(testApiOverride)
|
|
233
|
+
if (cachedApi === undefined) {
|
|
234
|
+
cachedApi = import('@deepseek-ai/dsh-session')
|
|
235
|
+
.then((mod) => {
|
|
236
|
+
const m = mod as unknown as Partial<SessionRecordApi>
|
|
237
|
+
if (typeof m.appendPluginRecord !== 'function' || typeof m.pluginRecordOf !== 'function') {
|
|
238
|
+
return null
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
appendPluginRecord: m.appendPluginRecord.bind(mod),
|
|
242
|
+
pluginRecordOf: m.pluginRecordOf.bind(mod),
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
.catch(() => null)
|
|
246
|
+
}
|
|
247
|
+
return cachedApi
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Override the session-record API for tests. Pass `undefined` to restore the
|
|
252
|
+
* real dynamic import, or an object / `null` to force a deterministic result.
|
|
253
|
+
*/
|
|
254
|
+
export function setSessionRecordApiForTests(api: SessionRecordApi | null | undefined): void {
|
|
255
|
+
testApiOverride = api
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Extract the session from a tool execution (`exec.agent.session`), defensively. */
|
|
259
|
+
export function sessionOf(exec: unknown): SessionLike | undefined {
|
|
260
|
+
try {
|
|
261
|
+
const agent = (exec as { agent?: { session?: unknown } } | null | undefined)?.agent
|
|
262
|
+
const session = agent?.session
|
|
263
|
+
if (!session || typeof session !== 'object') return undefined
|
|
264
|
+
const s = session as SessionLike
|
|
265
|
+
if (typeof s.ownEvents === 'function' || typeof s.snapshotEvents === 'function') return s
|
|
266
|
+
return undefined
|
|
267
|
+
} catch {
|
|
268
|
+
return undefined
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Read every event a session exposes (own events preferred, snapshot fallback). */
|
|
273
|
+
function eventsOf(session: SessionLike): readonly unknown[] {
|
|
274
|
+
try {
|
|
275
|
+
if (typeof session.ownEvents === 'function') return session.ownEvents() ?? []
|
|
276
|
+
} catch {
|
|
277
|
+
// fall through to snapshot
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
if (typeof session.snapshotEvents === 'function') return session.snapshotEvents() ?? []
|
|
281
|
+
} catch {
|
|
282
|
+
// no readable log
|
|
283
|
+
}
|
|
284
|
+
return []
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Best-effort append of one run record to the session log.
|
|
289
|
+
* Never throws: an absent API, a degraded session, or a rejected payload all
|
|
290
|
+
* surface as `{ ok: false }` so the surrounding tool call can continue.
|
|
291
|
+
*/
|
|
292
|
+
export async function appendRunRecord(
|
|
293
|
+
session: SessionLike | undefined,
|
|
294
|
+
record: IterateRunRecord,
|
|
295
|
+
): Promise<AppendRecordResult> {
|
|
296
|
+
const api = await loadSessionRecordApi()
|
|
297
|
+
if (!api) return { ok: false, supported: false, error: 'session record API unavailable' }
|
|
298
|
+
if (!session) return { ok: false, supported: true, error: 'no session in scope' }
|
|
299
|
+
try {
|
|
300
|
+
const seq = api.appendPluginRecord(session, ITERATE_RUN_RECORD_TYPE, record)
|
|
301
|
+
return { ok: true, supported: true, seq }
|
|
302
|
+
} catch (err) {
|
|
303
|
+
return {
|
|
304
|
+
ok: false,
|
|
305
|
+
supported: true,
|
|
306
|
+
error: `session rejected the run record: ${err instanceof Error ? err.message : String(err)}`,
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Read every iterate run record from the session log (oldest → newest). */
|
|
312
|
+
export async function readRunRecords(session: SessionLike | undefined): Promise<ReadRecordsResult> {
|
|
313
|
+
const api = await loadSessionRecordApi()
|
|
314
|
+
if (!api) return { supported: false, records: [] }
|
|
315
|
+
if (!session) return { supported: true, records: [] }
|
|
316
|
+
try {
|
|
317
|
+
const records: StoredRunRecord[] = []
|
|
318
|
+
for (const event of eventsOf(session)) {
|
|
319
|
+
const rec = api.pluginRecordOf(event)
|
|
320
|
+
if (!rec || rec.type !== ITERATE_RUN_RECORD_TYPE) continue
|
|
321
|
+
const normalized = normalizeRunRecord(rec.data)
|
|
322
|
+
if (!normalized) continue
|
|
323
|
+
records.push({ ...normalized, seq: nonNegInt(rec.seq), time: nonNegInt(rec.time) })
|
|
324
|
+
}
|
|
325
|
+
return { supported: true, records }
|
|
326
|
+
} catch (err) {
|
|
327
|
+
return {
|
|
328
|
+
supported: true,
|
|
329
|
+
records: [],
|
|
330
|
+
error: `failed to read session records: ${err instanceof Error ? err.message : String(err)}`,
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
package/src/skill-prompt.ts
CHANGED
|
@@ -23,7 +23,8 @@ You have the iterate plugin installed, which registers these tools:
|
|
|
23
23
|
- \`iterate_status\` — summarize the current run: mode, round, fixes applied, architectural remaining, decision-log size, checkpoint presence, and whether the run was interrupted (a checkpoint on disk with no newer decision-log activity means the previous run was interrupted and can be resumed)
|
|
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
|
-
- \`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, per-round validation results (\`validations\`: command + exitCode + allowed + rejectReason), 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.
|
|
26
|
+
- \`iterate_transcript\` — runtime observatory file (\`.iterate/transcript.json\`). \`read\` fetches the persisted manifest including any steering \`nudge\` for this run's reviewers — and, when the file is missing or malformed, recovers a summary manifest from the session log's run record (returns \`recovered:true\`); \`capture\` (call once after the final report) persists the per-reviewer threads, convergence trend, findings, fixes, checkpoint, per-round validation results (\`validations\`: command + exitCode + allowed + rejectReason), and timeline so the client observatory panel reflects the run, and links the run to the dsh session (\`sessionRecord\`); \`recall\` lists the run records linked to this session (newest first) so a run stays traceable after \`.iterate/\` is wiped; \`nudge\` sets/clears steering text the next round's reviewers read. Purely local, never touches source files.
|
|
27
|
+
- \`iterate_report\` — export the current run as a portable report. \`format:"markdown"\` (default) renders a PR-pasteable report (run header, quality gate, findings, fixes, validations, defense events, experience bank); \`format:"sarif"\` renders a SARIF 2.1.0 log for CI / GitHub Code Scanning (one rule per review dimension, severity → error/warning/note, file+line locations, suggested fix attached). Set \`write:true\` to persist it under \`.iterate/report.md\` / \`.iterate/report.sarif\`; \`lang\` (zh | en) selects the report language. Read-only apart from the optional report file.
|
|
27
28
|
- \`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
29
|
- \`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
30
|
- \`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).
|
|
@@ -135,7 +136,7 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
135
136
|
(attachments.length > 0 ? '\\n User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
|
|
136
137
|
'\\n Already-known findings (do NOT re-report): ' +
|
|
137
138
|
JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.'
|
|
138
|
-
return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta
|
|
139
|
+
return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta?.findingsSchema }, backend))
|
|
139
140
|
}))
|
|
140
141
|
// A per-dimension reviewer subagent resolves \`null\` on child failure; the
|
|
141
142
|
// aggregate below would report an all-failed round as an empty round, and
|
|
@@ -464,7 +465,7 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
464
465
|
(steering ? '\\n STEERING — read this first: ' + steering : '') +
|
|
465
466
|
(attachments.length > 0 ? '\\n User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
|
|
466
467
|
'\\n Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.'
|
|
467
|
-
return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta
|
|
468
|
+
return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta?.findingsSchema }, backend))
|
|
468
469
|
}))
|
|
469
470
|
// ALL reviewers returning null (child failures) means this round produced
|
|
470
471
|
// no usable output — an empty aggregate must not read like a clean
|
package/src/tools/checkpoint.ts
CHANGED
|
@@ -14,6 +14,7 @@ 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
16
|
import { writeJsonAtomic } from '../atomic-fs.ts'
|
|
17
|
+
import { withProjectLock } from '../file-lock.ts'
|
|
17
18
|
import { checkpointPath, iterateDir, transcriptPath } from '../paths.ts'
|
|
18
19
|
import { readRegistry } from './fix.ts'
|
|
19
20
|
import { readDecisionEntries } from './decision-log.ts'
|
|
@@ -322,22 +323,28 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
|
|
|
322
323
|
}
|
|
323
324
|
|
|
324
325
|
if (args.operation === 'resume') {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
326
|
+
// Read-modify-write across processes: a concurrent resume in a
|
|
327
|
+
// sibling process would read the same resumeCount and write it back,
|
|
328
|
+
// losing one increment. Serialize on a checkpoint lock (the same
|
|
329
|
+
// advisory pattern the config/triage/transcript writers use).
|
|
330
|
+
return withProjectLock(projectRoot, 'checkpoint', () => {
|
|
331
|
+
const current = readCheckpoint(projectRoot)
|
|
332
|
+
if (!current) {
|
|
333
|
+
return { operation: 'resume', ok: false, error: 'no checkpoint to resume — run `save` first' }
|
|
334
|
+
}
|
|
335
|
+
const resumed: IterationCheckpoint = {
|
|
336
|
+
...current,
|
|
337
|
+
resumeCount: (current.resumeCount ?? 0) + 1,
|
|
338
|
+
updatedAt: new Date().toISOString(),
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
mkdirSync(iterateDir(projectRoot), { recursive: true })
|
|
342
|
+
writeJsonAtomic(checkpointPath(projectRoot), resumed)
|
|
343
|
+
} catch (err) {
|
|
344
|
+
return { operation: 'resume', ok: false, error: `failed to persist resumed checkpoint: ${String(err)}` }
|
|
345
|
+
}
|
|
346
|
+
return { operation: 'resume', ok: true, checkpoint: resumed as unknown as JsonValue }
|
|
347
|
+
})
|
|
341
348
|
}
|
|
342
349
|
|
|
343
350
|
if (args.operation === 'clear') {
|
package/src/tools/config.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
validateConfigUpdates,
|
|
10
10
|
writeConfigFile,
|
|
11
11
|
} from '../config-write.ts'
|
|
12
|
+
import { withProjectLock } from '../file-lock.ts'
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Register the `iterate_config` tool.
|
|
@@ -121,40 +122,48 @@ export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
121
122
|
if (updateErrors.length > 0) {
|
|
122
123
|
return { operation: 'write', ok: false, found: false, errors: updateErrors }
|
|
123
124
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
125
|
+
// Serialize the read→merge→validate→write cycle against any other
|
|
126
|
+
// config/triage writer in a sibling process: without the lock, two
|
|
127
|
+
// sessions on the same project each read a stale base and the second
|
|
128
|
+
// merge silently discards the first's update (lost update). The same
|
|
129
|
+
// lock name is shared with `iterate_triage apply` so the two writers
|
|
130
|
+
// are mutually exclusive.
|
|
131
|
+
return withProjectLock(projectRoot, 'config-write', () => {
|
|
132
|
+
let base: Record<string, unknown>
|
|
133
|
+
try {
|
|
134
|
+
// Partial updates must be mergeable on a FRESH project (no config
|
|
135
|
+
// file yet): otherwise `{max_rounds: 5}` alone fails schema
|
|
136
|
+
// validation with a misleading "missing goal". Merge against the
|
|
137
|
+
// built-in defaults when there is no file to read, so the written
|
|
138
|
+
// config captures the effective config (defaults + update) and the
|
|
139
|
+
// documented "write a partial update" contract works out of the box.
|
|
140
|
+
base = existsSync(join(projectRoot, 'iterate.config.yaml'))
|
|
141
|
+
? readRawConfig(join(projectRoot, 'iterate.config.yaml'))
|
|
142
|
+
: (defaultConfig() as unknown as Record<string, unknown>)
|
|
143
|
+
} catch (err) {
|
|
144
|
+
return { operation: 'write', ok: false, found: false, error: `failed to read config: ${String(err)}` }
|
|
145
|
+
}
|
|
146
|
+
const merged = applyConfigUpdates(base, updates ?? {})
|
|
147
|
+
const schemaErrors = validateConfig(merged)
|
|
148
|
+
if (schemaErrors.length > 0) {
|
|
149
|
+
return {
|
|
150
|
+
operation: 'write',
|
|
151
|
+
ok: false,
|
|
152
|
+
found: false,
|
|
153
|
+
errors: schemaErrors.map((e) => `missing/required field: ${e}`),
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const result = writeConfigFile(projectRoot, merged)
|
|
157
|
+
if (!result.ok) return { operation: 'write', ok: false, found: false, error: result.error }
|
|
158
|
+
const { config } = loadEffectiveConfig(projectRoot)
|
|
141
159
|
return {
|
|
142
160
|
operation: 'write',
|
|
143
|
-
ok:
|
|
144
|
-
found:
|
|
145
|
-
|
|
161
|
+
ok: true,
|
|
162
|
+
found: true,
|
|
163
|
+
backupPath: result.backupPath ?? null,
|
|
164
|
+
config: config as unknown as JsonValue,
|
|
146
165
|
}
|
|
147
|
-
}
|
|
148
|
-
const result = writeConfigFile(projectRoot, merged)
|
|
149
|
-
if (!result.ok) return { operation: 'write', ok: false, found: false, error: result.error }
|
|
150
|
-
const { config } = loadEffectiveConfig(projectRoot)
|
|
151
|
-
return {
|
|
152
|
-
operation: 'write',
|
|
153
|
-
ok: true,
|
|
154
|
-
found: true,
|
|
155
|
-
backupPath: result.backupPath ?? null,
|
|
156
|
-
config: config as unknown as JsonValue,
|
|
157
|
-
}
|
|
166
|
+
})
|
|
158
167
|
}
|
|
159
168
|
|
|
160
169
|
// ── Read operations (original behavior) ────────────────────────────
|
|
@@ -3,17 +3,13 @@ import {
|
|
|
3
3
|
readFileSync,
|
|
4
4
|
mkdirSync,
|
|
5
5
|
existsSync,
|
|
6
|
-
openSync,
|
|
7
|
-
writeSync,
|
|
8
|
-
closeSync,
|
|
9
|
-
unlinkSync,
|
|
10
6
|
statSync,
|
|
11
7
|
} from 'node:fs'
|
|
12
8
|
import { join } from 'node:path'
|
|
13
9
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
14
10
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
15
11
|
import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
16
|
-
import {
|
|
12
|
+
import { acquireProjectLockChecked } from '../file-lock.ts'
|
|
17
13
|
import type { DecisionLogEntry } from '../types.ts'
|
|
18
14
|
|
|
19
15
|
const LOG_DIR = '.iterate'
|
|
@@ -34,35 +30,16 @@ const LOG_FILE = 'decision-log.jsonl'
|
|
|
34
30
|
// opposite — it renames the log and therefore refuses to run when the lock
|
|
35
31
|
// cannot be taken (see `rewriteDecisionLogKeepingRecent` in prune.ts).
|
|
36
32
|
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
const LOCK_STALE_MS = 10000
|
|
40
|
-
const LOCK_POLL_MS = 25
|
|
41
|
-
|
|
42
|
-
/** True when a pid refers to a live process (or we lack permission to tell). */
|
|
43
|
-
function processAlive(pid: number): boolean {
|
|
44
|
-
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
45
|
-
try {
|
|
46
|
-
process.kill(pid, 0)
|
|
47
|
-
return true
|
|
48
|
-
} catch (err) {
|
|
49
|
-
return (err as NodeJS.ErrnoException).code === 'EPERM'
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Synchronous busy-wait sleep (Atomics.wait is a reliable sleep in Node). */
|
|
54
|
-
function sleepSync(ms: number): void {
|
|
55
|
-
const sab = new Int32Array(new SharedArrayBuffer(4))
|
|
56
|
-
Atomics.wait(sab, 0, 0, ms)
|
|
57
|
-
}
|
|
33
|
+
/** Lock name shared with the prune rewrite (→ `.iterate/.decision-log.lock`). */
|
|
34
|
+
const LOG_LOCK_NAME = 'decision-log'
|
|
58
35
|
|
|
59
36
|
/**
|
|
60
37
|
* Acquire the decision-log lock for `projectRoot`.
|
|
61
38
|
* Returns a release function (always callable; a no-op when the lock could
|
|
62
39
|
* not be taken — never wedge an append behind a vanished holder).
|
|
63
40
|
*
|
|
64
|
-
* `options.waitMs` / `options.staleMs` override the defaults (tests use
|
|
65
|
-
* waits so a live-holder timeout is exercised in milliseconds, not 5s).
|
|
41
|
+
* `options.waitMs` / `options.staleMs` override the shared defaults (tests use
|
|
42
|
+
* short waits so a live-holder timeout is exercised in milliseconds, not 5s).
|
|
66
43
|
*/
|
|
67
44
|
export function acquireLogLock(
|
|
68
45
|
projectRoot: string,
|
|
@@ -77,63 +54,18 @@ export function acquireLogLock(
|
|
|
77
54
|
* audit line must not be lost to a wedged append), but the rewrite renames the
|
|
78
55
|
* log under any unlocked concurrent appender — it must refuse to run without
|
|
79
56
|
* the mutex rather than silently dropping an audit line.
|
|
57
|
+
*
|
|
58
|
+
* Delegates to the shared `acquireProjectLockChecked` with lock name
|
|
59
|
+
* `decision-log`: the decision-log mutex IS that generic project lock. This
|
|
60
|
+
* module used to hand-roll an identical copy of the acquire/steal/wait logic,
|
|
61
|
+
* and the two stale/wait parameter sets could drift apart — one implementation,
|
|
62
|
+
* one set of defaults, one ownership-checked release.
|
|
80
63
|
*/
|
|
81
64
|
export function acquireLogLockChecked(
|
|
82
65
|
projectRoot: string,
|
|
83
66
|
options: { waitMs?: number; staleMs?: number } = {},
|
|
84
67
|
): { acquired: boolean; release: () => void } {
|
|
85
|
-
|
|
86
|
-
? (options.waitMs as number)
|
|
87
|
-
: LOCK_WAIT_MS
|
|
88
|
-
const staleMs = Number.isFinite(options.staleMs) && (options.staleMs ?? 0) >= 0
|
|
89
|
-
? (options.staleMs as number)
|
|
90
|
-
: LOCK_STALE_MS
|
|
91
|
-
const dir = join(projectRoot, LOG_DIR)
|
|
92
|
-
if (!existsSync(dir)) {
|
|
93
|
-
try {
|
|
94
|
-
mkdirSync(dir, { recursive: true })
|
|
95
|
-
} catch {
|
|
96
|
-
return { acquired: false, release: () => {} }
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
const lockPath = join(dir, LOCK_FILE)
|
|
100
|
-
const start = Date.now()
|
|
101
|
-
for (;;) {
|
|
102
|
-
let fd: number | null = null
|
|
103
|
-
try {
|
|
104
|
-
fd = openSync(lockPath, 'wx')
|
|
105
|
-
} catch (err) {
|
|
106
|
-
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return { acquired: false, release: () => {} }
|
|
107
|
-
// Lock exists — steal it when the owner is gone or the lock is ancient.
|
|
108
|
-
let stale = false
|
|
109
|
-
try {
|
|
110
|
-
const st = statSync(lockPath)
|
|
111
|
-
if (Date.now() - st.mtimeMs > staleMs) stale = true
|
|
112
|
-
else {
|
|
113
|
-
const pid = Number(readFileSync(lockPath, 'utf-8'))
|
|
114
|
-
if (!processAlive(pid)) stale = true
|
|
115
|
-
}
|
|
116
|
-
} catch {
|
|
117
|
-
stale = true // unreadable/vanishing lock → retry as stale
|
|
118
|
-
}
|
|
119
|
-
if (stale) {
|
|
120
|
-
try { unlinkSync(lockPath) } catch { /* another holder stole it */ }
|
|
121
|
-
continue
|
|
122
|
-
}
|
|
123
|
-
if (Date.now() - start > waitMs) return { acquired: false, release: () => {} }
|
|
124
|
-
sleepSync(LOCK_POLL_MS)
|
|
125
|
-
continue
|
|
126
|
-
}
|
|
127
|
-
// Owned the lock: stamp the holder pid, then release once on return.
|
|
128
|
-
// The release is ownership-checked (see `makeLockRelease`): a lock stolen
|
|
129
|
-
// from this process after its owner went stale must not be unlinked by us.
|
|
130
|
-
try { writeSync(fd, String(process.pid)) } catch { /* pid stamp is advisory */ }
|
|
131
|
-
try { closeSync(fd) } catch { /* best-effort */ }
|
|
132
|
-
return {
|
|
133
|
-
acquired: true,
|
|
134
|
-
release: makeLockRelease(lockPath),
|
|
135
|
-
}
|
|
136
|
-
}
|
|
68
|
+
return acquireProjectLockChecked(projectRoot, LOG_LOCK_NAME, options)
|
|
137
69
|
}
|
|
138
70
|
|
|
139
71
|
// ─── Entry-count cache (avoid an O(n) full re-read per append) ─────────────
|