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/dist/tools/fix.js
CHANGED
|
@@ -364,6 +364,7 @@ export function registerFixTool(ctx) {
|
|
|
364
364
|
linesRemoved: { type: 'integer' },
|
|
365
365
|
diffSummary: { type: 'string' },
|
|
366
366
|
backupPath: { type: 'string' },
|
|
367
|
+
warning: { type: 'string' },
|
|
367
368
|
error: { type: 'string' },
|
|
368
369
|
},
|
|
369
370
|
},
|
|
@@ -451,6 +452,16 @@ export function registerFixTool(ctx) {
|
|
|
451
452
|
if (findFixRecord(registry, id)) {
|
|
452
453
|
return { ok: false, error: `finding already fixed this run (id: ${id})`, id };
|
|
453
454
|
}
|
|
455
|
+
// No-op guard: content-identical "fixes" (e.g. a fixer that re-sent the
|
|
456
|
+
// file unchanged) must never burn a backup, a write, or a registry/success
|
|
457
|
+
// record. Placed after the registry check so a re-sent fix of an id that
|
|
458
|
+
// was ALREADY fixed is still reported as "already fixed this run".
|
|
459
|
+
if (added === 0 && removed === 0) {
|
|
460
|
+
return {
|
|
461
|
+
ok: false,
|
|
462
|
+
error: `no changes: the supplied content for ${file} is identical to the current content — apply a real edit`,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
454
465
|
const target = resolveProjectFile(projectRoot, file);
|
|
455
466
|
if (!target.ok)
|
|
456
467
|
return { ok: false, error: target.reason };
|
|
@@ -521,12 +532,15 @@ export function registerFixTool(ctx) {
|
|
|
521
532
|
}
|
|
522
533
|
return { ok: false, error: `failed to write fix registry: ${String(err)} (file restored from backup)` };
|
|
523
534
|
}
|
|
524
|
-
appendDecisionEntry(projectRoot, {
|
|
535
|
+
const logRes = appendDecisionEntry(projectRoot, {
|
|
525
536
|
timestamp,
|
|
526
537
|
round: args.round,
|
|
527
538
|
type: 'atomic_fix',
|
|
528
539
|
data: { id, file, finding: finding.summary, linesAdded: added, linesRemoved: removed },
|
|
529
540
|
});
|
|
541
|
+
// The fix itself succeeded, but a decision-log write failure would leave
|
|
542
|
+
// the audit trail incomplete — surface it (warning, not fatal) so the
|
|
543
|
+
// model/UI knows the record was not persisted.
|
|
530
544
|
return {
|
|
531
545
|
ok: true,
|
|
532
546
|
id,
|
|
@@ -536,6 +550,7 @@ export function registerFixTool(ctx) {
|
|
|
536
550
|
linesRemoved: removed,
|
|
537
551
|
diffSummary: record.diffSummary,
|
|
538
552
|
backupPath,
|
|
553
|
+
...(logRes.error ? { warning: logRes.error } : {}),
|
|
539
554
|
};
|
|
540
555
|
});
|
|
541
556
|
return result;
|
|
@@ -696,6 +711,7 @@ export function registerRollbackTool(ctx) {
|
|
|
696
711
|
ok: { type: 'boolean', required: true },
|
|
697
712
|
id: { type: 'string' },
|
|
698
713
|
file: { type: 'string' },
|
|
714
|
+
warning: { type: 'string' },
|
|
699
715
|
error: { type: 'string' },
|
|
700
716
|
},
|
|
701
717
|
},
|
|
@@ -736,7 +752,7 @@ export function registerRollbackTool(ctx) {
|
|
|
736
752
|
catch (err) {
|
|
737
753
|
return { ok: false, error: `failed to update fix registry: ${String(err)}` };
|
|
738
754
|
}
|
|
739
|
-
appendDecisionEntry(projectRoot, {
|
|
755
|
+
const logRes = appendDecisionEntry(projectRoot, {
|
|
740
756
|
timestamp: new Date().toISOString(),
|
|
741
757
|
round: record.round,
|
|
742
758
|
type: 'revert',
|
|
@@ -747,7 +763,12 @@ export function registerRollbackTool(ctx) {
|
|
|
747
763
|
// a missing/corrupt transcript is left untouched and never breaks the
|
|
748
764
|
// rollback flow.
|
|
749
765
|
await markFixRolledBackInTranscript(projectRoot, id);
|
|
750
|
-
return {
|
|
766
|
+
return {
|
|
767
|
+
ok: true,
|
|
768
|
+
id,
|
|
769
|
+
file: record.finding.file,
|
|
770
|
+
...(logRes.error ? { warning: logRes.error } : {}),
|
|
771
|
+
};
|
|
751
772
|
},
|
|
752
773
|
}));
|
|
753
774
|
}
|
package/dist/tools/prune.js
CHANGED
|
@@ -28,7 +28,7 @@ import { join } from 'node:path';
|
|
|
28
28
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
29
29
|
import { resolveProjectRootForExec } from "../config-loader.js";
|
|
30
30
|
import { writeTextAtomic, writeJsonAtomic } from "../atomic-fs.js";
|
|
31
|
-
import { readDecisionEntries, appendDecisionEntry } from "./decision-log.js";
|
|
31
|
+
import { readDecisionEntries, appendDecisionEntry, acquireLogLock } from "./decision-log.js";
|
|
32
32
|
import { readRegistry, recomputeRoundCounts } from "./fix.js";
|
|
33
33
|
import { readExperienceBank, writeExperienceBank } from "./experience-store.js";
|
|
34
34
|
import { readDefenseEvents, writeDefenseEvents } from "./defense-store.js";
|
|
@@ -234,26 +234,36 @@ export function inspectPrune(projectRoot, retainDays) {
|
|
|
234
234
|
*/
|
|
235
235
|
function rewriteDecisionLogKeepingRecent(projectRoot, cutoff) {
|
|
236
236
|
const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl');
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
237
|
+
// Take the cross-process log lock for the ENTIRE loop: with the mutex held,
|
|
238
|
+
// no concurrent appender can slip a fresh line into the read-before-rewrite
|
|
239
|
+
// window, so the first attempt is nearly always the only one. (The retry
|
|
240
|
+
// loop stays as defense-in-depth for the lock-unavailable path.)
|
|
241
|
+
const release = acquireLogLock(projectRoot);
|
|
242
|
+
try {
|
|
243
|
+
for (let attempt = 0; attempt < MAX_LOG_REWRITE_RETRIES; attempt++) {
|
|
244
|
+
try {
|
|
245
|
+
const entries = readDecisionEntries(projectRoot);
|
|
246
|
+
const kept = entries.filter((e) => e.timestamp >= cutoff);
|
|
247
|
+
const deleted = entries.length - kept.length;
|
|
248
|
+
if (deleted === 0)
|
|
249
|
+
return { deleted: 0 };
|
|
250
|
+
writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n');
|
|
251
|
+
// A concurrent appender (cross-process, lock-unavailable path) may have
|
|
252
|
+
// landed new lines after our read. If the log grew during the write
|
|
253
|
+
// window, re-read and prune again instead of accepting a lost audit trail.
|
|
254
|
+
const after = readDecisionEntries(projectRoot);
|
|
255
|
+
if (after.length <= kept.length)
|
|
256
|
+
return { deleted };
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
return { deleted: 0, error: `failed to rewrite decision log: ${String(err)}` };
|
|
260
|
+
}
|
|
254
261
|
}
|
|
262
|
+
return { deleted: 0, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` };
|
|
263
|
+
}
|
|
264
|
+
finally {
|
|
265
|
+
release();
|
|
255
266
|
}
|
|
256
|
-
return { deleted: 0, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` };
|
|
257
267
|
}
|
|
258
268
|
/**
|
|
259
269
|
* Execute the prune: apply every cleanable item reported by `inspectPrune`.
|
|
@@ -459,8 +469,10 @@ export function registerPruneTool(ctx) {
|
|
|
459
469
|
};
|
|
460
470
|
}
|
|
461
471
|
const result = executePrune(projectRoot, retainDays, report);
|
|
462
|
-
// Log the prune to the decision log.
|
|
463
|
-
|
|
472
|
+
// Log the prune to the decision log. The mutations already happened, so
|
|
473
|
+
// a failed log append is reported via `errors` — a silent audit miss
|
|
474
|
+
// (F2) must never pretend every deletion was recorded.
|
|
475
|
+
const logRes = appendDecisionEntry(projectRoot, {
|
|
464
476
|
timestamp: new Date().toISOString(),
|
|
465
477
|
round: 0,
|
|
466
478
|
type: 'decision',
|
|
@@ -475,6 +487,8 @@ export function registerPruneTool(ctx) {
|
|
|
475
487
|
deletedDefenseEvents: result.deletedDefenseEvents,
|
|
476
488
|
},
|
|
477
489
|
});
|
|
490
|
+
if (logRes.error)
|
|
491
|
+
result.errors.push(logRes.error);
|
|
478
492
|
return {
|
|
479
493
|
ok: true,
|
|
480
494
|
dryRun: false,
|
package/dist/tools/triage.js
CHANGED
|
@@ -184,8 +184,9 @@ function readConfigFile(configPath) {
|
|
|
184
184
|
return {};
|
|
185
185
|
const content = readFileSync(configPath, 'utf-8');
|
|
186
186
|
const parsed = yaml.load(content);
|
|
187
|
-
if (!parsed || typeof parsed !== 'object') {
|
|
188
|
-
// A config that exists but is
|
|
187
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
188
|
+
// A config that exists but is NOT a YAML mapping (including a YAML list,
|
|
189
|
+
// which `typeof [] === 'object'` alone would accept) must NOT be silently
|
|
189
190
|
// treated as empty: writing over it would destroy user data. Callers
|
|
190
191
|
// surface this as an error and refuse to write.
|
|
191
192
|
throw new Error('existing iterate.config.yaml is not a valid YAML mapping');
|
package/dist/transcript.js
CHANGED
|
@@ -54,6 +54,20 @@ function clampStringList(source, cap) {
|
|
|
54
54
|
}
|
|
55
55
|
return out;
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Deep-clone a decision-timeline payload so it is decoupled from the caller's
|
|
59
|
+
* live object. structuredClone is available in every supported Node release;
|
|
60
|
+
* on the off chance it is missing, fall back to a JSON round-trip (decision
|
|
61
|
+
* payloads are JSON-serializable by construction).
|
|
62
|
+
*/
|
|
63
|
+
function cloneDecisionData(data) {
|
|
64
|
+
try {
|
|
65
|
+
return structuredClone(data);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return JSON.parse(JSON.stringify(data));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
57
71
|
/** Normalize a single finding, dropping malformed entries. */
|
|
58
72
|
function normalizeFinding(input) {
|
|
59
73
|
if (!input || typeof input !== 'object')
|
|
@@ -81,12 +95,23 @@ function normalizeFinding(input) {
|
|
|
81
95
|
acknowledged: typeof f.acknowledged === 'boolean' ? f.acknowledged : undefined,
|
|
82
96
|
};
|
|
83
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Append one raw finding to a thread's list, bound to MAX_FINDINGS_PER_THREAD
|
|
100
|
+
* with NEWEST-WINS eviction (drop the oldest findings once the cap is hit).
|
|
101
|
+
* A single oversized fixer agent could otherwise OOM the client with an
|
|
102
|
+
* unbounded finding dump inside one thread.
|
|
103
|
+
*/
|
|
104
|
+
function pushThreadFinding(thread, raw) {
|
|
105
|
+
thread.findings.push(raw);
|
|
106
|
+
if (thread.findings.length > MAX_FINDINGS_PER_THREAD) {
|
|
107
|
+
thread.findings.splice(0, thread.findings.length - MAX_FINDINGS_PER_THREAD);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
84
110
|
/** Merge a report snapshot's findings/readFiles into a thread by dimension. */
|
|
85
111
|
function mergeReportIntoThread(thread, findings, readFiles) {
|
|
86
112
|
for (const raw of findings) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
thread.findings.push(raw);
|
|
113
|
+
if (normalizeFinding(raw))
|
|
114
|
+
pushThreadFinding(thread, raw);
|
|
90
115
|
}
|
|
91
116
|
for (const r of readFiles ?? []) {
|
|
92
117
|
if (typeof r === 'string')
|
|
@@ -111,6 +136,7 @@ export class ReviewTranscriptBuilder {
|
|
|
111
136
|
rounds = [];
|
|
112
137
|
convergence = [];
|
|
113
138
|
globalFindings = [];
|
|
139
|
+
globalSeenKeys = new Set();
|
|
114
140
|
fixes = [];
|
|
115
141
|
checkpoint = null;
|
|
116
142
|
timeline = [];
|
|
@@ -237,12 +263,11 @@ export class ReviewTranscriptBuilder {
|
|
|
237
263
|
for (const raw of findings) {
|
|
238
264
|
const f = normalizeFinding(raw);
|
|
239
265
|
if (f) {
|
|
240
|
-
thread
|
|
241
|
-
this.
|
|
266
|
+
pushThreadFinding(thread, raw);
|
|
267
|
+
this.addGlobal(f);
|
|
242
268
|
}
|
|
243
269
|
}
|
|
244
270
|
}
|
|
245
|
-
this.reevaluateGlobal();
|
|
246
271
|
this.touch();
|
|
247
272
|
}
|
|
248
273
|
/** Merge a round-level report snapshot (findings + readFiles) into a thread. */
|
|
@@ -255,9 +280,8 @@ export class ReviewTranscriptBuilder {
|
|
|
255
280
|
for (const raw of findings) {
|
|
256
281
|
const f = normalizeFinding(raw);
|
|
257
282
|
if (f)
|
|
258
|
-
this.
|
|
283
|
+
this.addGlobal(f);
|
|
259
284
|
}
|
|
260
|
-
this.reevaluateGlobal();
|
|
261
285
|
this.touch();
|
|
262
286
|
}
|
|
263
287
|
// ─── Convergence (F2) ───────────────────────────────────────────────────
|
|
@@ -337,8 +361,12 @@ export class ReviewTranscriptBuilder {
|
|
|
337
361
|
? Math.floor(entry.round)
|
|
338
362
|
: this.round,
|
|
339
363
|
type,
|
|
340
|
-
data: entry.data && typeof entry.data === 'object'
|
|
341
|
-
|
|
364
|
+
data: entry.data && typeof entry.data === 'object' && !Array.isArray(entry.data)
|
|
365
|
+
// Deep clone so a caller mutating its own object later can never
|
|
366
|
+
// alias into the timeline (serialize would then emit the mutated
|
|
367
|
+
// values instead of what was actually decided). Decision-log payloads
|
|
368
|
+
// are small JSON, so the clone cost is negligible.
|
|
369
|
+
? cloneDecisionData(entry.data)
|
|
342
370
|
: {},
|
|
343
371
|
});
|
|
344
372
|
if (this.timeline.length > MAX_TIMELINE) {
|
|
@@ -385,9 +413,9 @@ export class ReviewTranscriptBuilder {
|
|
|
385
413
|
maxRounds: this.maxRounds,
|
|
386
414
|
rounds,
|
|
387
415
|
convergence: this.convergence,
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
416
|
+
// addGlobal maintains the newest-wins cap incrementally, so the array is
|
|
417
|
+
// already ≤ MAX_FINDINGS_TOTAL — no post-hoc slice needed.
|
|
418
|
+
findings: this.globalFindings,
|
|
391
419
|
fixes: this.fixes,
|
|
392
420
|
checkpoint: this.checkpoint,
|
|
393
421
|
timeline: this.timeline,
|
|
@@ -416,25 +444,23 @@ export class ReviewTranscriptBuilder {
|
|
|
416
444
|
threadCount(live) {
|
|
417
445
|
return live.threads.length;
|
|
418
446
|
}
|
|
419
|
-
/**
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
return;
|
|
437
|
-
}
|
|
447
|
+
/**
|
|
448
|
+
* Insert one deduplicated finding into the compact global list.
|
|
449
|
+
* NEWEST-WINS: when the global cap (MAX_FINDINGS_TOTAL) is hit, the oldest
|
|
450
|
+
* kept finding is evicted so fresh, still-relevant findings survive — this
|
|
451
|
+
* is the documented contract. Amortized O(1): dedupe via a seen-key set and
|
|
452
|
+
* eviction is one `shift` per overflow insert.
|
|
453
|
+
*/
|
|
454
|
+
addGlobal(f) {
|
|
455
|
+
const key = `${f.file}\u0000${f.line ?? 0}\u0000${f.dimension}\u0000${f.summary}`;
|
|
456
|
+
if (this.globalSeenKeys.has(key))
|
|
457
|
+
return;
|
|
458
|
+
this.globalSeenKeys.add(key);
|
|
459
|
+
this.globalFindings.push(f);
|
|
460
|
+
if (this.globalFindings.length > MAX_FINDINGS_TOTAL) {
|
|
461
|
+
const evicted = this.globalFindings.shift();
|
|
462
|
+
if (evicted) {
|
|
463
|
+
this.globalSeenKeys.delete(`${evicted.file}\u0000${evicted.line ?? 0}\u0000${evicted.dimension}\u0000${evicted.summary}`);
|
|
438
464
|
}
|
|
439
465
|
}
|
|
440
466
|
}
|
package/lib/client.js
CHANGED
|
@@ -465,6 +465,10 @@ function normalizeTranscript(manifest) {
|
|
|
465
465
|
phases: asArray(safeGet(src, "phases")).map((p) => typeof p === "string" ? p : ""),
|
|
466
466
|
round: asNum(safeGet(src, "round")),
|
|
467
467
|
maxRounds: asNum(safeGet(src, "maxRounds")),
|
|
468
|
+
// v3.5: why a FINISHED run stopped (converged / max_rounds_reached /
|
|
469
|
+
// aborted_by_validation / aborted_by_config). Required for the
|
|
470
|
+
// observatory badge to render the stop reason instead of a bare "已结束".
|
|
471
|
+
stoppedReason: src.stoppedReason === null || src.stoppedReason === void 0 ? null : asStr(safeGet(src, "stoppedReason")) || null,
|
|
468
472
|
rounds,
|
|
469
473
|
convergence: asArray(safeGet(src, "convergence")).map((n) => asCount(n)),
|
|
470
474
|
findings: asArray(safeGet(src, "findings")).map((f) => ({ ...f })),
|
package/lib/parse.js
CHANGED
|
@@ -581,6 +581,12 @@ export function normalizeTranscript(manifest) {
|
|
|
581
581
|
phases: asArray(safeGet(src, 'phases')).map((p) => (typeof p === 'string' ? p : '')),
|
|
582
582
|
round: asNum(safeGet(src, 'round')),
|
|
583
583
|
maxRounds: asNum(safeGet(src, 'maxRounds')),
|
|
584
|
+
// v3.5: why a FINISHED run stopped (converged / max_rounds_reached /
|
|
585
|
+
// aborted_by_validation / aborted_by_config). Required for the
|
|
586
|
+
// observatory badge to render the stop reason instead of a bare "已结束".
|
|
587
|
+
stoppedReason: src.stoppedReason === null || src.stoppedReason === undefined
|
|
588
|
+
? null
|
|
589
|
+
: asStr(safeGet(src, 'stoppedReason')) || null,
|
|
584
590
|
rounds,
|
|
585
591
|
convergence: asArray(safeGet(src, 'convergence')).map((n) => asCount(n)),
|
|
586
592
|
findings: asArray(safeGet(src, 'findings')).map((f) => ({ ...f })),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iterate-plugin",
|
|
3
|
-
"version": "3.5.
|
|
3
|
+
"version": "3.5.5",
|
|
4
4
|
"description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness with quality command center and experience bank (v3.3). Features: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus dry-run pure-review mode, quality gate compute/persist, writable experience bank, defense events stream (record + bilingual labels), and native command buttons — with a live quality command center (F8/F9/F10 render real session data), §8 assign-fix instruction, and task_mode indicator wired end to end.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
"0.1.5-alpha.2": "compatible",
|
|
39
39
|
"0.1.5-rc.1": "compatible",
|
|
40
40
|
"0.1.5-rc.2": "compatible",
|
|
41
|
-
"0.1.6-alpha.1": "compatible"
|
|
41
|
+
"0.1.6-alpha.1": "compatible",
|
|
42
|
+
"0.1.6-alpha.2": "compatible"
|
|
42
43
|
}
|
|
43
44
|
},
|
|
44
45
|
"client": {
|
|
@@ -76,14 +77,14 @@
|
|
|
76
77
|
},
|
|
77
78
|
"dependencies": {
|
|
78
79
|
"@deepseek-ai/cordis": "4.0.2",
|
|
79
|
-
"@deepseek-ai/dsh-tools": "0.1.6-alpha.
|
|
80
|
-
"@deepseek-ai/dsh-util-values": "0.1.6-alpha.
|
|
80
|
+
"@deepseek-ai/dsh-tools": "0.1.6-alpha.2",
|
|
81
|
+
"@deepseek-ai/dsh-util-values": "0.1.6-alpha.2",
|
|
81
82
|
"js-yaml": "4.3.2"
|
|
82
83
|
},
|
|
83
84
|
"devDependencies": {
|
|
84
|
-
"@deepseek-ai/dsh-agent": "0.1.6-alpha.
|
|
85
|
-
"@deepseek-ai/dsh-jobs": "^0.1.6-alpha.
|
|
86
|
-
"@deepseek-ai/dsh-session": "0.1.6-alpha.
|
|
85
|
+
"@deepseek-ai/dsh-agent": "0.1.6-alpha.2",
|
|
86
|
+
"@deepseek-ai/dsh-jobs": "^0.1.6-alpha.2",
|
|
87
|
+
"@deepseek-ai/dsh-session": "0.1.6-alpha.2",
|
|
87
88
|
"@types/js-yaml": "4.0.9",
|
|
88
89
|
"@types/node": "22.15.0",
|
|
89
90
|
"@types/react": "19.2.2",
|
package/src/atomic-fs.ts
CHANGED
|
@@ -54,11 +54,19 @@ function tempPathFor(filePath: string): string {
|
|
|
54
54
|
*/
|
|
55
55
|
export function writeTextAtomic(filePath: string, data: string): void {
|
|
56
56
|
const tmp = tempPathFor(filePath)
|
|
57
|
-
|
|
57
|
+
try {
|
|
58
|
+
writeFileSync(tmp, data, 'utf-8')
|
|
59
|
+
} catch (err) {
|
|
60
|
+
// The write itself failed (disk full, permission, …): the temp file may
|
|
61
|
+
// already be partially written, so remove it before propagating — a
|
|
62
|
+
// failed write must never litter the directory either.
|
|
63
|
+
cleanupTemp(tmp)
|
|
64
|
+
throw err
|
|
65
|
+
}
|
|
58
66
|
try {
|
|
59
67
|
renameSync(tmp, filePath)
|
|
60
68
|
} catch (err) {
|
|
61
|
-
// Best-effort temp cleanup so a failed
|
|
69
|
+
// Best-effort temp cleanup so a failed rename never litters the directory.
|
|
62
70
|
cleanupTemp(tmp)
|
|
63
71
|
throw err
|
|
64
72
|
}
|
|
@@ -67,11 +75,19 @@ export function writeTextAtomic(filePath: string, data: string): void {
|
|
|
67
75
|
/** Async variant of {@link writeTextAtomic} (same temp naming convention). */
|
|
68
76
|
export async function writeTextAtomicAsync(filePath: string, data: string): Promise<void> {
|
|
69
77
|
const tmp = tempPathFor(filePath)
|
|
70
|
-
|
|
78
|
+
try {
|
|
79
|
+
await writeFile(tmp, data, 'utf-8')
|
|
80
|
+
} catch (err) {
|
|
81
|
+
// The write itself failed (disk full, permission, …): the temp file may
|
|
82
|
+
// already be partially written, so remove it before propagating — a
|
|
83
|
+
// failed write must never litter the directory either.
|
|
84
|
+
await cleanupTempAsync(tmp)
|
|
85
|
+
throw err
|
|
86
|
+
}
|
|
71
87
|
try {
|
|
72
88
|
await rename(tmp, filePath)
|
|
73
89
|
} catch (err) {
|
|
74
|
-
// Best-effort temp cleanup so a failed
|
|
90
|
+
// Best-effort temp cleanup so a failed rename never litters the directory.
|
|
75
91
|
await cleanupTempAsync(tmp)
|
|
76
92
|
throw err
|
|
77
93
|
}
|
package/src/config-loader.ts
CHANGED
|
@@ -12,7 +12,10 @@ export function loadConfig(projectRoot: string): IterateConfig | null {
|
|
|
12
12
|
try {
|
|
13
13
|
const content = readFileSync(join(projectRoot, 'iterate.config.yaml'), 'utf-8')
|
|
14
14
|
const parsed = yaml.load(content) as Record<string, unknown>
|
|
15
|
-
|
|
15
|
+
// A YAML sequence root (e.g. a list of findings or a `- foo` file) must
|
|
16
|
+
// not masquerade as a config object — treating it as one would merge its
|
|
17
|
+
// indices into the config (config bomb). Reject it, not coerce it.
|
|
18
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
|
16
19
|
return parsed as unknown as IterateConfig
|
|
17
20
|
} catch {
|
|
18
21
|
return null
|
|
@@ -158,7 +161,7 @@ export function flattenCommands(
|
|
|
158
161
|
*/
|
|
159
162
|
export function validateConfig(config: unknown): string[] {
|
|
160
163
|
const errors: string[] = []
|
|
161
|
-
if (!config || typeof config !== 'object') {
|
|
164
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
162
165
|
errors.push('root')
|
|
163
166
|
return errors
|
|
164
167
|
}
|
|
@@ -206,7 +209,9 @@ export type ProjectRootResult = { ok: true; root: string } | { ok: false; reason
|
|
|
206
209
|
* the DSH runtime injects per-session env into tool sub-processes).
|
|
207
210
|
*/
|
|
208
211
|
export function resolveProjectRoot(input?: string, sessionCwd?: string): ProjectRootResult {
|
|
209
|
-
|
|
212
|
+
// A non-string input (number, array, …) from a hostile tool invocation must
|
|
213
|
+
// be treated as "no explicit path" instead of crashing on `.trim()`.
|
|
214
|
+
const raw = (typeof input === 'string' ? input : '').trim()
|
|
210
215
|
// A NUL byte can never name a real path and makes `resolve()` (and every
|
|
211
216
|
// downstream fs call) throw — treat it as unsafe input, not a throw path.
|
|
212
217
|
if (raw.includes('\0')) {
|
package/src/config-write.ts
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* config, always back up before writing, roll back on failure.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { copyFileSync, existsSync, readFileSync, rmSync } from 'node:fs'
|
|
14
|
-
import { join } from 'node:path'
|
|
13
|
+
import { copyFileSync, existsSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
|
14
|
+
import { basename, dirname, join } from 'node:path'
|
|
15
15
|
import yaml from 'js-yaml'
|
|
16
16
|
import { writeTextAtomic } from './atomic-fs.ts'
|
|
17
17
|
|
|
@@ -23,6 +23,46 @@ export function configBackupSuffix(now = new Date()): string {
|
|
|
23
23
|
return now.toISOString().replace(/[:.]/g, '-')
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** Upper bound on configurable iteration rounds (config bomb guard). */
|
|
27
|
+
export const MAX_MAX_ROUNDS = 100
|
|
28
|
+
|
|
29
|
+
/** Upper bound on `atomic.max_lines` (a single fix never needs more). */
|
|
30
|
+
export const MAX_ATOMIC_MAX_LINES = 10_000
|
|
31
|
+
|
|
32
|
+
/** Upper bound on `atomic.max_adjacent_methods`. */
|
|
33
|
+
export const MAX_MAX_ADJACENT_METHODS = 200
|
|
34
|
+
|
|
35
|
+
/** Keep at most this many timestamped config backups (older ones are removed). */
|
|
36
|
+
export const MAX_CONFIG_BACKUPS = 5
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Bound the timestamped config backups: after a fresh one is written, delete
|
|
40
|
+
* every older `config.bak-*` file beyond the newest `keep`. Best-effort — a
|
|
41
|
+
* filesystem failure here must never fail the write that just succeeded.
|
|
42
|
+
* @returns the absolute paths of the backups that were removed.
|
|
43
|
+
*/
|
|
44
|
+
export function pruneOldConfigBackups(
|
|
45
|
+
configPath: string,
|
|
46
|
+
keep = MAX_CONFIG_BACKUPS,
|
|
47
|
+
): string[] {
|
|
48
|
+
const removed: string[] = []
|
|
49
|
+
try {
|
|
50
|
+
const dir = dirname(configPath)
|
|
51
|
+
const prefix = `${basename(configPath)}.bak-`
|
|
52
|
+
const matches = existsSync(dir)
|
|
53
|
+
? readdirSync(dir).filter((f) => f.startsWith(prefix)).sort()
|
|
54
|
+
: []
|
|
55
|
+
const doomed = matches.slice(0, Math.max(0, matches.length - keep))
|
|
56
|
+
for (const f of doomed) {
|
|
57
|
+
rmSync(join(dir, f), { force: true })
|
|
58
|
+
removed.push(join(dir, f))
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// Best-effort cleanup — never surface a cleanup failure.
|
|
62
|
+
}
|
|
63
|
+
return removed
|
|
64
|
+
}
|
|
65
|
+
|
|
26
66
|
/**
|
|
27
67
|
* Validate a partial config update.
|
|
28
68
|
* Returns an array of error strings (empty when the update is valid).
|
|
@@ -45,8 +85,11 @@ export function validateConfigUpdates(updates: Record<string, unknown>): string[
|
|
|
45
85
|
}
|
|
46
86
|
}
|
|
47
87
|
if ('max_rounds' in updates) {
|
|
48
|
-
if (
|
|
49
|
-
|
|
88
|
+
if (
|
|
89
|
+
typeof updates.max_rounds !== 'number' || !Number.isInteger(updates.max_rounds) ||
|
|
90
|
+
updates.max_rounds < 1 || updates.max_rounds > MAX_MAX_ROUNDS
|
|
91
|
+
) {
|
|
92
|
+
errors.push(`updates.max_rounds must be an integer between 1 and ${MAX_MAX_ROUNDS}`)
|
|
50
93
|
}
|
|
51
94
|
}
|
|
52
95
|
if ('review' in updates) {
|
|
@@ -62,11 +105,11 @@ export function validateConfigUpdates(updates: Record<string, unknown>): string[
|
|
|
62
105
|
if (!a || typeof a !== 'object') {
|
|
63
106
|
errors.push('updates.atomic must be an object')
|
|
64
107
|
} else {
|
|
65
|
-
if (a.max_lines !== undefined && (typeof a.max_lines !== 'number' || !Number.isInteger(a.max_lines) || a.max_lines < 1)) {
|
|
66
|
-
errors.push(
|
|
108
|
+
if (a.max_lines !== undefined && (typeof a.max_lines !== 'number' || !Number.isInteger(a.max_lines) || a.max_lines < 1 || a.max_lines > MAX_ATOMIC_MAX_LINES)) {
|
|
109
|
+
errors.push(`updates.atomic.max_lines must be an integer between 1 and ${MAX_ATOMIC_MAX_LINES}`)
|
|
67
110
|
}
|
|
68
|
-
if (a.max_adjacent_methods !== undefined && (typeof a.max_adjacent_methods !== 'number' || a.max_adjacent_methods < 0)) {
|
|
69
|
-
errors.push(
|
|
111
|
+
if (a.max_adjacent_methods !== undefined && (typeof a.max_adjacent_methods !== 'number' || a.max_adjacent_methods < 0 || a.max_adjacent_methods > MAX_MAX_ADJACENT_METHODS)) {
|
|
112
|
+
errors.push(`updates.atomic.max_adjacent_methods must be a number between 0 and ${MAX_MAX_ADJACENT_METHODS}`)
|
|
70
113
|
}
|
|
71
114
|
}
|
|
72
115
|
}
|
|
@@ -168,7 +211,10 @@ export function readRawConfig(configPath: string): Record<string, unknown> {
|
|
|
168
211
|
} catch {
|
|
169
212
|
throw new Error('existing iterate.config.yaml is not a valid YAML mapping')
|
|
170
213
|
}
|
|
171
|
-
|
|
214
|
+
// A YAML sequence root (array) must not masquerade as a config object —
|
|
215
|
+
// `typeof [] === 'object'`, so the presence check alone would accept a
|
|
216
|
+
// config file that is actually a list. Writing over it would destroy data.
|
|
217
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
172
218
|
throw new Error('existing iterate.config.yaml is not a valid YAML mapping')
|
|
173
219
|
}
|
|
174
220
|
return parsed as Record<string, unknown>
|
|
@@ -207,5 +253,9 @@ export function writeConfigFile(
|
|
|
207
253
|
return { ok: false, error: `failed to write config: ${String(err)}${rollbackError}` }
|
|
208
254
|
}
|
|
209
255
|
|
|
256
|
+
// Success: bound the accumulation of timestamped backups so a long-lived
|
|
257
|
+
// project never collects an unbounded pile of config snapshots.
|
|
258
|
+
if (backupPath) pruneOldConfigBackups(configPath)
|
|
259
|
+
|
|
210
260
|
return { ok: true, backupPath }
|
|
211
261
|
}
|
package/src/evidence.ts
CHANGED
|
@@ -125,8 +125,36 @@ export function verifyFinding(
|
|
|
125
125
|
input: Locatable,
|
|
126
126
|
opts: { readSet?: Set<string> } = {},
|
|
127
127
|
): FindingEvidence {
|
|
128
|
-
|
|
128
|
+
// A null element / non-object finding (hostile or model-authored JSON) must
|
|
129
|
+
// be reported as poisoned evidence, never crash `path.resolve` downstream.
|
|
130
|
+
if (!input || typeof input !== 'object') {
|
|
131
|
+
return {
|
|
132
|
+
file: '',
|
|
133
|
+
line: null,
|
|
134
|
+
lineTotal: null,
|
|
135
|
+
resolvedPath: null,
|
|
136
|
+
verified: false,
|
|
137
|
+
error: 'file_not_found',
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// A non-string `file` (number, object, …) would make `resolve` throw
|
|
141
|
+
// ERR_INVALID_ARG_TYPE — coerce to "" so it fails closed as not-found.
|
|
142
|
+
const relFile = typeof input.file === 'string' ? input.file : ''
|
|
129
143
|
const line = typeof input.line === 'number' ? input.line : null
|
|
144
|
+
|
|
145
|
+
// "" resolves to the project ROOT (resolve(root, '') === root), which then
|
|
146
|
+
// trips the not-a-regular-file branch as `line_out_of_range` — but there is
|
|
147
|
+
// no file reference at all. Report it as `file_not_found` upfront.
|
|
148
|
+
if (relFile === '') {
|
|
149
|
+
return {
|
|
150
|
+
file: '',
|
|
151
|
+
line,
|
|
152
|
+
lineTotal: null,
|
|
153
|
+
resolvedPath: null,
|
|
154
|
+
verified: false,
|
|
155
|
+
error: 'file_not_found',
|
|
156
|
+
}
|
|
157
|
+
}
|
|
130
158
|
const resolved = resolveWithin(root, relFile)
|
|
131
159
|
|
|
132
160
|
if (resolved === null || !existsSync(resolved)) {
|