iterate-plugin 3.4.1 → 3.5.1
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 +4 -4
- package/README.zh-CN.md +4 -4
- package/dist/atomic-fs.js +77 -0
- package/dist/config-write.js +3 -2
- package/dist/live.js +10 -4
- package/dist/skill-prompt.js +2 -2
- package/dist/tools/checkpoint.js +31 -11
- package/dist/tools/config.js +14 -0
- package/dist/tools/context.js +3 -0
- package/dist/tools/decision-log.js +25 -10
- package/dist/tools/defense-events.js +3 -0
- package/dist/tools/defense-store.js +6 -2
- package/dist/tools/experience-bank.js +42 -3
- package/dist/tools/experience-store.js +30 -2
- package/dist/tools/fix.js +40 -4
- package/dist/tools/history.js +3 -0
- package/dist/tools/prune.js +50 -6
- package/dist/tools/quality-gate.js +24 -4
- package/dist/tools/quality-store.js +20 -1
- package/dist/tools/transcript.js +38 -5
- package/dist/tools/triage.js +6 -2
- package/dist/tools/validate.js +32 -3
- package/package.json +5 -2
- package/src/atomic-fs.ts +83 -0
- package/src/config-write.ts +3 -2
- package/src/live.ts +10 -4
- package/src/skill-prompt.ts +2 -2
- package/src/tools/checkpoint.ts +37 -11
- package/src/tools/config.ts +14 -0
- package/src/tools/context.ts +3 -0
- package/src/tools/decision-log.ts +35 -10
- package/src/tools/defense-events.ts +3 -0
- package/src/tools/defense-store.ts +6 -2
- package/src/tools/experience-bank.ts +43 -3
- package/src/tools/experience-store.ts +32 -2
- package/src/tools/fix.ts +39 -4
- package/src/tools/history.ts +3 -0
- package/src/tools/prune.ts +51 -6
- package/src/tools/quality-gate.ts +25 -4
- package/src/tools/quality-store.ts +21 -1
- package/src/tools/transcript.ts +39 -5
- package/src/tools/triage.ts +6 -2
- package/src/tools/validate.ts +32 -3
- package/src/types.ts +2 -0
package/dist/tools/fix.js
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
* - Backups are written before any write, so a failure never destroys data.
|
|
17
17
|
* - Atomicity is enforced against `config.atomic.max_lines` unless `force`.
|
|
18
18
|
*/
|
|
19
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync
|
|
19
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
20
|
+
import { writeJsonAtomic, writeTextAtomic } from "../atomic-fs.js";
|
|
20
21
|
import { join, sep } from 'node:path';
|
|
21
22
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
22
23
|
import { loadEffectiveConfig, resolveProjectRootForExec } from "../config-loader.js";
|
|
@@ -24,6 +25,7 @@ import { runWithJob } from "../jobs.js";
|
|
|
24
25
|
import { countTouchedMethods } from "../method-scope.js";
|
|
25
26
|
import { fixBackupPath, fixRegistryPath, fixesDir } from "../paths.js";
|
|
26
27
|
import { appendDecisionEntry } from "./decision-log.js";
|
|
28
|
+
import { markFixRolledBackInTranscript } from "./transcript.js";
|
|
27
29
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
28
30
|
/** Upper bound for a single fix `content` payload (characters). */
|
|
29
31
|
export const MAX_FIX_CONTENT_CHARS = 1_000_000;
|
|
@@ -276,6 +278,20 @@ function readProjectFile(projectRoot, file) {
|
|
|
276
278
|
export function registerFixTool(ctx) {
|
|
277
279
|
ctx.tools.register(defineTool({
|
|
278
280
|
name: 'iterate_fix',
|
|
281
|
+
// Pending-call card: surface the file about to change as an inline diff.
|
|
282
|
+
// oldText is null — a call-time presenter has no access to the file's
|
|
283
|
+
// prior content (an overwrite, per the presentation contract).
|
|
284
|
+
presentCall: (args) => {
|
|
285
|
+
const a = args;
|
|
286
|
+
if (typeof a.file !== 'string' || typeof a.content !== 'string')
|
|
287
|
+
return undefined;
|
|
288
|
+
return {
|
|
289
|
+
card: 'diff',
|
|
290
|
+
title: `Fix ${a.file}`,
|
|
291
|
+
diffs: [{ path: a.file, oldText: null, newText: a.content }],
|
|
292
|
+
locations: [{ path: a.file }],
|
|
293
|
+
};
|
|
294
|
+
},
|
|
279
295
|
description: 'Apply ONE atomic fix to a file. Pass the target relative `file`, the finding that motivated ' +
|
|
280
296
|
'the fix, the NEW full `content` of that file (after your edit), and the current `round`. ' +
|
|
281
297
|
'The tool backs up the original, enforces the atomic `max_lines` and `max_adjacent_methods` thresholds (unless `force`), ' +
|
|
@@ -445,7 +461,7 @@ export function registerFixTool(ctx) {
|
|
|
445
461
|
return { ok: false, error: `failed to create backup: ${String(err)}` };
|
|
446
462
|
}
|
|
447
463
|
try {
|
|
448
|
-
|
|
464
|
+
writeTextAtomic(target.resolved, args.content);
|
|
449
465
|
}
|
|
450
466
|
catch (err) {
|
|
451
467
|
return { ok: false, error: `failed to write file: ${String(err)}` };
|
|
@@ -463,7 +479,7 @@ export function registerFixTool(ctx) {
|
|
|
463
479
|
};
|
|
464
480
|
const nextRegistry = upsertRecord(registry, record);
|
|
465
481
|
try {
|
|
466
|
-
|
|
482
|
+
writeJsonAtomic(fixRegistryPath(projectRoot), nextRegistry);
|
|
467
483
|
}
|
|
468
484
|
catch (err) {
|
|
469
485
|
// Registry write failed → the file was already modified but no record
|
|
@@ -511,6 +527,9 @@ export function registerFixTool(ctx) {
|
|
|
511
527
|
export function registerDiffTool(ctx) {
|
|
512
528
|
ctx.tools.register(defineTool({
|
|
513
529
|
name: 'iterate_diff',
|
|
530
|
+
// Read-only (never writes project files or plugin state) → safe to join
|
|
531
|
+
// a parallel dispatch group alongside other read-only sibling calls.
|
|
532
|
+
isConcurrencySafe: () => true,
|
|
514
533
|
description: 'Show the changes made by iterate fixes. With `file`, returns the unified diff of the current ' +
|
|
515
534
|
'file content vs its original (first backup). Without `file`, returns a summary of every fixed file.',
|
|
516
535
|
parameters: {
|
|
@@ -612,6 +631,18 @@ export function registerDiffTool(ctx) {
|
|
|
612
631
|
export function registerRollbackTool(ctx) {
|
|
613
632
|
ctx.tools.register(defineTool({
|
|
614
633
|
name: 'iterate_rollback',
|
|
634
|
+
// Pending-call card: which fix is about to be reverted.
|
|
635
|
+
presentCall: (args) => {
|
|
636
|
+
const a = args;
|
|
637
|
+
if (typeof a.id !== 'string' || a.id.length === 0)
|
|
638
|
+
return undefined;
|
|
639
|
+
return {
|
|
640
|
+
card: 'generic',
|
|
641
|
+
title: `Rollback fix ${a.id}`,
|
|
642
|
+
kind: 'edit',
|
|
643
|
+
rawInput: { id: a.id },
|
|
644
|
+
};
|
|
645
|
+
},
|
|
615
646
|
description: 'Revert a previously applied fix. Pass the fix `id` (returned by iterate_fix). ' +
|
|
616
647
|
'The file is restored from the fix backup, the fix is removed from the registry, ' +
|
|
617
648
|
'and a `revert` entry is appended to the decision log. Use when a round\'s validation fails.',
|
|
@@ -667,7 +698,7 @@ export function registerRollbackTool(ctx) {
|
|
|
667
698
|
}
|
|
668
699
|
const nextRegistry = removeRecord(registry, id);
|
|
669
700
|
try {
|
|
670
|
-
|
|
701
|
+
writeJsonAtomic(fixRegistryPath(projectRoot), nextRegistry);
|
|
671
702
|
}
|
|
672
703
|
catch (err) {
|
|
673
704
|
return { ok: false, error: `failed to update fix registry: ${String(err)}` };
|
|
@@ -678,6 +709,11 @@ export function registerRollbackTool(ctx) {
|
|
|
678
709
|
type: 'revert',
|
|
679
710
|
data: { id, file: record.finding.file, revertedDiff: record.diffSummary },
|
|
680
711
|
});
|
|
712
|
+
// Mirror the reversal into the persisted observatory transcript (F4) so
|
|
713
|
+
// the client no longer shows a rolled-back fix as "成功". Best-effort:
|
|
714
|
+
// a missing/corrupt transcript is left untouched and never breaks the
|
|
715
|
+
// rollback flow.
|
|
716
|
+
await markFixRolledBackInTranscript(projectRoot, id);
|
|
681
717
|
return { ok: true, id, file: record.finding.file };
|
|
682
718
|
},
|
|
683
719
|
}));
|
package/dist/tools/history.js
CHANGED
|
@@ -64,6 +64,9 @@ export function summarizeFixRegistry(registry) {
|
|
|
64
64
|
export function registerHistoryTool(ctx) {
|
|
65
65
|
ctx.tools.register(defineTool({
|
|
66
66
|
name: 'iterate_history',
|
|
67
|
+
// Read-only audit view over on-disk state → safe to join a parallel
|
|
68
|
+
// dispatch group alongside other read-only sibling calls.
|
|
69
|
+
isConcurrencySafe: () => true,
|
|
67
70
|
description: 'Read the iteration history: decision-log entries (optionally filtered by entry `type`, `since` ' +
|
|
68
71
|
'timestamp, and a `limit`) plus a summary of the fix registry (per-round fixed/failed counts). ' +
|
|
69
72
|
'Read-only — use it to review what the run did, audit a log, or inspect fixes.',
|
package/dist/tools/prune.js
CHANGED
|
@@ -11,16 +11,19 @@
|
|
|
11
11
|
* - Fix backups left over from old rounds (backups whose fix-id no longer
|
|
12
12
|
* appears in the registry).
|
|
13
13
|
* - Empty fix rounds (rounds with 0 records).
|
|
14
|
+
* - Stray temp files left behind by a crashed atomic write (see
|
|
15
|
+
* `isPrunableTemp` for the recognized naming conventions).
|
|
14
16
|
*
|
|
15
17
|
* Security model:
|
|
16
18
|
* - Only operates under the resolved project `.iterate/` directory.
|
|
17
19
|
* - dryRun=true by default — the caller must explicitly opt into deletion.
|
|
18
20
|
* - Each deletion is logged to the decision log (when not dry-run).
|
|
19
21
|
*/
|
|
20
|
-
import { existsSync, readdirSync,
|
|
22
|
+
import { existsSync, readdirSync, rmSync, unlinkSync } from 'node:fs';
|
|
21
23
|
import { join } from 'node:path';
|
|
22
24
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
23
25
|
import { resolveProjectRootForExec } from "../config-loader.js";
|
|
26
|
+
import { writeTextAtomic, writeJsonAtomic } from "../atomic-fs.js";
|
|
24
27
|
import { readDecisionEntries, appendDecisionEntry } from "./decision-log.js";
|
|
25
28
|
import { readRegistry, recomputeRoundCounts } from "./fix.js";
|
|
26
29
|
import { iterateDir, fixesDir, checkpointPath, fixRegistryPath } from "../paths.js";
|
|
@@ -41,6 +44,28 @@ export function cutoffTimestamp(retainDays) {
|
|
|
41
44
|
d.setDate(d.getDate() - retainDays);
|
|
42
45
|
return d.toISOString();
|
|
43
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Whether a file name inside `.iterate/` is a prunable leftover temp file.
|
|
49
|
+
*
|
|
50
|
+
* Recognized conventions (all written to the SAME directory as their target,
|
|
51
|
+
* so they can never be confused with real state files):
|
|
52
|
+
* - `.<name>.tmp-<pid>-<rand>` — current atomic-fs.ts temp prefix;
|
|
53
|
+
* - `.tmp-<pid>-<rand>` — legacy temp prefix (older plugin builds);
|
|
54
|
+
* - `<name>.tmp` — legacy transcript.ts / live.ts temp suffix.
|
|
55
|
+
*
|
|
56
|
+
* Pure and exported for unit tests. A live writer's temp file is only visible
|
|
57
|
+
* to it (unique name), so deleting any match is always safe.
|
|
58
|
+
*/
|
|
59
|
+
export function isPrunableTemp(name) {
|
|
60
|
+
if (typeof name !== 'string' || name.length === 0 || name === '.' || name === '..')
|
|
61
|
+
return false;
|
|
62
|
+
if (name.startsWith('.tmp-'))
|
|
63
|
+
return true;
|
|
64
|
+
if (name.endsWith('.tmp'))
|
|
65
|
+
return true;
|
|
66
|
+
// .<basename>.tmp-<pid>-<rand>: starts with a dot and contains ".tmp-" after it.
|
|
67
|
+
return name.startsWith('.') && name.includes('.tmp-');
|
|
68
|
+
}
|
|
44
69
|
/**
|
|
45
70
|
* Inspect the runtime state and report what would be pruned.
|
|
46
71
|
* Pure (no deletions). Returns a structured report.
|
|
@@ -79,10 +104,17 @@ export function inspectPrune(projectRoot, retainDays) {
|
|
|
79
104
|
const emptyRounds = registry.rounds
|
|
80
105
|
.filter((r) => r.records.length === 0)
|
|
81
106
|
.map((r) => r.round);
|
|
107
|
+
// 5. Stray atomic-write temp files left behind by a crashed writer. The
|
|
108
|
+
// unique prefix makes them recognizable and safe to delete — no live writer
|
|
109
|
+
// ever reads another writer's temp file.
|
|
110
|
+
const staleTemps = existsSync(iterateDir(projectRoot))
|
|
111
|
+
? readdirSync(iterateDir(projectRoot)).filter((f) => isPrunableTemp(f)).sort()
|
|
112
|
+
: [];
|
|
82
113
|
return {
|
|
83
114
|
oldLogEntries,
|
|
84
115
|
hasCheckpoint,
|
|
85
116
|
staleBackups,
|
|
117
|
+
staleTemps,
|
|
86
118
|
emptyRounds,
|
|
87
119
|
totalLogEntries: entries.length,
|
|
88
120
|
registryRounds: registry.rounds.length,
|
|
@@ -98,6 +130,7 @@ export function executePrune(projectRoot, retainDays, report) {
|
|
|
98
130
|
deletedLogEntries: 0,
|
|
99
131
|
deletedCheckpoint: false,
|
|
100
132
|
deletedBackups: [],
|
|
133
|
+
deletedTemps: [],
|
|
101
134
|
trimmedEmptyRounds: 0,
|
|
102
135
|
errors: [],
|
|
103
136
|
};
|
|
@@ -109,9 +142,7 @@ export function executePrune(projectRoot, retainDays, report) {
|
|
|
109
142
|
result.deletedLogEntries = entries.length - kept.length;
|
|
110
143
|
if (result.deletedLogEntries > 0) {
|
|
111
144
|
const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl');
|
|
112
|
-
|
|
113
|
-
writeFileSync(tmpPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf-8');
|
|
114
|
-
renameSync(tmpPath, logPath);
|
|
145
|
+
writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n');
|
|
115
146
|
}
|
|
116
147
|
}
|
|
117
148
|
catch (err) {
|
|
@@ -151,13 +182,23 @@ export function executePrune(projectRoot, retainDays, report) {
|
|
|
151
182
|
rounds: registry.rounds.filter((r) => !emptyRoundNos.has(r.round) || (r.records?.length ?? 0) > 0),
|
|
152
183
|
};
|
|
153
184
|
registry = recomputeRoundCounts(registry);
|
|
154
|
-
|
|
185
|
+
writeJsonAtomic(fixRegistryPath(projectRoot), registry);
|
|
155
186
|
result.trimmedEmptyRounds = report.emptyRounds.length;
|
|
156
187
|
}
|
|
157
188
|
catch (err) {
|
|
158
189
|
result.errors.push(`failed to trim empty rounds: ${String(err)}`);
|
|
159
190
|
}
|
|
160
191
|
}
|
|
192
|
+
// 5. Remove stray atomic-write temp files.
|
|
193
|
+
for (const tmp of report.staleTemps) {
|
|
194
|
+
try {
|
|
195
|
+
rmSync(join(iterateDir(projectRoot), tmp), { force: true });
|
|
196
|
+
result.deletedTemps.push(tmp);
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
result.errors.push(`failed to delete temp file ${tmp}: ${String(err)}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
161
202
|
return result;
|
|
162
203
|
}
|
|
163
204
|
/**
|
|
@@ -170,7 +211,8 @@ export function registerPruneTool(ctx) {
|
|
|
170
211
|
name: 'iterate_prune',
|
|
171
212
|
description: 'Inspect or clean up old iterate runtime artifacts (.iterate/). ' +
|
|
172
213
|
'Defaults to dry-run (report-only, no deletion). Pass `dryRun: false` to actually prune. ' +
|
|
173
|
-
'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds
|
|
214
|
+
'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds, ' +
|
|
215
|
+
'and stray temp files left by crashed atomic writes. ' +
|
|
174
216
|
'Each deletion is logged to the decision log.',
|
|
175
217
|
parameters: {
|
|
176
218
|
dryRun: {
|
|
@@ -210,6 +252,7 @@ export function registerPruneTool(ctx) {
|
|
|
210
252
|
` Decision-log entries to remove: ${report?.oldLogEntries ?? '?'} (of ${report?.totalLogEntries ?? '?'})`,
|
|
211
253
|
` Checkpoint to delete: ${report?.hasCheckpoint ? 'yes' : 'none'}`,
|
|
212
254
|
` Stale backups to delete: ${report?.staleBackups?.length ?? 0}`,
|
|
255
|
+
` Stray temp files to delete: ${report?.staleTemps?.length ?? 0}`,
|
|
213
256
|
` Empty rounds to trim: ${report?.emptyRounds?.length ?? 0}`,
|
|
214
257
|
'',
|
|
215
258
|
'Pass dryRun:false to execute the prune.',
|
|
@@ -221,6 +264,7 @@ export function registerPruneTool(ctx) {
|
|
|
221
264
|
` Deleted ${result?.deletedLogEntries ?? 0} old log entries.`,
|
|
222
265
|
` Checkpoint deleted: ${result?.deletedCheckpoint ? 'yes' : 'no'}`,
|
|
223
266
|
` Deleted ${result?.deletedBackups?.length ?? 0} stale backups.`,
|
|
267
|
+
` Deleted ${result?.deletedTemps?.length ?? 0} stray temp files.`,
|
|
224
268
|
` Trimmed ${result?.trimmedEmptyRounds ?? 0} empty rounds.`,
|
|
225
269
|
];
|
|
226
270
|
const errs = result?.errors ?? [];
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
10
10
|
import { resolveProjectRootForExec } from "../config-loader.js";
|
|
11
|
-
import { readQualityGate, writeQualityGate, computeQualityGate } from "./quality-store.js";
|
|
11
|
+
import { readQualityGate, writeQualityGate, computeQualityGate, clearQualityGate } from "./quality-store.js";
|
|
12
12
|
/** Validate a single finding object; returns true when well-formed. */
|
|
13
13
|
function isValidFinding(raw) {
|
|
14
14
|
if (!raw || typeof raw !== 'object')
|
|
@@ -59,16 +59,21 @@ function sanitizeRoundSeries(raw) {
|
|
|
59
59
|
export function registerQualityGateTool(ctx) {
|
|
60
60
|
ctx.tools.register(defineTool({
|
|
61
61
|
name: 'iterate_quality_gate',
|
|
62
|
+
// `read` never writes; `compute` persists the snapshot → only read
|
|
63
|
+
// joins a parallel dispatch group.
|
|
64
|
+
isConcurrencySafe: (args) => args.operation === 'read',
|
|
62
65
|
description: 'Query or write the quality gate status: dimension convergence rates, verification pass rates, ' +
|
|
63
66
|
'and overall PASS/FAIL status. ' +
|
|
64
67
|
'Operation "read" (default) returns the persisted machine-readable quality certificate. ' +
|
|
65
68
|
'Operation "compute" computes a fresh snapshot from this round\'s findings/validation results, ' +
|
|
66
|
-
'persists it to .iterate/quality-gate.json, and returns it.'
|
|
69
|
+
'persists it to .iterate/quality-gate.json, and returns it. ' +
|
|
70
|
+
'Operation "clear" removes the persisted certificate (use it to reset a stale gate before ' +
|
|
71
|
+
'starting a fresh iteration).',
|
|
67
72
|
parameters: {
|
|
68
73
|
operation: {
|
|
69
74
|
type: 'string',
|
|
70
|
-
description: 'Operation: read (load persisted certificate)
|
|
71
|
-
enum: ['read', 'compute'],
|
|
75
|
+
description: 'Operation: read (load persisted certificate), compute (recompute + persist), or clear (remove the persisted certificate). Default: read.',
|
|
76
|
+
enum: ['read', 'compute', 'clear'],
|
|
72
77
|
},
|
|
73
78
|
dimensions: {
|
|
74
79
|
type: 'array',
|
|
@@ -134,6 +139,9 @@ export function registerQualityGateTool(ctx) {
|
|
|
134
139
|
if (operation === 'compute') {
|
|
135
140
|
lines.push('', 'Quality gate snapshot computed and persisted.');
|
|
136
141
|
}
|
|
142
|
+
if (operation === 'clear') {
|
|
143
|
+
lines.push('', 'Quality gate certificate cleared — the gate will be recomputed on the next iteration.');
|
|
144
|
+
}
|
|
137
145
|
return [{ type: 'text', text: lines.join('\n') }];
|
|
138
146
|
},
|
|
139
147
|
},
|
|
@@ -143,6 +151,18 @@ export function registerQualityGateTool(ctx) {
|
|
|
143
151
|
return { ok: false, kind: 'quality_gate', error: resolved.reason };
|
|
144
152
|
const projectRoot = resolved.root;
|
|
145
153
|
const operation = typeof args.operation === 'string' ? args.operation : 'read';
|
|
154
|
+
if (operation === 'clear') {
|
|
155
|
+
const result = clearQualityGate(projectRoot);
|
|
156
|
+
if (!result.ok) {
|
|
157
|
+
return { ok: false, kind: 'quality_gate', operation: 'clear', error: result.error };
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
ok: true,
|
|
161
|
+
kind: 'quality_gate',
|
|
162
|
+
operation: 'clear',
|
|
163
|
+
snapshot: readQualityGate(projectRoot),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
146
166
|
if (operation === 'compute') {
|
|
147
167
|
const dimensions = Array.isArray(args.dimensions)
|
|
148
168
|
? args.dimensions.filter((d) => typeof d === 'string' && d.length > 0)
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import * as fs from 'node:fs';
|
|
9
9
|
import * as path from 'node:path';
|
|
10
|
+
import { writeJsonAtomic } from "../atomic-fs.js";
|
|
10
11
|
const QUALITY_GATE_FILE = 'quality-gate.json';
|
|
11
12
|
/** Default empty quality gate snapshot. */
|
|
12
13
|
function emptySnapshot() {
|
|
@@ -96,13 +97,31 @@ export function writeQualityGate(projectRoot, snapshot) {
|
|
|
96
97
|
if (!fs.existsSync(dirPath)) {
|
|
97
98
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
98
99
|
}
|
|
99
|
-
|
|
100
|
+
writeJsonAtomic(filePath, snapshot);
|
|
100
101
|
}
|
|
101
102
|
catch (err) {
|
|
102
103
|
return { ok: false, error: `unable to write ${filePath}: ${String(err)}` };
|
|
103
104
|
}
|
|
104
105
|
return { ok: true };
|
|
105
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Clear the persisted quality gate certificate (`.iterate/quality-gate.json`).
|
|
109
|
+
* Returns whether a file existed and was removed, or a structured error when
|
|
110
|
+
* the removal fails — a stale FAIL certificate must never silently persist.
|
|
111
|
+
*/
|
|
112
|
+
export function clearQualityGate(projectRoot) {
|
|
113
|
+
const filePath = path.join(projectRoot, '.iterate', QUALITY_GATE_FILE);
|
|
114
|
+
const existed = fs.existsSync(filePath);
|
|
115
|
+
if (!existed)
|
|
116
|
+
return { ok: true, existed: false };
|
|
117
|
+
try {
|
|
118
|
+
fs.rmSync(filePath, { force: true });
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
return { ok: false, error: `unable to remove ${filePath}: ${String(err)}` };
|
|
122
|
+
}
|
|
123
|
+
return { ok: true, existed: true };
|
|
124
|
+
}
|
|
106
125
|
/**
|
|
107
126
|
* Compute the convergence rate for a dimension.
|
|
108
127
|
*
|
package/dist/tools/transcript.js
CHANGED
|
@@ -18,8 +18,9 @@
|
|
|
18
18
|
* tmp+rename so a crashed writer never leaves a corrupt manifest.
|
|
19
19
|
*/
|
|
20
20
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
21
|
-
import { mkdir, readFile
|
|
21
|
+
import { mkdir, readFile } from 'node:fs/promises';
|
|
22
22
|
import { existsSync } from 'node:fs';
|
|
23
|
+
import { writeTextAtomicAsync } from "../atomic-fs.js";
|
|
23
24
|
import { dirname } from 'node:path';
|
|
24
25
|
import { loadEffectiveConfig, resolveProjectRootForExec, } from "../config-loader.js";
|
|
25
26
|
import { transcriptPath } from "../paths.js";
|
|
@@ -96,6 +97,9 @@ function normalizeFix(input) {
|
|
|
96
97
|
export function registerTranscriptTool(ctx) {
|
|
97
98
|
ctx.tools.register(defineTool({
|
|
98
99
|
name: 'iterate_transcript',
|
|
100
|
+
// `read` never writes; `capture`/`nudge` persist transcript state → only
|
|
101
|
+
// read joins a parallel dispatch group.
|
|
102
|
+
isConcurrencySafe: (args) => args.operation === 'read',
|
|
99
103
|
description: 'Runtime-observatory transcript for the iterate workflow. ' +
|
|
100
104
|
'`read` returns the current persisted transcript manifest (per-reviewer threads, ' +
|
|
101
105
|
'convergence series, findings, fixes, checkpoint, timeline, and any steering nudge ' +
|
|
@@ -322,10 +326,39 @@ function rehydrateBuilder(manifest, approval) {
|
|
|
322
326
|
builder.finish();
|
|
323
327
|
return builder;
|
|
324
328
|
}
|
|
325
|
-
/** Atomically persist a manifest (
|
|
329
|
+
/** Atomically persist a manifest (unique temp + rename) under `.iterate/`. */
|
|
326
330
|
async function persist(file, manifest) {
|
|
327
331
|
await mkdir(dirname(file), { recursive: true });
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
332
|
+
await writeTextAtomicAsync(file, JSON.stringify(manifest, null, 2));
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Mark one applied fix as rolled back in the PERSISTED transcript, so the
|
|
336
|
+
* client observatory (F4 fix/rollback tab) reflects the reversal instead of
|
|
337
|
+
* showing the fix as still "成功" after a rollback.
|
|
338
|
+
*
|
|
339
|
+
* Best-effort and fail-safe: a missing/corrupt transcript is ignored, and a
|
|
340
|
+
* persistence failure never throws into the caller (rollback itself is the
|
|
341
|
+
* primary operation; the transcript is a secondary UI projection).
|
|
342
|
+
*
|
|
343
|
+
* @returns true when a transcript existed and was updated.
|
|
344
|
+
*/
|
|
345
|
+
export async function markFixRolledBackInTranscript(projectRoot, id) {
|
|
346
|
+
const file = transcriptPath(projectRoot);
|
|
347
|
+
if (!existsSync(file))
|
|
348
|
+
return false;
|
|
349
|
+
try {
|
|
350
|
+
const parsed = JSON.parse(await readFile(file, 'utf-8'));
|
|
351
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.fixes))
|
|
352
|
+
return false;
|
|
353
|
+
const { config } = loadEffectiveConfig(projectRoot);
|
|
354
|
+
const approval = config.observatory?.approval ?? 'ask';
|
|
355
|
+
const builder = rehydrateBuilder(parsed, approval);
|
|
356
|
+
builder.markFixRolledBack(id);
|
|
357
|
+
await persist(file, builder.serialize());
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
// Never let the secondary transcript write break the rollback flow.
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
331
364
|
}
|
package/dist/tools/triage.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { copyFileSync, existsSync, readFileSync, rmSync
|
|
1
|
+
import { copyFileSync, existsSync, readFileSync, rmSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
4
4
|
import yaml from 'js-yaml';
|
|
5
5
|
import { resolveProjectRootForExec } from "../config-loader.js";
|
|
6
|
+
import { writeTextAtomic } from "../atomic-fs.js";
|
|
6
7
|
const CONFIG_FILE = 'iterate.config.yaml';
|
|
7
8
|
/** Personalization key that holds the known-intentional list. */
|
|
8
9
|
const PERSONALIZATION_KEY = 'personalization';
|
|
@@ -191,7 +192,7 @@ function applyEntries(projectRoot, incoming) {
|
|
|
191
192
|
}
|
|
192
193
|
const yamlText = yaml.dump(nextConfig, { noRefs: true });
|
|
193
194
|
try {
|
|
194
|
-
|
|
195
|
+
writeTextAtomic(configPath, yamlText);
|
|
195
196
|
}
|
|
196
197
|
catch (err) {
|
|
197
198
|
// Rollback: restore the backup, or REMOVE the file we just created when
|
|
@@ -231,6 +232,9 @@ function applyEntries(projectRoot, incoming) {
|
|
|
231
232
|
export function registerTriageTool(ctx) {
|
|
232
233
|
ctx.tools.register(defineTool({
|
|
233
234
|
name: 'iterate_triage',
|
|
235
|
+
// `list` is a pure config read; `apply` rewrites iterate.config.yaml →
|
|
236
|
+
// only list joins a parallel dispatch group.
|
|
237
|
+
isConcurrencySafe: (args) => args.operation === 'list',
|
|
234
238
|
description: 'Manage `personalization.known_intentional` entries in iterate.config.yaml. ' +
|
|
235
239
|
'Use `apply` to write back triage verdicts (entries where the reviewer said "known intentional") so ' +
|
|
236
240
|
'future review rounds filter them out. Entries are deduped by file|dimension|line and the config is ' +
|
package/dist/tools/validate.js
CHANGED
|
@@ -19,8 +19,14 @@ export function clampTimeout(ms) {
|
|
|
19
19
|
/**
|
|
20
20
|
* Run a single shell command with timeout and return structured results.
|
|
21
21
|
* Pure function (no side effects beyond the exec call).
|
|
22
|
+
*
|
|
23
|
+
* `signal` (the caller's `exec.signal` per the dsh tools contract) is forwarded
|
|
24
|
+
* to the child process so a cancelled tool call kills the running command
|
|
25
|
+
* instead of pinning the dispatch open until the timeout elapses. The result
|
|
26
|
+
* distinguishes "cancelled by the caller" (`canceled: true`) from "ran past
|
|
27
|
+
* `timeoutMs`" (`timedOut: true`).
|
|
22
28
|
*/
|
|
23
|
-
async function runCommand(command, cwd, timeoutMs) {
|
|
29
|
+
export async function runCommand(command, cwd, timeoutMs, signal) {
|
|
24
30
|
const start = performance.now();
|
|
25
31
|
return new Promise((resolve) => {
|
|
26
32
|
exec(command, {
|
|
@@ -28,18 +34,23 @@ async function runCommand(command, cwd, timeoutMs) {
|
|
|
28
34
|
timeout: timeoutMs,
|
|
29
35
|
maxBuffer: 10 * 1024 * 1024, // 10 MB
|
|
30
36
|
env: { ...process.env, PAGER: 'cat' },
|
|
37
|
+
signal,
|
|
31
38
|
}, (error, stdout, stderr) => {
|
|
32
39
|
const durationMs = Math.round(performance.now() - start);
|
|
33
40
|
// error.code is the exit code when the command ran; when the binary
|
|
34
41
|
// cannot be spawned Node sets error.code to a STRING ('ENOENT' etc).
|
|
35
42
|
// Coerce to a number so the integer output schema is never violated.
|
|
36
43
|
const exitCode = typeof error?.code === 'number' ? error.code : (error ? 1 : 0);
|
|
44
|
+
const canceled = signal?.aborted === true;
|
|
37
45
|
resolve({
|
|
38
46
|
command,
|
|
39
47
|
exitCode,
|
|
40
48
|
stdout: stdout ?? '',
|
|
41
49
|
stderr: stderr ?? '',
|
|
42
|
-
|
|
50
|
+
// A caller cancellation also kills the child (killed === true), but
|
|
51
|
+
// that is not a timeout — only report timedOut for the deadline path.
|
|
52
|
+
timedOut: error?.killed === true && !canceled,
|
|
53
|
+
canceled,
|
|
43
54
|
durationMs,
|
|
44
55
|
});
|
|
45
56
|
});
|
|
@@ -53,6 +64,19 @@ async function runCommand(command, cwd, timeoutMs) {
|
|
|
53
64
|
export function registerValidateTool(ctx) {
|
|
54
65
|
ctx.tools.register(defineTool({
|
|
55
66
|
name: 'iterate_validate',
|
|
67
|
+
// Pending-call card: render the pending validation as a terminal card.
|
|
68
|
+
// Pure — derived from args only; no `cwd` (the UI bridge resolves the
|
|
69
|
+
// relative command against the session workspace).
|
|
70
|
+
presentCall: (args) => {
|
|
71
|
+
const a = args;
|
|
72
|
+
if (typeof a.command !== 'string' || a.command.length === 0)
|
|
73
|
+
return undefined;
|
|
74
|
+
return {
|
|
75
|
+
card: 'terminal',
|
|
76
|
+
title: a.command,
|
|
77
|
+
description: 'Run a preconfigured iterate validation command (exact match on validation.commands).',
|
|
78
|
+
};
|
|
79
|
+
},
|
|
56
80
|
description: 'Run a validation command that is PRECONFIGURED in iterate.config.yaml `validation.commands`. ' +
|
|
57
81
|
'The command must exactly match one of the configured commands (they are the only ones the user trusts). ' +
|
|
58
82
|
'Returns exit code, stdout, stderr, and duration. ' +
|
|
@@ -83,6 +107,7 @@ export function registerValidateTool(ctx) {
|
|
|
83
107
|
stdout: { type: 'string', required: true },
|
|
84
108
|
stderr: { type: 'string', required: true },
|
|
85
109
|
timedOut: { type: 'boolean', required: true },
|
|
110
|
+
canceled: { type: 'boolean', required: true },
|
|
86
111
|
durationMs: { type: 'integer', required: true },
|
|
87
112
|
rejectReason: { type: 'string' },
|
|
88
113
|
},
|
|
@@ -96,6 +121,7 @@ export function registerValidateTool(ctx) {
|
|
|
96
121
|
`Exit code: ${value.exitCode}`,
|
|
97
122
|
`Duration: ${value.durationMs}ms`,
|
|
98
123
|
value.timedOut ? '⚠ Timed out' : '',
|
|
124
|
+
value.canceled ? '⚠ Cancelled before completion' : '',
|
|
99
125
|
'',
|
|
100
126
|
value.stdout ? `[stdout]\n${value.stdout}` : '',
|
|
101
127
|
value.stderr ? `[stderr]\n${value.stderr}` : '',
|
|
@@ -116,6 +142,7 @@ export function registerValidateTool(ctx) {
|
|
|
116
142
|
stdout: '',
|
|
117
143
|
stderr: '',
|
|
118
144
|
timedOut: false,
|
|
145
|
+
canceled: false,
|
|
119
146
|
durationMs: 0,
|
|
120
147
|
rejectReason: resolved.reason,
|
|
121
148
|
};
|
|
@@ -137,6 +164,7 @@ export function registerValidateTool(ctx) {
|
|
|
137
164
|
stdout: '',
|
|
138
165
|
stderr: '',
|
|
139
166
|
timedOut: false,
|
|
167
|
+
canceled: false,
|
|
140
168
|
durationMs: 0,
|
|
141
169
|
rejectReason: (source === 'defaults'
|
|
142
170
|
? 'No iterate.config.yaml at project root — running on built-in defaults, which configure NO trusted validation commands. '
|
|
@@ -152,12 +180,13 @@ export function registerValidateTool(ctx) {
|
|
|
152
180
|
stdout: '',
|
|
153
181
|
stderr: '',
|
|
154
182
|
timedOut: false,
|
|
183
|
+
canceled: false,
|
|
155
184
|
durationMs: 0,
|
|
156
185
|
rejectReason: `Command must exactly match a command predefined in iterate.config.yaml validation.commands. ` +
|
|
157
186
|
`Allowed commands: ${predefinedCommands.join(' | ')}`,
|
|
158
187
|
};
|
|
159
188
|
}
|
|
160
|
-
const result = await runCommand(args.command, projectRoot, timeout);
|
|
189
|
+
const result = await runCommand(args.command, projectRoot, timeout, exec.signal);
|
|
161
190
|
return {
|
|
162
191
|
allowed: true,
|
|
163
192
|
...result,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iterate-plugin",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.1",
|
|
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",
|
|
@@ -33,7 +33,10 @@
|
|
|
33
33
|
"0.1.2-alpha.4": "compatible",
|
|
34
34
|
"0.1.2-alpha.5": "compatible",
|
|
35
35
|
"0.1.2-rc.1": "compatible",
|
|
36
|
-
"0.1.3-alpha.1": "compatible"
|
|
36
|
+
"0.1.3-alpha.1": "compatible",
|
|
37
|
+
"0.1.5-alpha.1": "compatible",
|
|
38
|
+
"0.1.5-alpha.2": "compatible",
|
|
39
|
+
"0.1.5-rc.1": "compatible"
|
|
37
40
|
}
|
|
38
41
|
},
|
|
39
42
|
"client": {
|
package/src/atomic-fs.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/atomic-fs.ts — crash-safe file writes for all plugin state.
|
|
3
|
+
*
|
|
4
|
+
* Every JSON/YAML artifact the plugin persists (.iterate/*.json,
|
|
5
|
+
* iterate.config.yaml) is written through here: content goes to a
|
|
6
|
+
* uniquely-named temp file in the SAME directory as the target (rename is
|
|
7
|
+
* only atomic within one filesystem), then is `renameSync`ed over the
|
|
8
|
+
* destination. A crash mid-write can therefore leave a stale temp file
|
|
9
|
+
* behind, but never a truncated / half-written state file — readers
|
|
10
|
+
* (which all tolerate missing/corrupt files) see either the previous or
|
|
11
|
+
* the new content, never a mixture.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { dirname, join } from 'node:path'
|
|
15
|
+
import { renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
16
|
+
import { rename, rm, writeFile } from 'node:fs/promises'
|
|
17
|
+
|
|
18
|
+
/** Best-effort unlink of a leftover temp file (never throws). */
|
|
19
|
+
function cleanupTemp(tmp: string): void {
|
|
20
|
+
try {
|
|
21
|
+
rmSync(tmp, { force: true })
|
|
22
|
+
} catch {
|
|
23
|
+
/* ignore — temp files follow the prunable `.tmp-<pid>-<rand>` convention */
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Best-effort async unlink of a leftover temp file (never throws). */
|
|
28
|
+
async function cleanupTempAsync(tmp: string): Promise<void> {
|
|
29
|
+
try {
|
|
30
|
+
await rm(tmp, { force: true })
|
|
31
|
+
} catch {
|
|
32
|
+
/* ignore — temp files follow the prunable `.tmp-<pid>-<rand>` convention */
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Unique temp file path for `filePath`, in the same directory, following the
|
|
38
|
+
* prunable `.<basename>.tmp-<pid>-<random>` convention (see prune.ts
|
|
39
|
+
* `isPrunableTemp`). The pid+random suffix makes concurrent writers safe.
|
|
40
|
+
*/
|
|
41
|
+
function tempPathFor(filePath: string): string {
|
|
42
|
+
return join(
|
|
43
|
+
dirname(filePath),
|
|
44
|
+
`.${filePath.split('/').pop() ?? 'file'}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`,
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Write `data` to `filePath` atomically (temp file + rename, same dir).
|
|
50
|
+
* Temp files follow the `<name>.tmp-<pid>-<random>` convention also used by
|
|
51
|
+
* prune.ts / checkpoint.ts so they are recognizable and prunable.
|
|
52
|
+
* Throws on failure (callers own error reporting — they map failures to
|
|
53
|
+
* structured tool results).
|
|
54
|
+
*/
|
|
55
|
+
export function writeTextAtomic(filePath: string, data: string): void {
|
|
56
|
+
const tmp = tempPathFor(filePath)
|
|
57
|
+
writeFileSync(tmp, data, 'utf-8')
|
|
58
|
+
try {
|
|
59
|
+
renameSync(tmp, filePath)
|
|
60
|
+
} catch (err) {
|
|
61
|
+
// Best-effort temp cleanup so a failed write never litters the directory.
|
|
62
|
+
cleanupTemp(tmp)
|
|
63
|
+
throw err
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Async variant of {@link writeTextAtomic} (same temp naming convention). */
|
|
68
|
+
export async function writeTextAtomicAsync(filePath: string, data: string): Promise<void> {
|
|
69
|
+
const tmp = tempPathFor(filePath)
|
|
70
|
+
await writeFile(tmp, data, 'utf-8')
|
|
71
|
+
try {
|
|
72
|
+
await rename(tmp, filePath)
|
|
73
|
+
} catch (err) {
|
|
74
|
+
// Best-effort temp cleanup so a failed write never litters the directory.
|
|
75
|
+
await cleanupTempAsync(tmp)
|
|
76
|
+
throw err
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** JSON.stringify (2-space indent) + atomic write in one call. */
|
|
81
|
+
export function writeJsonAtomic(filePath: string, value: unknown): void {
|
|
82
|
+
writeTextAtomic(filePath, JSON.stringify(value, null, 2))
|
|
83
|
+
}
|