iterate-plugin 3.5.4 → 3.5.5
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/dist/atomic-fs.js +22 -4
- package/dist/config-loader.js +8 -3
- package/dist/config-write.js +50 -9
- package/dist/evidence.js +28 -1
- package/dist/git-scope.js +14 -5
- package/dist/jobs.js +15 -1
- package/dist/live.js +39 -22
- package/dist/meta-review.js +2 -2
- package/dist/review-scope.js +6 -2
- package/dist/review.js +7 -6
- package/dist/skill-prompt.js +3 -3
- package/dist/tools/decision-log.js +142 -17
- package/dist/tools/fix.js +24 -3
- package/dist/tools/prune.js +35 -21
- package/dist/tools/triage.js +3 -2
- package/dist/transcript.js +58 -32
- package/lib/client.js +4 -0
- package/lib/parse.js +6 -0
- package/package.json +8 -7
- package/src/atomic-fs.ts +20 -4
- package/src/config-loader.ts +8 -3
- package/src/config-write.ts +59 -9
- package/src/evidence.ts +29 -1
- package/src/git-scope.ts +14 -3
- package/src/jobs.ts +15 -1
- package/src/live.ts +41 -22
- package/src/meta-review.ts +2 -2
- package/src/review-scope.ts +6 -2
- package/src/review.ts +7 -6
- package/src/skill-prompt.ts +3 -3
- package/src/tools/decision-log.ts +136 -16
- package/src/tools/fix.ts +25 -3
- package/src/tools/prune.ts +30 -18
- package/src/tools/triage.ts +3 -2
- package/src/transcript.ts +59 -29
package/src/git-scope.ts
CHANGED
|
@@ -90,7 +90,10 @@ function decodeQuotedPath(content: string): string {
|
|
|
90
90
|
|
|
91
91
|
export function parseChangedFiles(stdout: string): string[] {
|
|
92
92
|
if (stdout.includes('\0')) {
|
|
93
|
-
|
|
93
|
+
// NUL mode: git emits exact names with no surrounding whitespace, so any
|
|
94
|
+
// trim would CORRUPT a filename that legitimately starts/ends with spaces
|
|
95
|
+
// (git supports such names). Split and drop only the trailing empty entry.
|
|
96
|
+
return stdout.split('\0').filter((s) => s.length > 0)
|
|
94
97
|
}
|
|
95
98
|
return stdout
|
|
96
99
|
.split('\n')
|
|
@@ -152,12 +155,19 @@ export function decideScope(changedFiles: string[]): {
|
|
|
152
155
|
export function runGit(
|
|
153
156
|
args: string[],
|
|
154
157
|
cwd: string,
|
|
158
|
+
opts: { signal?: AbortSignal } = {},
|
|
155
159
|
): Promise<{ ok: boolean; stdout: string; stderr: string; exitCode: number }> {
|
|
156
160
|
return new Promise((resolve) => {
|
|
157
161
|
execFile(
|
|
158
162
|
'git',
|
|
159
163
|
args,
|
|
160
|
-
{
|
|
164
|
+
{
|
|
165
|
+
cwd,
|
|
166
|
+
timeout: 30_000,
|
|
167
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
168
|
+
env: { ...process.env, PAGER: 'cat' },
|
|
169
|
+
signal: opts.signal,
|
|
170
|
+
},
|
|
161
171
|
(error, stdout, stderr) => {
|
|
162
172
|
const exitCode = error ? (typeof error.code === 'number' ? error.code : 1) : 0
|
|
163
173
|
resolve({ ok: exitCode === 0, stdout: stdout ?? '', stderr: stderr ?? '', exitCode })
|
|
@@ -175,6 +185,7 @@ export function runGit(
|
|
|
175
185
|
export async function resolveChangedFiles(
|
|
176
186
|
root: string,
|
|
177
187
|
targetBranch: string,
|
|
188
|
+
opts: { signal?: AbortSignal } = {},
|
|
178
189
|
): Promise<GitScopeResult> {
|
|
179
190
|
// Option-injection guard: a branch name starting with '-' would be parsed by
|
|
180
191
|
// git as an option (e.g. --output=...), not a ref. Reject it outright.
|
|
@@ -188,7 +199,7 @@ export async function resolveChangedFiles(
|
|
|
188
199
|
}
|
|
189
200
|
// -z: NUL-delimited names — machine-safe for any filename (spaces, quotes,
|
|
190
201
|
// non-ASCII), and never confused with option-like content.
|
|
191
|
-
const { ok, stdout, stderr } = await runGit(['diff', '--name-only', '-z', targetBranch, '--'], root)
|
|
202
|
+
const { ok, stdout, stderr } = await runGit(['diff', '--name-only', '-z', targetBranch, '--'], root, opts)
|
|
192
203
|
if (!ok) {
|
|
193
204
|
const reason = stderr.trim() || `git diff --name-only -z ${targetBranch} failed`
|
|
194
205
|
return { scope: 'full', changedFiles: [], fallbackToFull: true, error: reason }
|
package/src/jobs.ts
CHANGED
|
@@ -39,6 +39,20 @@ interface JobsLike {
|
|
|
39
39
|
}): string
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Read `ctx.jobs` defensively: the dsh plugin context can be a Proxy whose
|
|
44
|
+
* `jobs` getter throws (or is missing) when no job registry is present. A
|
|
45
|
+
* Proxy throw on property access must degrade to "no jobs", never crash the
|
|
46
|
+
* tool execution we are about to wrap.
|
|
47
|
+
*/
|
|
48
|
+
function safeJobs(ctx: unknown): JobsLike | undefined {
|
|
49
|
+
try {
|
|
50
|
+
return (ctx as { jobs?: JobsLike } | undefined)?.jobs
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
42
56
|
/**
|
|
43
57
|
* Run `fn` wrapped in a dsh background job, settling it completed/failed
|
|
44
58
|
* with the execution's outcome. When the host exposes no job registry (or
|
|
@@ -57,7 +71,7 @@ export async function runWithJob<T>(
|
|
|
57
71
|
label: string,
|
|
58
72
|
fn: () => Promise<T> | T,
|
|
59
73
|
): Promise<{ result: T; jobId: string | null }> {
|
|
60
|
-
const jobs = (ctx
|
|
74
|
+
const jobs = safeJobs(ctx)
|
|
61
75
|
if (!jobs || typeof jobs.start !== 'function') {
|
|
62
76
|
return { result: await fn(), jobId: null }
|
|
63
77
|
}
|
package/src/live.ts
CHANGED
|
@@ -68,6 +68,47 @@ export function liveFilePath(projectRoot: string): string {
|
|
|
68
68
|
return join(projectRoot, '.iterate', 'transcript-live.ndjson')
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Module-level in-process queue serializing live-feed writes. The trim path
|
|
73
|
+
* (read → rewrite → append) is a read-modify-write: two concurrent tool
|
|
74
|
+
* results could interleave and one's rewrite would silently drop the other's
|
|
75
|
+
* freshly-appended line. Chaining every append onto one tail makes the whole
|
|
76
|
+
* read+rewrite+append atomic within the process (the only realistic mode for
|
|
77
|
+
* the plugin); cross-process racing remains a theoretical hazard, but the
|
|
78
|
+
* cap-trim makes a lost line a cosmetic issue, not a correctness one.
|
|
79
|
+
*/
|
|
80
|
+
let liveWriteQueue: Promise<void> = Promise.resolve()
|
|
81
|
+
|
|
82
|
+
function enqueueLiveWrite(task: () => Promise<void>): Promise<void> {
|
|
83
|
+
const next = liveWriteQueue.then(task, task)
|
|
84
|
+
liveWriteQueue = next.catch(() => {})
|
|
85
|
+
return next
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Append one activity record to the project's live feed (byte-capped). */
|
|
89
|
+
export function appendLive(projectRoot: string, entry: LiveActivityEntry): Promise<void> {
|
|
90
|
+
return enqueueLiveWrite(async () => {
|
|
91
|
+
const file = liveFilePath(projectRoot)
|
|
92
|
+
const line = JSON.stringify(entry) + '\n'
|
|
93
|
+
await mkdir(join(projectRoot, '.iterate'), { recursive: true })
|
|
94
|
+
// Amortized O(1): only read+rewrite when the file has grown past the cap.
|
|
95
|
+
try {
|
|
96
|
+
const st = await stat(file).catch(() => null)
|
|
97
|
+
if (st && st.size > LIVE_MAX_BYTES) {
|
|
98
|
+
const raw = await readFile(file, 'utf-8')
|
|
99
|
+
const lines = raw.split('\n').filter(Boolean)
|
|
100
|
+
const tail = lines.slice(-LIVE_MAX_ENTRIES)
|
|
101
|
+
// Atomic rewrite (unique temp + rename) so a crash mid-trim never
|
|
102
|
+
// truncates the live feed; the temp file is prunable by iterate_prune.
|
|
103
|
+
await writeTextAtomicAsync(file, tail.join('\n') + '\n')
|
|
104
|
+
}
|
|
105
|
+
await appendFile(file, line, 'utf-8')
|
|
106
|
+
} catch {
|
|
107
|
+
// Fire-and-forget: never let live capture break a tool call.
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
71
112
|
/** Resolve the project root a tool execution belongs to, if any. */
|
|
72
113
|
function projectRootOf(exec: ToolExecution): string | null {
|
|
73
114
|
const cwd = exec.agent?.session?.header?.cwd
|
|
@@ -130,28 +171,6 @@ export function classifyTool(
|
|
|
130
171
|
return { ts: new Date().toISOString(), type, tool: name, target }
|
|
131
172
|
}
|
|
132
173
|
|
|
133
|
-
/** Append one activity record to the project's live feed (byte-capped). */
|
|
134
|
-
export async function appendLive(projectRoot: string, entry: LiveActivityEntry): Promise<void> {
|
|
135
|
-
const file = liveFilePath(projectRoot)
|
|
136
|
-
const line = JSON.stringify(entry) + '\n'
|
|
137
|
-
await mkdir(join(projectRoot, '.iterate'), { recursive: true })
|
|
138
|
-
// Amortized O(1): only read+rewrite when the file has grown past the cap.
|
|
139
|
-
try {
|
|
140
|
-
const st = await stat(file).catch(() => null)
|
|
141
|
-
if (st && st.size > LIVE_MAX_BYTES) {
|
|
142
|
-
const raw = await readFile(file, 'utf-8')
|
|
143
|
-
const lines = raw.split('\n').filter(Boolean)
|
|
144
|
-
const tail = lines.slice(-LIVE_MAX_ENTRIES)
|
|
145
|
-
// Atomic rewrite (unique temp + rename) so a crash mid-trim never
|
|
146
|
-
// truncates the live feed; the temp file is prunable by iterate_prune.
|
|
147
|
-
await writeTextAtomicAsync(file, tail.join('\n') + '\n')
|
|
148
|
-
}
|
|
149
|
-
await appendFile(file, line, 'utf-8')
|
|
150
|
-
} catch {
|
|
151
|
-
// Fire-and-forget: never let live capture break a tool call.
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
174
|
/** Read the live feed (newest first), capped at the last LIVE_MAX_ENTRIES. */
|
|
156
175
|
export async function readLive(projectRoot: string): Promise<LiveActivityEntry[]> {
|
|
157
176
|
const file = liveFilePath(projectRoot)
|
package/src/meta-review.ts
CHANGED
|
@@ -184,7 +184,7 @@ export function metaReviewReport(report: ReviewReport): MetaReviewResult {
|
|
|
184
184
|
`byDimension sums to ${dimSum}, but totalFindings is ${total}.`,
|
|
185
185
|
)
|
|
186
186
|
}
|
|
187
|
-
const invalidDim = findings.find((f) => !dimensions.includes(f?.dimension))
|
|
187
|
+
const invalidDim = findings.find((f) => !!f && !dimensions.includes(f?.dimension))
|
|
188
188
|
if (invalidDim) {
|
|
189
189
|
add(
|
|
190
190
|
'DIMENSION_UNKNOWN',
|
|
@@ -364,7 +364,7 @@ export function buildFinalReviewReport(
|
|
|
364
364
|
// surfaced it (best-effort; report rounds carry it).
|
|
365
365
|
for (const r of report.rounds ?? []) {
|
|
366
366
|
const matched = (r.findings ?? []).some(
|
|
367
|
-
(fnd) => fnd
|
|
367
|
+
(fnd) => !!fnd && fnd?.file === violation.file && fnd?.line === violation.line,
|
|
368
368
|
)
|
|
369
369
|
if (matched) {
|
|
370
370
|
roundHint = ` (round ${r.round})`
|
package/src/review-scope.ts
CHANGED
|
@@ -72,8 +72,12 @@ function normalizePath(path: string): string {
|
|
|
72
72
|
for (const part of cleaned.split(SEP)) {
|
|
73
73
|
if (part === '' || part === '.') continue
|
|
74
74
|
if (part === '..') {
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
// Mirrors Python `os.path.normpath`: a leading `..` is preserved and a
|
|
76
|
+
// second `..` is NEVER collapsed into a bare `/` away — otherwise a
|
|
77
|
+
// traversal like `../../evil` folds into the single segment `evil` and
|
|
78
|
+
// the escape becomes undetectable downstream.
|
|
79
|
+
if (parts.length > 0 && parts[parts.length - 1] !== '..') parts.pop()
|
|
80
|
+
else parts.push(part)
|
|
77
81
|
continue
|
|
78
82
|
}
|
|
79
83
|
parts.push(part)
|
package/src/review.ts
CHANGED
|
@@ -39,17 +39,18 @@ export const SEVERITY_RANK: Record<ReviewFinding['severity'], number> = {
|
|
|
39
39
|
export function sortFindings(findings: ReviewFinding[]): ReviewFinding[] {
|
|
40
40
|
return [...findings].sort((a, b) => {
|
|
41
41
|
// Guard against an out-of-spec severity string (e.g. from a model that
|
|
42
|
-
// bypassed the schema)
|
|
43
|
-
// the
|
|
44
|
-
|
|
45
|
-
const
|
|
42
|
+
// bypassed the schema) AND against a null list element (model-authored
|
|
43
|
+
// JSON): treat both as the least severe so NaN never enters the
|
|
44
|
+
// comparator and ordering stays deterministic.
|
|
45
|
+
const rankA = SEVERITY_RANK[a?.severity] ?? SEVERITY_RANK.low
|
|
46
|
+
const rankB = SEVERITY_RANK[b?.severity] ?? SEVERITY_RANK.low
|
|
46
47
|
const bySeverity = rankA - rankB
|
|
47
48
|
if (bySeverity !== 0) return bySeverity
|
|
48
49
|
// Defensive coercion: `file`/`line` can be wrong-typed when schema
|
|
49
50
|
// validation is disabled — String()/Number() keep the comparator total.
|
|
50
|
-
const byFile = String(a
|
|
51
|
+
const byFile = String(a?.file ?? '').localeCompare(String(b?.file ?? ''))
|
|
51
52
|
if (byFile !== 0) return byFile
|
|
52
|
-
return (Number(a
|
|
53
|
+
return (Number(a?.line) || 0) - (Number(b?.line) || 0)
|
|
53
54
|
})
|
|
54
55
|
}
|
|
55
56
|
|
package/src/skill-prompt.ts
CHANGED
|
@@ -357,10 +357,10 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
357
357
|
const byFile = {}
|
|
358
358
|
atomic.forEach(f => { (byFile[f.file] = byFile[f.file] || []).push(f) })
|
|
359
359
|
const fixRes = await parallel(Object.keys(byFile).map(file => () => agent(
|
|
360
|
-
'Apply the fixes for ' + file + ' using iterate_fix. For EACH finding in this list, ' +
|
|
360
|
+
'Apply the fixes for ' + JSON.stringify(file) + ' using iterate_fix. For EACH finding in this list, ' +
|
|
361
361
|
'read the current file, compute the edited full content (change <= ' + atomicMaxLines + ' lines), and call ' +
|
|
362
|
-
'iterate_fix({ file:
|
|
363
|
-
'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file:
|
|
362
|
+
'iterate_fix({ file: ' + JSON.stringify(file) + ', content: <full new file content>, finding: <that finding>, round: ' + r + ' }). ' +
|
|
363
|
+
'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file: ' + JSON.stringify(file) + ' }) to verify the accumulated diff and ' +
|
|
364
364
|
'read its line statistics (lines added/removed). ' +
|
|
365
365
|
'Findings: ' + JSON.stringify(byFile[file]) + '. Return the array of {id, ok, error, file, linesAdded, linesRemoved} per iterate_fix call ' +
|
|
366
366
|
'(id/ok required; put the file-wide line stats from iterate_diff on each record, or on the last record and 0 elsewhere).',
|
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
appendFileSync,
|
|
3
|
+
readFileSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
openSync,
|
|
7
|
+
writeSync,
|
|
8
|
+
closeSync,
|
|
9
|
+
unlinkSync,
|
|
10
|
+
statSync,
|
|
11
|
+
} from 'node:fs'
|
|
2
12
|
import { join } from 'node:path'
|
|
3
13
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
4
14
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
@@ -8,6 +18,109 @@ import type { DecisionLogEntry } from '../types.ts'
|
|
|
8
18
|
const LOG_DIR = '.iterate'
|
|
9
19
|
const LOG_FILE = 'decision-log.jsonl'
|
|
10
20
|
|
|
21
|
+
// ─── Cross-process log mutex ────────────────────────────────────────────────
|
|
22
|
+
//
|
|
23
|
+
// The prune rewrite (temp + atomic rename) can silently discard an audit line
|
|
24
|
+
// from a CONCURRENT appender whose fd was opened against the pre-rename inode:
|
|
25
|
+
// the appender writes to the unlinked inode, the post-rewrite re-read sees a
|
|
26
|
+
// "stable" file, and the fresh entry is lost. In-process safety is guaranteed
|
|
27
|
+
// by synchronous I/O, but a second plugin process appending to the same
|
|
28
|
+
// project realises the race. The log is serialized with a tiny advisory lock
|
|
29
|
+
// file (exclusive create, pid stamped, stale-stealable) so `append` and the
|
|
30
|
+
// prune rewrite mutually exclude across processes. Best-effort: contention
|
|
31
|
+
// timeouts degrade to "proceed unlocked" (the bounded retry loop in prune.ts
|
|
32
|
+
// stays as defense-in-depth), never to a crash.
|
|
33
|
+
|
|
34
|
+
const LOCK_FILE = '.decision-log.lock'
|
|
35
|
+
const LOCK_WAIT_MS = 5000
|
|
36
|
+
const LOCK_STALE_MS = 10000
|
|
37
|
+
const LOCK_POLL_MS = 25
|
|
38
|
+
|
|
39
|
+
/** True when a pid refers to a live process (or we lack permission to tell). */
|
|
40
|
+
function processAlive(pid: number): boolean {
|
|
41
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
42
|
+
try {
|
|
43
|
+
process.kill(pid, 0)
|
|
44
|
+
return true
|
|
45
|
+
} catch (err) {
|
|
46
|
+
return (err as NodeJS.ErrnoException).code === 'EPERM'
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Synchronous busy-wait sleep (Atomics.wait is a reliable sleep in Node). */
|
|
51
|
+
function sleepSync(ms: number): void {
|
|
52
|
+
const sab = new Int32Array(new SharedArrayBuffer(4))
|
|
53
|
+
Atomics.wait(sab, 0, 0, ms)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Acquire the decision-log lock for `projectRoot`.
|
|
58
|
+
* Returns a release function (always callable; a no-op when the lock could
|
|
59
|
+
* not be taken — never wedge an append behind a vanished holder).
|
|
60
|
+
*/
|
|
61
|
+
export function acquireLogLock(projectRoot: string): () => void {
|
|
62
|
+
const dir = join(projectRoot, LOG_DIR)
|
|
63
|
+
if (!existsSync(dir)) {
|
|
64
|
+
try {
|
|
65
|
+
mkdirSync(dir, { recursive: true })
|
|
66
|
+
} catch {
|
|
67
|
+
return () => {}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const lockPath = join(dir, LOCK_FILE)
|
|
71
|
+
const start = Date.now()
|
|
72
|
+
for (;;) {
|
|
73
|
+
let fd: number | null = null
|
|
74
|
+
try {
|
|
75
|
+
fd = openSync(lockPath, 'wx')
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return () => {}
|
|
78
|
+
// Lock exists — steal it when the owner is gone or the lock is ancient.
|
|
79
|
+
let stale = false
|
|
80
|
+
try {
|
|
81
|
+
const st = statSync(lockPath)
|
|
82
|
+
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) stale = true
|
|
83
|
+
else {
|
|
84
|
+
const pid = Number(readFileSync(lockPath, 'utf-8'))
|
|
85
|
+
if (!processAlive(pid)) stale = true
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
stale = true // unreadable/vanishing lock → retry as stale
|
|
89
|
+
}
|
|
90
|
+
if (stale) {
|
|
91
|
+
try { unlinkSync(lockPath) } catch { /* another holder stole it */ }
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
if (Date.now() - start > LOCK_WAIT_MS) return () => {}
|
|
95
|
+
sleepSync(LOCK_POLL_MS)
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
// Owned the lock: stamp the holder pid, then release once on return.
|
|
99
|
+
try { writeSync(fd, String(process.pid)) } catch { /* pid stamp is advisory */ }
|
|
100
|
+
try { closeSync(fd) } catch { /* best-effort */ }
|
|
101
|
+
let released = false
|
|
102
|
+
return () => {
|
|
103
|
+
if (released) return
|
|
104
|
+
released = true
|
|
105
|
+
try { unlinkSync(lockPath) } catch { /* already gone */ }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── Entry-count cache (avoid an O(n) full re-read per append) ─────────────
|
|
111
|
+
|
|
112
|
+
const entryCountCache = new Map<string, { size: number; count: number }>()
|
|
113
|
+
|
|
114
|
+
/** Line-count the log file from scratch (entries = non-empty JSON lines). */
|
|
115
|
+
function recountEntries(filePath: string): number {
|
|
116
|
+
try {
|
|
117
|
+
const content = readFileSync(filePath, 'utf-8')
|
|
118
|
+
return content.split('\n').filter((l) => l.trim().length > 0).length
|
|
119
|
+
} catch {
|
|
120
|
+
return 1
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
11
124
|
/** All valid DecisionLogEntry `type` values (must stay in sync with Types). */
|
|
12
125
|
const VALID_ENTRY_TYPES = new Set<DecisionLogEntry['type']>([
|
|
13
126
|
'round_start',
|
|
@@ -57,23 +170,30 @@ function logPath(projectRoot: string): string {
|
|
|
57
170
|
* miss without failing the mutation they already performed.
|
|
58
171
|
*/
|
|
59
172
|
export function appendDecisionEntry(projectRoot: string, entry: DecisionLogEntry): { count: number; path: string; error?: string } {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const line = JSON.stringify(entry) + '\n'
|
|
64
|
-
appendFileSync(filePath, line, 'utf-8')
|
|
65
|
-
} catch (err) {
|
|
66
|
-
return { count: 0, path: join(projectRoot, LOG_DIR, LOG_FILE), error: `failed to append decision log: ${String(err)}` }
|
|
67
|
-
}
|
|
68
|
-
// Count entries
|
|
69
|
-
let count = 0
|
|
173
|
+
// Serialize with the cross-process log lock so an append can never interleave
|
|
174
|
+
// with a prune rewrite (which renames the file under us).
|
|
175
|
+
const release = acquireLogLock(projectRoot)
|
|
70
176
|
try {
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
177
|
+
const filePath = logPath(projectRoot)
|
|
178
|
+
try {
|
|
179
|
+
const line = JSON.stringify(entry) + '\n'
|
|
180
|
+
const prevSize = existsSync(filePath) ? statSync(filePath).size : 0
|
|
181
|
+
appendFileSync(filePath, line, 'utf-8')
|
|
182
|
+
// Count entries via the size/count cache: when nothing else changed the
|
|
183
|
+
// file since our last append (same byte size), count is just +1 — no full
|
|
184
|
+
// re-read. Falls back to a full re-count whenever the cache is stale.
|
|
185
|
+
const cached = entryCountCache.get(filePath)
|
|
186
|
+
const count = cached && cached.size === prevSize
|
|
187
|
+
? cached.count + 1
|
|
188
|
+
: recountEntries(filePath)
|
|
189
|
+
entryCountCache.set(filePath, { size: existsSync(filePath) ? statSync(filePath).size : prevSize, count })
|
|
190
|
+
return { count, path: filePath }
|
|
191
|
+
} catch (err) {
|
|
192
|
+
return { count: 0, path: join(projectRoot, LOG_DIR, LOG_FILE), error: `failed to append decision log: ${String(err)}` }
|
|
193
|
+
}
|
|
194
|
+
} finally {
|
|
195
|
+
release()
|
|
75
196
|
}
|
|
76
|
-
return { count, path: filePath }
|
|
77
197
|
}
|
|
78
198
|
|
|
79
199
|
/**
|
package/src/tools/fix.ts
CHANGED
|
@@ -366,6 +366,7 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
366
366
|
linesRemoved: { type: 'integer' },
|
|
367
367
|
diffSummary: { type: 'string' },
|
|
368
368
|
backupPath: { type: 'string' },
|
|
369
|
+
warning: { type: 'string' },
|
|
369
370
|
error: { type: 'string' },
|
|
370
371
|
},
|
|
371
372
|
},
|
|
@@ -456,6 +457,17 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
456
457
|
return { ok: false, error: `finding already fixed this run (id: ${id})`, id }
|
|
457
458
|
}
|
|
458
459
|
|
|
460
|
+
// No-op guard: content-identical "fixes" (e.g. a fixer that re-sent the
|
|
461
|
+
// file unchanged) must never burn a backup, a write, or a registry/success
|
|
462
|
+
// record. Placed after the registry check so a re-sent fix of an id that
|
|
463
|
+
// was ALREADY fixed is still reported as "already fixed this run".
|
|
464
|
+
if (added === 0 && removed === 0) {
|
|
465
|
+
return {
|
|
466
|
+
ok: false,
|
|
467
|
+
error: `no changes: the supplied content for ${file} is identical to the current content — apply a real edit`,
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
459
471
|
const target = resolveProjectFile(projectRoot, file)
|
|
460
472
|
if (!target.ok) return { ok: false, error: target.reason }
|
|
461
473
|
|
|
@@ -528,13 +540,16 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
528
540
|
return { ok: false, error: `failed to write fix registry: ${String(err)} (file restored from backup)` }
|
|
529
541
|
}
|
|
530
542
|
|
|
531
|
-
appendDecisionEntry(projectRoot, {
|
|
543
|
+
const logRes = appendDecisionEntry(projectRoot, {
|
|
532
544
|
timestamp,
|
|
533
545
|
round: args.round,
|
|
534
546
|
type: 'atomic_fix',
|
|
535
547
|
data: { id, file, finding: finding.summary, linesAdded: added, linesRemoved: removed },
|
|
536
548
|
})
|
|
537
549
|
|
|
550
|
+
// The fix itself succeeded, but a decision-log write failure would leave
|
|
551
|
+
// the audit trail incomplete — surface it (warning, not fatal) so the
|
|
552
|
+
// model/UI knows the record was not persisted.
|
|
538
553
|
return {
|
|
539
554
|
ok: true,
|
|
540
555
|
id,
|
|
@@ -544,6 +559,7 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
544
559
|
linesRemoved: removed,
|
|
545
560
|
diffSummary: record.diffSummary,
|
|
546
561
|
backupPath,
|
|
562
|
+
...(logRes.error ? { warning: logRes.error } : {}),
|
|
547
563
|
}
|
|
548
564
|
})
|
|
549
565
|
return result
|
|
@@ -710,6 +726,7 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
|
|
|
710
726
|
ok: { type: 'boolean', required: true },
|
|
711
727
|
id: { type: 'string' },
|
|
712
728
|
file: { type: 'string' },
|
|
729
|
+
warning: { type: 'string' },
|
|
713
730
|
error: { type: 'string' },
|
|
714
731
|
},
|
|
715
732
|
},
|
|
@@ -749,7 +766,7 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
|
|
|
749
766
|
return { ok: false, error: `failed to update fix registry: ${String(err)}` }
|
|
750
767
|
}
|
|
751
768
|
|
|
752
|
-
appendDecisionEntry(projectRoot, {
|
|
769
|
+
const logRes = appendDecisionEntry(projectRoot, {
|
|
753
770
|
timestamp: new Date().toISOString(),
|
|
754
771
|
round: record.round,
|
|
755
772
|
type: 'revert',
|
|
@@ -762,7 +779,12 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
|
|
|
762
779
|
// rollback flow.
|
|
763
780
|
await markFixRolledBackInTranscript(projectRoot, id)
|
|
764
781
|
|
|
765
|
-
return {
|
|
782
|
+
return {
|
|
783
|
+
ok: true,
|
|
784
|
+
id,
|
|
785
|
+
file: record.finding.file,
|
|
786
|
+
...(logRes.error ? { warning: logRes.error } : {}),
|
|
787
|
+
}
|
|
766
788
|
},
|
|
767
789
|
}),
|
|
768
790
|
)
|
package/src/tools/prune.ts
CHANGED
|
@@ -30,7 +30,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
30
30
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
31
31
|
import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
32
32
|
import { writeTextAtomic, writeJsonAtomic } from '../atomic-fs.ts'
|
|
33
|
-
import { readDecisionEntries, appendDecisionEntry } from './decision-log.ts'
|
|
33
|
+
import { readDecisionEntries, appendDecisionEntry, acquireLogLock } from './decision-log.ts'
|
|
34
34
|
import { readRegistry, removeRecord, recomputeRoundCounts } from './fix.ts'
|
|
35
35
|
import { readExperienceBank, writeExperienceBank } from './experience-store.ts'
|
|
36
36
|
import { readDefenseEvents, writeDefenseEvents } from './defense-store.ts'
|
|
@@ -264,23 +264,32 @@ export function inspectPrune(
|
|
|
264
264
|
*/
|
|
265
265
|
function rewriteDecisionLogKeepingRecent(projectRoot: string, cutoff: string): { deleted: number; error?: string } {
|
|
266
266
|
const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl')
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
267
|
+
// Take the cross-process log lock for the ENTIRE loop: with the mutex held,
|
|
268
|
+
// no concurrent appender can slip a fresh line into the read-before-rewrite
|
|
269
|
+
// window, so the first attempt is nearly always the only one. (The retry
|
|
270
|
+
// loop stays as defense-in-depth for the lock-unavailable path.)
|
|
271
|
+
const release = acquireLogLock(projectRoot)
|
|
272
|
+
try {
|
|
273
|
+
for (let attempt = 0; attempt < MAX_LOG_REWRITE_RETRIES; attempt++) {
|
|
274
|
+
try {
|
|
275
|
+
const entries = readDecisionEntries(projectRoot)
|
|
276
|
+
const kept = entries.filter((e) => e.timestamp >= cutoff)
|
|
277
|
+
const deleted = entries.length - kept.length
|
|
278
|
+
if (deleted === 0) return { deleted: 0 }
|
|
279
|
+
writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n')
|
|
280
|
+
// A concurrent appender (cross-process, lock-unavailable path) may have
|
|
281
|
+
// landed new lines after our read. If the log grew during the write
|
|
282
|
+
// window, re-read and prune again instead of accepting a lost audit trail.
|
|
283
|
+
const after = readDecisionEntries(projectRoot)
|
|
284
|
+
if (after.length <= kept.length) return { deleted }
|
|
285
|
+
} catch (err) {
|
|
286
|
+
return { deleted: 0, error: `failed to rewrite decision log: ${String(err)}` }
|
|
287
|
+
}
|
|
281
288
|
}
|
|
289
|
+
return { deleted: 0, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` }
|
|
290
|
+
} finally {
|
|
291
|
+
release()
|
|
282
292
|
}
|
|
283
|
-
return { deleted: 0, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` }
|
|
284
293
|
}
|
|
285
294
|
|
|
286
295
|
/**
|
|
@@ -507,8 +516,10 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
|
|
|
507
516
|
|
|
508
517
|
const result = executePrune(projectRoot, retainDays, report)
|
|
509
518
|
|
|
510
|
-
// Log the prune to the decision log.
|
|
511
|
-
|
|
519
|
+
// Log the prune to the decision log. The mutations already happened, so
|
|
520
|
+
// a failed log append is reported via `errors` — a silent audit miss
|
|
521
|
+
// (F2) must never pretend every deletion was recorded.
|
|
522
|
+
const logRes = appendDecisionEntry(projectRoot, {
|
|
512
523
|
timestamp: new Date().toISOString(),
|
|
513
524
|
round: 0,
|
|
514
525
|
type: 'decision',
|
|
@@ -523,6 +534,7 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
|
|
|
523
534
|
deletedDefenseEvents: result.deletedDefenseEvents,
|
|
524
535
|
},
|
|
525
536
|
})
|
|
537
|
+
if (logRes.error) result.errors.push(logRes.error)
|
|
526
538
|
|
|
527
539
|
return {
|
|
528
540
|
ok: true,
|
package/src/tools/triage.ts
CHANGED
|
@@ -208,8 +208,9 @@ function readConfigFile(configPath: string): Record<string, unknown> {
|
|
|
208
208
|
if (!existsSync(configPath)) return {}
|
|
209
209
|
const content = readFileSync(configPath, 'utf-8')
|
|
210
210
|
const parsed = yaml.load(content)
|
|
211
|
-
if (!parsed || typeof parsed !== 'object') {
|
|
212
|
-
// A config that exists but is
|
|
211
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
212
|
+
// A config that exists but is NOT a YAML mapping (including a YAML list,
|
|
213
|
+
// which `typeof [] === 'object'` alone would accept) must NOT be silently
|
|
213
214
|
// treated as empty: writing over it would destroy user data. Callers
|
|
214
215
|
// surface this as an error and refuse to write.
|
|
215
216
|
throw new Error('existing iterate.config.yaml is not a valid YAML mapping')
|