iterate-plugin 3.6.0 → 3.8.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 +70 -7
- package/README.zh-CN.md +50 -6
- package/dist/config-loader.js +7 -1
- package/dist/file-lock.js +110 -21
- package/dist/git-scope.js +22 -2
- package/dist/index.js +7 -0
- package/dist/live.js +2 -1
- package/dist/session-hooks.js +6 -1
- package/dist/skill-prompt.js +2 -2
- package/dist/tools/checkpoint.js +24 -17
- package/dist/tools/config.js +42 -33
- package/dist/tools/decision-log.js +13 -99
- package/dist/tools/transcript.js +74 -44
- package/dist/tools/triage.js +52 -45
- package/dist/transcript.js +2 -2
- package/dist/working-directory.js +114 -0
- package/lib/client.js +1128 -468
- package/lib/parse.js +280 -62
- package/package.json +34 -11
- package/src/client/i18n.ts +517 -0
- package/src/client/index.ts +553 -344
- package/src/client/session-cache.ts +54 -0
- package/src/config-loader.ts +7 -1
- package/src/file-lock.ts +115 -18
- package/src/git-scope.ts +21 -2
- package/src/index.ts +7 -0
- package/src/live.ts +2 -1
- package/src/session-hooks.ts +6 -1
- package/src/skill-prompt.ts +2 -2
- package/src/tools/checkpoint.ts +23 -16
- package/src/tools/config.ts +40 -31
- package/src/tools/decision-log.ts +12 -82
- package/src/tools/transcript.ts +72 -43
- package/src/tools/triage.ts +47 -40
- package/src/transcript.ts +2 -2
- package/src/types.ts +1 -1
- package/src/working-directory.ts +126 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/client/session-cache.ts — the newest session the session-scoped slots saw.
|
|
3
|
+
*
|
|
4
|
+
* Slot scopes in dsh are not uniform: `conversation.input.dock` (dashboard,
|
|
5
|
+
* observatory) renders inside a session and receives `props.session`, while
|
|
6
|
+
* `settings.section` renders at ROOT scope on the settings page and gets no
|
|
7
|
+
* session at all. The settings card nevertheless needs session-derived state —
|
|
8
|
+
* specifically the config the model echoed back through `iterate_config`, which
|
|
9
|
+
* is what closes the config write → read-back loop.
|
|
10
|
+
*
|
|
11
|
+
* Rather than fake a session for the root slot, the session-scoped slots
|
|
12
|
+
* publish what the host handed them and root-scoped panels subscribe:
|
|
13
|
+
*
|
|
14
|
+
* session slot renders → noteSession(props.session)
|
|
15
|
+
* settings panel → useSessionSnapshot() (via subscribeSession)
|
|
16
|
+
*
|
|
17
|
+
* Design constraints (mirrors `i18n.ts`):
|
|
18
|
+
* - **Pure**: no React, no DOM — the store is unit-testable on its own, and
|
|
19
|
+
* the React binding lives next to `useLang()` in index.ts.
|
|
20
|
+
* - **Identity-compared**: the same session object never notifies twice, so
|
|
21
|
+
* a slot that re-renders every frame cannot spin its subscribers.
|
|
22
|
+
* - **Never fabricated**: it only ever holds what the host really passed; an
|
|
23
|
+
* empty cache reads as "nothing echoed yet", which is the honest state.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Anything host-shaped; the plugins only ever pass it through opaque. */
|
|
27
|
+
export type CachedSession = unknown
|
|
28
|
+
|
|
29
|
+
let current: CachedSession = null
|
|
30
|
+
|
|
31
|
+
const listeners = new Set<() => void>()
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Publish the session a session-scoped slot was rendered with.
|
|
35
|
+
* Notifies subscribers only when the identity actually changes.
|
|
36
|
+
*/
|
|
37
|
+
export function noteSession(session: CachedSession): void {
|
|
38
|
+
if (Object.is(session, current)) return
|
|
39
|
+
current = session
|
|
40
|
+
for (const listener of [...listeners]) listener()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Last published session (or `null` before any session slot has rendered). */
|
|
44
|
+
export function readSession(): CachedSession {
|
|
45
|
+
return current
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Subscribe to session changes; returns the unsubscribe function. */
|
|
49
|
+
export function subscribeSession(listener: () => void): () => void {
|
|
50
|
+
listeners.add(listener)
|
|
51
|
+
return () => {
|
|
52
|
+
listeners.delete(listener)
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/config-loader.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { homedir } from 'node:os'
|
|
|
3
3
|
import { basename, dirname, join, resolve, sep } from 'node:path'
|
|
4
4
|
import yaml from 'js-yaml'
|
|
5
5
|
import type { IterateConfig } from './types.ts'
|
|
6
|
+
import { sessionCwdOf } from './working-directory.ts'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Load and parse iterate.config.yaml from the project root.
|
|
@@ -748,12 +749,17 @@ export function resolveProjectRoot(input?: string, sessionCwd?: string): Project
|
|
|
748
749
|
/**
|
|
749
750
|
* Thin adapter for tool `execute(args, exec)` bodies: pull the session cwd
|
|
750
751
|
* from the DSH run context and hand it to {@link resolveProjectRoot}.
|
|
752
|
+
*
|
|
753
|
+
* The cwd source is the EFFECTIVE session directory (dsh 0.2.1-alpha.2's
|
|
754
|
+
* `workingDirectory` service when the host provides one, else the immutable
|
|
755
|
+
* `header.cwd` — see `./working-directory.ts`), so a session whose directory
|
|
756
|
+
* was switched mid-run still resolves the project it is actually editing.
|
|
751
757
|
*/
|
|
752
758
|
export function resolveProjectRootForExec(
|
|
753
759
|
exec: { agent?: { session?: { header?: { cwd?: string } } } } | undefined,
|
|
754
760
|
input?: string,
|
|
755
761
|
): ProjectRootResult {
|
|
756
|
-
return resolveProjectRoot(input, exec
|
|
762
|
+
return resolveProjectRoot(input, sessionCwdOf(exec))
|
|
757
763
|
}
|
|
758
764
|
|
|
759
765
|
/**
|
package/src/file-lock.ts
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
import {
|
|
31
31
|
closeSync,
|
|
32
32
|
existsSync,
|
|
33
|
+
fstatSync,
|
|
33
34
|
mkdirSync,
|
|
34
35
|
openSync,
|
|
35
36
|
readFileSync,
|
|
@@ -40,7 +41,14 @@ import {
|
|
|
40
41
|
import { join } from 'node:path'
|
|
41
42
|
|
|
42
43
|
const LOCK_DIR = '.iterate'
|
|
43
|
-
|
|
44
|
+
// Bounded wait before degrading to "proceed unlocked". `acquireProjectLock`
|
|
45
|
+
// spins on `Atomics.wait`, which BLOCKS the single plugin thread for the whole
|
|
46
|
+
// budget — so a long default wedges unrelated tool calls and the UI heartbeat
|
|
47
|
+
// whenever a sibling process holds the lock. Healthy holders release in
|
|
48
|
+
// microseconds, so 1000ms is very generous; anything slower is treated as
|
|
49
|
+
// contention and degrades to the historical unlocked behavior rather than
|
|
50
|
+
// stalling the event loop for five seconds.
|
|
51
|
+
const DEFAULT_WAIT_MS = 1000
|
|
44
52
|
const DEFAULT_STALE_MS = 10000
|
|
45
53
|
const POLL_MS = 25
|
|
46
54
|
|
|
@@ -80,19 +88,28 @@ function lockFileName(name: string): string {
|
|
|
80
88
|
return `.${name}.lock`
|
|
81
89
|
}
|
|
82
90
|
|
|
91
|
+
/** Outcome of a checked acquisition: whether the lock was actually TAKEN. */
|
|
92
|
+
export interface AcquireLockResult {
|
|
93
|
+
acquired: boolean
|
|
94
|
+
release: () => void
|
|
95
|
+
}
|
|
96
|
+
|
|
83
97
|
/**
|
|
84
|
-
* Acquire an advisory lock named `name` inside `<projectRoot>/.iterate
|
|
98
|
+
* Acquire an advisory lock named `name` inside `<projectRoot>/.iterate/`,
|
|
99
|
+
* reporting whether it was actually taken.
|
|
85
100
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* created or the wait times out — callers then proceed
|
|
89
|
-
* historical best-effort behavior, rather than failing
|
|
101
|
+
* The returned release function is always callable, idempotent, and never
|
|
102
|
+
* throws. `acquired:false` (with a no-op release) is returned when the lock
|
|
103
|
+
* directory cannot be created or the wait times out — callers then proceed
|
|
104
|
+
* unlocked, which is the historical best-effort behavior, rather than failing
|
|
105
|
+
* the operation. Callers that MUST hold the mutex (e.g. a log rewrite that
|
|
106
|
+
* renames the file) branch on `acquired` and refuse to run unlocked.
|
|
90
107
|
*/
|
|
91
|
-
export function
|
|
108
|
+
export function acquireProjectLockChecked(
|
|
92
109
|
projectRoot: string,
|
|
93
110
|
name: string,
|
|
94
111
|
options: LockOptions = {},
|
|
95
|
-
):
|
|
112
|
+
): AcquireLockResult {
|
|
96
113
|
const waitMs = Number.isFinite(options.waitMs) && (options.waitMs ?? 0) >= 0
|
|
97
114
|
? (options.waitMs as number)
|
|
98
115
|
: DEFAULT_WAIT_MS
|
|
@@ -105,7 +122,7 @@ export function acquireProjectLock(
|
|
|
105
122
|
try {
|
|
106
123
|
mkdirSync(dir, { recursive: true })
|
|
107
124
|
} catch {
|
|
108
|
-
return () => {}
|
|
125
|
+
return { acquired: false, release: () => {} }
|
|
109
126
|
}
|
|
110
127
|
}
|
|
111
128
|
const lockPath = join(dir, lockFileName(name))
|
|
@@ -115,7 +132,7 @@ export function acquireProjectLock(
|
|
|
115
132
|
try {
|
|
116
133
|
fd = openSync(lockPath, 'wx')
|
|
117
134
|
} catch (err) {
|
|
118
|
-
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return () => {}
|
|
135
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return { acquired: false, release: () => {} }
|
|
119
136
|
// Lock exists — steal it when the owner is gone or the lock is ancient.
|
|
120
137
|
let stale = false
|
|
121
138
|
try {
|
|
@@ -136,11 +153,20 @@ export function acquireProjectLock(
|
|
|
136
153
|
}
|
|
137
154
|
continue
|
|
138
155
|
}
|
|
139
|
-
if (Date.now() - start > waitMs) return () => {}
|
|
156
|
+
if (Date.now() - start > waitMs) return { acquired: false, release: () => {} }
|
|
140
157
|
sleepSync(POLL_MS)
|
|
141
158
|
continue
|
|
142
159
|
}
|
|
143
|
-
// Owned the lock:
|
|
160
|
+
// Owned the lock: capture the created inode (for the ownership-checked
|
|
161
|
+
// release), stamp the holder pid, then release once on return.
|
|
162
|
+
let ino = 0
|
|
163
|
+
try {
|
|
164
|
+
ino = fstatSync(fd).ino
|
|
165
|
+
} catch {
|
|
166
|
+
// inode unavailable on this filesystem — the release falls back to the
|
|
167
|
+
// pid-stamp check only.
|
|
168
|
+
ino = 0
|
|
169
|
+
}
|
|
144
170
|
try {
|
|
145
171
|
writeSync(fd, String(process.pid))
|
|
146
172
|
} catch {
|
|
@@ -151,16 +177,87 @@ export function acquireProjectLock(
|
|
|
151
177
|
} catch {
|
|
152
178
|
// best-effort
|
|
153
179
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
180
|
+
return { acquired: true, release: makeLockRelease(lockPath, ino) }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Acquire an advisory lock named `name` inside `<projectRoot>/.iterate/`.
|
|
186
|
+
*
|
|
187
|
+
* Returns a release function: always callable, idempotent, never throws.
|
|
188
|
+
* A no-op (but callable) release is returned when the lock directory cannot be
|
|
189
|
+
* created or the wait times out — callers then proceed unlocked, which is the
|
|
190
|
+
* historical best-effort behavior, rather than failing the operation.
|
|
191
|
+
*/
|
|
192
|
+
export function acquireProjectLock(
|
|
193
|
+
projectRoot: string,
|
|
194
|
+
name: string,
|
|
195
|
+
options: LockOptions = {},
|
|
196
|
+
): () => void {
|
|
197
|
+
return acquireProjectLockChecked(projectRoot, name, options).release
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Build the release function for a lock file this process just acquired.
|
|
202
|
+
*
|
|
203
|
+
* Advisory locks here are STOLEN when the recorded owner dies or the mtime
|
|
204
|
+
* goes ancient: the steal unlinks our file and creates a new one stamped with
|
|
205
|
+
* the STEALER's pid. An unguarded release would then unlink the new holder's
|
|
206
|
+
* lock — turning our release into a double-release that admits a third process
|
|
207
|
+
* into a "held" critical section. So the release re-reads the stamp and only
|
|
208
|
+
* unlinks a file that is still ours (same pid) or unstamped (the shape left
|
|
209
|
+
* when our own advisory pid write failed, or when the content is not a pid).
|
|
210
|
+
*
|
|
211
|
+
* Always callable, idempotent, and never throws.
|
|
212
|
+
*/
|
|
213
|
+
/**
|
|
214
|
+
* Build the release function for a lock file this process just acquired.
|
|
215
|
+
*
|
|
216
|
+
* Advisory locks here are STOLEN when the recorded owner dies or the mtime
|
|
217
|
+
* goes ancient: the steal unlinks our file and creates a new one stamped with
|
|
218
|
+
* the STEALER's pid. An unguarded release would then unlink the new holder's
|
|
219
|
+
* lock — turning our release into a double-release that admits a third process
|
|
220
|
+
* into a "held" critical section. So the release re-reads the stamp and only
|
|
221
|
+
* unlinks a file that is still ours: same pid (or unstamped) AND, when we know
|
|
222
|
+
* the inode we created, the SAME inode still at the path. The inode check closes
|
|
223
|
+
* the read-then-unlink window where the stamp check alone could race a steal
|
|
224
|
+
* (our pid is alive, but the file was already replaced between the two calls).
|
|
225
|
+
*
|
|
226
|
+
* Always callable, idempotent, and never throws.
|
|
227
|
+
*
|
|
228
|
+
* @param lockPath - the lock file path.
|
|
229
|
+
* @param ino - the inode captured at acquisition (0 = unknown; skip the check).
|
|
230
|
+
*/
|
|
231
|
+
export function makeLockRelease(lockPath: string, ino?: number): () => void {
|
|
232
|
+
let released = false
|
|
233
|
+
return () => {
|
|
234
|
+
if (released) return
|
|
235
|
+
released = true
|
|
236
|
+
let raw = ''
|
|
237
|
+
try {
|
|
238
|
+
raw = readFileSync(lockPath, 'utf-8').trim()
|
|
239
|
+
} catch {
|
|
240
|
+
// Already gone (or unreadable) — nothing of ours left to remove.
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
// A stamp naming another pid means the lock was stolen and reassigned:
|
|
244
|
+
// it is no longer ours to delete.
|
|
245
|
+
if (raw !== '' && Number(raw) !== process.pid) return
|
|
246
|
+
// The path must still refer to the file WE created. A stealer unlinked our
|
|
247
|
+
// file and created a fresh one; that replacement carries a different inode,
|
|
248
|
+
// so unlinking by path would delete the NEW holder's lock.
|
|
249
|
+
if (typeof ino === 'number' && ino !== 0) {
|
|
158
250
|
try {
|
|
159
|
-
|
|
251
|
+
if (statSync(lockPath).ino !== ino) return
|
|
160
252
|
} catch {
|
|
161
|
-
//
|
|
253
|
+
return // vanished mid-release — nothing of ours left
|
|
162
254
|
}
|
|
163
255
|
}
|
|
256
|
+
try {
|
|
257
|
+
unlinkSync(lockPath)
|
|
258
|
+
} catch {
|
|
259
|
+
// already gone
|
|
260
|
+
}
|
|
164
261
|
}
|
|
165
262
|
}
|
|
166
263
|
|
package/src/git-scope.ts
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { execFile } from 'node:child_process'
|
|
25
|
-
import {
|
|
25
|
+
import { lstatSync, realpathSync } from 'node:fs'
|
|
26
26
|
import { join, sep } from 'node:path'
|
|
27
27
|
|
|
28
28
|
/** A resolved changed-only scope result. */
|
|
@@ -113,6 +113,15 @@ export function parseChangedFiles(stdout: string): string[] {
|
|
|
113
113
|
* Keep only entries that resolve to an existing regular file under `root`.
|
|
114
114
|
* Traversal-safe: rejects absolute paths and any relative path that would
|
|
115
115
|
* escape `root` via `..` (resolved against the root before stat).
|
|
116
|
+
*
|
|
117
|
+
* Symlink handling: `statSync` (and `existsSync`) FOLLOW symlinks, so a git
|
|
118
|
+
* entry that is a link to a file OUTSIDE the project would pass a path-only
|
|
119
|
+
* containment check and enter the changed-only scope. Such a file is then
|
|
120
|
+
* rejected by the evidence gate (realpath out of bounds → `file_not_found`),
|
|
121
|
+
* so every finding on it would fail closed as `EVIDENCE_VIOLATION` forever —
|
|
122
|
+
* a permanent false positive for repos with dotfile or shared-config links.
|
|
123
|
+
* `lstatSync` distinguishes the link itself; a link whose REAL target escapes
|
|
124
|
+
* `root` is dropped here instead.
|
|
116
125
|
*/
|
|
117
126
|
export function filterExistingFiles(root: string, files: string[]): string[] {
|
|
118
127
|
const out: string[] = []
|
|
@@ -122,7 +131,17 @@ export function filterExistingFiles(root: string, files: string[]): string[] {
|
|
|
122
131
|
const candidate = join(root, rel)
|
|
123
132
|
if (!candidate.startsWith(rootPrefix) && candidate !== root) continue
|
|
124
133
|
try {
|
|
125
|
-
|
|
134
|
+
const st = lstatSync(candidate)
|
|
135
|
+
if (st.isFile()) {
|
|
136
|
+
out.push(rel)
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
if (!st.isSymbolicLink()) continue
|
|
140
|
+
// A link whose real target stays inside the project is a legitimate,
|
|
141
|
+
// reviewable file; one that escapes is excluded from scope (fail-closed
|
|
142
|
+
// at the source instead of as a permanent evidence violation).
|
|
143
|
+
const real = realpathSync(candidate)
|
|
144
|
+
if (real === root || real.startsWith(rootPrefix)) out.push(rel)
|
|
126
145
|
} catch {
|
|
127
146
|
// Unreadable entry (e.g. a broken symlink) is not a valid review target.
|
|
128
147
|
continue
|
package/src/index.ts
CHANGED
|
@@ -45,6 +45,7 @@ import { registerQualityGateTool } from './tools/quality-gate.ts'
|
|
|
45
45
|
import { registerDefenseEventsTool } from './tools/defense-events.ts'
|
|
46
46
|
import { registerSessionHooks } from './session-hooks.ts'
|
|
47
47
|
import { registerLiveCapture } from './live.ts'
|
|
48
|
+
import { installWorkingDirectoryContext } from './working-directory.ts'
|
|
48
49
|
import { ITERATE_SKILL_PROMPT } from './skill-prompt.ts'
|
|
49
50
|
|
|
50
51
|
export const name = 'iterate-plugin'
|
|
@@ -58,6 +59,12 @@ export function apply(ctx: Context): void {
|
|
|
58
59
|
// running with NO `tools/pre-execute` approval gate — a fail-open hole
|
|
59
60
|
// created by load order. Registering the gate first means a partial load
|
|
60
61
|
// can only ever err on the side of MORE gating, never less.
|
|
62
|
+
//
|
|
63
|
+
// The working-directory context is installed first for the same reason:
|
|
64
|
+
// every cwd read below (gate policy, live capture, tool project roots)
|
|
65
|
+
// must see dsh 0.2.1-alpha.2+ directory switches, and installing it last
|
|
66
|
+
// would leave a partially-loaded plugin resolving the wrong workspace.
|
|
67
|
+
installWorkingDirectoryContext(ctx)
|
|
61
68
|
registerSessionHooks(ctx)
|
|
62
69
|
registerLiveCapture(ctx)
|
|
63
70
|
|
package/src/live.ts
CHANGED
|
@@ -34,6 +34,7 @@ import { join } from 'node:path'
|
|
|
34
34
|
import type { Context } from '@deepseek-ai/cordis'
|
|
35
35
|
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
|
36
36
|
import { loadEffectiveConfig, resolveProjectRoot } from './config-loader.ts'
|
|
37
|
+
import { sessionCwdOf } from './working-directory.ts'
|
|
37
38
|
|
|
38
39
|
/** Keep at most this many live activity entries. */
|
|
39
40
|
export const LIVE_MAX_ENTRIES = 300
|
|
@@ -140,7 +141,7 @@ export function appendLive(projectRoot: string, entry: LiveActivityEntry): Promi
|
|
|
140
141
|
|
|
141
142
|
/** Resolve the project root a tool execution belongs to, if any. */
|
|
142
143
|
function projectRootOf(exec: ToolExecution): string | null {
|
|
143
|
-
const cwd = exec
|
|
144
|
+
const cwd = sessionCwdOf(exec)
|
|
144
145
|
if (!cwd) return null
|
|
145
146
|
const resolved = resolveProjectRoot(undefined, cwd)
|
|
146
147
|
return resolved.ok ? resolved.root : null
|
package/src/session-hooks.ts
CHANGED
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
|
|
56
56
|
import { loadEffectiveConfig, resolveProjectRoot } from './config-loader.ts'
|
|
57
57
|
import { decideApproval, isDestructiveIterateTool } from './approval-gate.ts'
|
|
58
|
+
import { sessionCwdOf } from './working-directory.ts'
|
|
58
59
|
import type { Context } from '@deepseek-ai/cordis'
|
|
59
60
|
import type { ToolErrorInfo, ToolExecution, PreToolDecision } from '@deepseek-ai/dsh-tools'
|
|
60
61
|
|
|
@@ -114,7 +115,11 @@ export function gateDecision(exec: ToolExecution): PreToolDecision {
|
|
|
114
115
|
const p = (args as Record<string, unknown>).path
|
|
115
116
|
if (typeof p === 'string') argPath = p
|
|
116
117
|
}
|
|
117
|
-
|
|
118
|
+
// Effective session directory (see ./working-directory.ts): a session whose
|
|
119
|
+
// directory was switched via dsh 0.2.1-alpha.2's `working_directory` tool
|
|
120
|
+
// must have its POLICY read from the workspace it is now in — `header.cwd`
|
|
121
|
+
// alone would read the original project's approval policy.
|
|
122
|
+
sessionCwd = sessionCwdOf(exec)
|
|
118
123
|
} catch {
|
|
119
124
|
// hostile/proxied exec — fall through with both undefined (defaults to ask)
|
|
120
125
|
}
|
package/src/skill-prompt.ts
CHANGED
|
@@ -135,7 +135,7 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
135
135
|
(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
136
|
'\\n Already-known findings (do NOT re-report): ' +
|
|
137
137
|
JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.'
|
|
138
|
-
return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta
|
|
138
|
+
return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta?.findingsSchema }, backend))
|
|
139
139
|
}))
|
|
140
140
|
// A per-dimension reviewer subagent resolves \`null\` on child failure; the
|
|
141
141
|
// aggregate below would report an all-failed round as an empty round, and
|
|
@@ -464,7 +464,7 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
464
464
|
(steering ? '\\n STEERING — read this first: ' + steering : '') +
|
|
465
465
|
(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
466
|
'\\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
|
|
467
|
+
return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta?.findingsSchema }, backend))
|
|
468
468
|
}))
|
|
469
469
|
// ALL reviewers returning null (child failures) means this round produced
|
|
470
470
|
// 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) ────────────────────────────
|