iterate-plugin 3.5.7 → 3.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -22
- package/README.zh-CN.md +79 -19
- package/dist/approval-gate.js +143 -23
- package/dist/atomic-fs.js +148 -17
- package/dist/config-loader.js +516 -35
- package/dist/config-write.js +83 -22
- package/dist/evidence.js +141 -87
- package/dist/file-lock.js +193 -0
- package/dist/index.js +61 -28
- package/dist/jobs.js +56 -11
- package/dist/live.js +54 -14
- package/dist/meta-review.js +132 -28
- package/dist/method-scope.js +31 -8
- package/dist/review-scope.js +32 -9
- package/dist/review.js +96 -17
- package/dist/session-hooks.js +61 -14
- package/dist/skill-prompt.js +396 -66
- package/dist/tools/checkpoint.js +85 -19
- package/dist/tools/config.js +44 -2
- package/dist/tools/context.js +22 -5
- package/dist/tools/decision-log.js +106 -28
- package/dist/tools/defense-events.js +90 -31
- package/dist/tools/defense-store.js +28 -3
- package/dist/tools/experience-bank.js +108 -43
- package/dist/tools/experience-store.js +54 -4
- package/dist/tools/fix.js +347 -119
- package/dist/tools/history.js +68 -14
- package/dist/tools/present.js +80 -0
- package/dist/tools/prune.js +150 -53
- package/dist/tools/quality-gate.js +52 -2
- package/dist/tools/quality-store.js +17 -1
- package/dist/tools/review.js +158 -7
- package/dist/tools/transcript.js +173 -36
- package/dist/tools/triage.js +67 -12
- package/dist/tools/validate.js +115 -24
- package/dist/transcript.js +267 -4
- package/dist/working-directory.js +114 -0
- package/lib/client.js +1912 -512
- package/lib/parse.js +1001 -103
- package/package.json +16 -9
- package/src/approval-gate.ts +160 -23
- package/src/atomic-fs.ts +152 -17
- package/src/client/i18n.ts +517 -0
- package/src/client/index.ts +1031 -369
- package/src/client/session-cache.ts +54 -0
- package/src/config-loader.ts +549 -36
- package/src/config-write.ts +108 -26
- package/src/evidence.ts +194 -92
- package/src/file-lock.ts +211 -0
- package/src/index.ts +60 -29
- package/src/jobs.ts +63 -12
- package/src/live.ts +50 -12
- package/src/meta-review.ts +151 -34
- package/src/method-scope.ts +31 -8
- package/src/review-scope.ts +33 -10
- package/src/review.ts +96 -17
- package/src/session-hooks.ts +60 -13
- package/src/skill-prompt.ts +396 -66
- package/src/tools/checkpoint.ts +81 -19
- package/src/tools/config.ts +44 -2
- package/src/tools/context.ts +25 -5
- package/src/tools/decision-log.ts +114 -23
- package/src/tools/defense-events.ts +87 -31
- package/src/tools/defense-store.ts +30 -3
- package/src/tools/experience-bank.ts +112 -44
- package/src/tools/experience-store.ts +54 -5
- package/src/tools/fix.ts +258 -20
- package/src/tools/history.ts +74 -15
- package/src/tools/present.ts +89 -0
- package/src/tools/prune.ts +154 -51
- package/src/tools/quality-gate.ts +57 -2
- package/src/tools/quality-store.ts +18 -1
- package/src/tools/review.ts +155 -6
- package/src/tools/transcript.ts +178 -36
- package/src/tools/triage.ts +70 -13
- package/src/tools/validate.ts +138 -32
- package/src/transcript.ts +279 -4
- package/src/types.ts +29 -1
- package/src/working-directory.ts +126 -0
package/dist/tools/history.js
CHANGED
|
@@ -24,16 +24,29 @@ export function clampHistoryLimit(limit) {
|
|
|
24
24
|
* Filter + cap decision-log entries. Pure, unit-tested.
|
|
25
25
|
* Returns the newest `limit` matching entries plus the total match count
|
|
26
26
|
* (before the cap), so callers can tell when the result was truncated.
|
|
27
|
+
*
|
|
28
|
+
* `round` keeps only entries logged for that round; `file` keeps only entries
|
|
29
|
+
* whose `data.file` names that fixed file. Invalid `round`/`file` inputs are
|
|
30
|
+
* ignored (same convention as `type`/`since`), never treated as a match-nothing
|
|
31
|
+
* filter by accident.
|
|
27
32
|
*/
|
|
28
33
|
export function filterDecisionEntries(entries, opts) {
|
|
29
34
|
const type = typeof opts.type === 'string' && opts.type ? opts.type : undefined;
|
|
30
35
|
const since = typeof opts.since === 'string' && opts.since ? opts.since : undefined;
|
|
36
|
+
const round = typeof opts.round === 'number' && Number.isInteger(opts.round) && opts.round >= 0
|
|
37
|
+
? opts.round
|
|
38
|
+
: undefined;
|
|
39
|
+
const file = typeof opts.file === 'string' && opts.file ? opts.file : undefined;
|
|
31
40
|
const limit = clampHistoryLimit(opts.limit);
|
|
32
41
|
const matching = (Array.isArray(entries) ? entries : []).filter((e) => {
|
|
33
42
|
if (type && e.type !== type)
|
|
34
43
|
return false;
|
|
35
44
|
if (since && e.timestamp <= since)
|
|
36
45
|
return false;
|
|
46
|
+
if (round !== undefined && e.round !== round)
|
|
47
|
+
return false;
|
|
48
|
+
if (file && (!e.data || e.data.file !== file))
|
|
49
|
+
return false;
|
|
37
50
|
return true;
|
|
38
51
|
});
|
|
39
52
|
return {
|
|
@@ -42,15 +55,44 @@ export function filterDecisionEntries(entries, opts) {
|
|
|
42
55
|
limit,
|
|
43
56
|
};
|
|
44
57
|
}
|
|
45
|
-
/**
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Per-round fix counts + totals from a fix registry. Pure, unit-tested.
|
|
60
|
+
*
|
|
61
|
+
* `opts.round` narrows the view to one round. `opts.file` narrows each round to
|
|
62
|
+
* the records that fixed that file: the stored per-round counts cover every
|
|
63
|
+
* file and cannot be subsetted, so a file-scoped summary RECOMPUTES the counts
|
|
64
|
+
* from the kept records (mirroring `recomputeRoundCounts`), and rounds left
|
|
65
|
+
* without any matching record are dropped entirely — an all-zero placeholder
|
|
66
|
+
* round would falsely suggest the file was touched that round.
|
|
67
|
+
*/
|
|
68
|
+
export function summarizeFixRegistry(registry, opts = {}) {
|
|
69
|
+
const roundFilter = typeof opts.round === 'number' && Number.isInteger(opts.round) && opts.round >= 0
|
|
70
|
+
? opts.round
|
|
71
|
+
: undefined;
|
|
72
|
+
const fileFilter = typeof opts.file === 'string' && opts.file ? opts.file : undefined;
|
|
73
|
+
const rounds = (registry.rounds ?? [])
|
|
74
|
+
.filter((r) => roundFilter === undefined || r.round === roundFilter)
|
|
75
|
+
.flatMap((r) => {
|
|
76
|
+
if (fileFilter === undefined) {
|
|
77
|
+
// Coerce defensively so a hand-edited registry round missing either count
|
|
78
|
+
// can never propagate NaN into the integer output fields.
|
|
79
|
+
return [{
|
|
80
|
+
round: r.round,
|
|
81
|
+
fixedCount: Number(r.fixedCount) || 0,
|
|
82
|
+
failedCount: Number(r.failedCount) || 0,
|
|
83
|
+
}];
|
|
84
|
+
}
|
|
85
|
+
const kept = (r.records ?? []).filter((rec) => rec && rec.finding && rec.finding.file === fileFilter);
|
|
86
|
+
if (kept.length === 0)
|
|
87
|
+
return [];
|
|
88
|
+
// Recompute from the kept records: a rolled-back (success:false) record
|
|
89
|
+
// counts as failed, matching the registry's own recomputeRoundCounts.
|
|
90
|
+
return [{
|
|
91
|
+
round: r.round,
|
|
92
|
+
fixedCount: kept.filter((rec) => rec.success).length,
|
|
93
|
+
failedCount: kept.filter((rec) => !rec.success).length,
|
|
94
|
+
}];
|
|
95
|
+
});
|
|
54
96
|
return {
|
|
55
97
|
totalFixed: rounds.reduce((s, r) => s + r.fixedCount, 0),
|
|
56
98
|
totalFailed: rounds.reduce((s, r) => s + r.failedCount, 0),
|
|
@@ -60,8 +102,9 @@ export function summarizeFixRegistry(registry) {
|
|
|
60
102
|
}
|
|
61
103
|
/**
|
|
62
104
|
* Register the `iterate_history` tool.
|
|
63
|
-
* Reads the decision log (optionally filtered by type / since /
|
|
64
|
-
* fix-registry summary
|
|
105
|
+
* Reads the decision log (optionally filtered by type / since / round / file /
|
|
106
|
+
* limit) and a fix-registry summary (scoped by the same round / file filters).
|
|
107
|
+
* Read-only; never modifies the filesystem.
|
|
65
108
|
*/
|
|
66
109
|
export function registerHistoryTool(ctx) {
|
|
67
110
|
ctx.tools.register(defineTool({
|
|
@@ -70,7 +113,8 @@ export function registerHistoryTool(ctx) {
|
|
|
70
113
|
// dispatch group alongside other read-only sibling calls.
|
|
71
114
|
isConcurrencySafe: () => true,
|
|
72
115
|
description: 'Read the iteration history: decision-log entries (optionally filtered by entry `type`, `since` ' +
|
|
73
|
-
'timestamp, and a `limit`) plus a summary of the fix registry (per-round
|
|
116
|
+
'timestamp, `round`, fixed `file`, and a `limit`) plus a summary of the fix registry (per-round ' +
|
|
117
|
+
'fixed/failed counts, scoped by the same round/file filters). ' +
|
|
74
118
|
'Read-only — use it to review what the run did, audit a log, or inspect fixes.',
|
|
75
119
|
parameters: {
|
|
76
120
|
type: {
|
|
@@ -82,6 +126,16 @@ export function registerHistoryTool(ctx) {
|
|
|
82
126
|
type: 'string',
|
|
83
127
|
description: 'Optional ISO timestamp; only entries AFTER this timestamp are returned.',
|
|
84
128
|
},
|
|
129
|
+
round: {
|
|
130
|
+
type: 'integer',
|
|
131
|
+
description: 'Optional round number; only decision-log entries logged for this round are returned, ' +
|
|
132
|
+
'and the fix summary is scoped to this round.',
|
|
133
|
+
},
|
|
134
|
+
file: {
|
|
135
|
+
type: 'string',
|
|
136
|
+
description: 'Optional fixed-file path; only decision-log entries whose `data.file` matches are returned, ' +
|
|
137
|
+
'and the fix summary counts only fixes applied to that file.',
|
|
138
|
+
},
|
|
85
139
|
limit: {
|
|
86
140
|
type: 'integer',
|
|
87
141
|
description: `Max entries to return (default: ${DEFAULT_LIMIT}, cap: ${MAX_LIMIT}). Returns the newest window in chronological order.`,
|
|
@@ -127,8 +181,8 @@ export function registerHistoryTool(ctx) {
|
|
|
127
181
|
if (!resolved.ok)
|
|
128
182
|
return { ok: false, kind: 'history', error: resolved.reason };
|
|
129
183
|
const projectRoot = resolved.root;
|
|
130
|
-
const { entries, filteredCount, limit } = filterDecisionEntries(readDecisionEntries(projectRoot), { type: args.type, since: args.since, limit: args.limit });
|
|
131
|
-
const fixes = summarizeFixRegistry(readRegistry(projectRoot));
|
|
184
|
+
const { entries, filteredCount, limit } = filterDecisionEntries(readDecisionEntries(projectRoot), { type: args.type, since: args.since, limit: args.limit, round: args.round, file: args.file });
|
|
185
|
+
const fixes = summarizeFixRegistry(readRegistry(projectRoot), { round: args.round, file: args.file });
|
|
132
186
|
return {
|
|
133
187
|
ok: true,
|
|
134
188
|
kind: 'history',
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/tools/present.ts — shared `presentResult` helpers for the iterate tools (#12).
|
|
3
|
+
*
|
|
4
|
+
* dsh presents a completed call through `ToolDefinition.presentResult(args,
|
|
5
|
+
* result)` when the tool supplies one; the return value is a `ToolResultView`
|
|
6
|
+
* (a `card`-tagged render intent from `@deepseek-ai/dsh-tools`). These helpers
|
|
7
|
+
* read ONLY the durable result projection handed to the presenter
|
|
8
|
+
* (`result.content` / `result.isError`) — they never re-execute anything, so a
|
|
9
|
+
* live call and a session-log replay produce the identical card.
|
|
10
|
+
*
|
|
11
|
+
* House rules for every presenter built on this module:
|
|
12
|
+
* - Pure / replay-safe: depend on `args` + `result` only.
|
|
13
|
+
* - Defensive: any shape it cannot read returns `undefined`, which makes the
|
|
14
|
+
* UI fall back to the default presentation (pending title + raw result)
|
|
15
|
+
* instead of a wrong or half-guessed card.
|
|
16
|
+
* - No invented copy: card text is derived from the render output the tool
|
|
17
|
+
* itself produced.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Concatenated text of a completed call's rendered content, or `undefined`
|
|
21
|
+
* when there is none. Failure results are never summarized — the presenter
|
|
22
|
+
* declines the card and the UI shows the raw error.
|
|
23
|
+
*/
|
|
24
|
+
export function renderedText(result) {
|
|
25
|
+
if (!result || result.isError === true || !Array.isArray(result.content))
|
|
26
|
+
return undefined;
|
|
27
|
+
const parts = [];
|
|
28
|
+
for (const block of result.content) {
|
|
29
|
+
if (block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') {
|
|
30
|
+
parts.push(block.text);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return parts.length > 0 ? parts.join('\n') : undefined;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* First non-empty line of the rendered content — the card headline for a
|
|
37
|
+
* `presentResult` (#12). Long lines are clamped so a card header stays a
|
|
38
|
+
* header. `undefined` (failure / no text) means "decline the card".
|
|
39
|
+
*/
|
|
40
|
+
export function resultHeadline(result, maxLength = 160) {
|
|
41
|
+
const text = renderedText(result);
|
|
42
|
+
if (!text)
|
|
43
|
+
return undefined;
|
|
44
|
+
const line = text.split('\n').find((l) => l.trim().length > 0);
|
|
45
|
+
if (!line)
|
|
46
|
+
return undefined;
|
|
47
|
+
const trimmed = line.trim();
|
|
48
|
+
return trimmed.length > maxLength ? `${trimmed.slice(0, maxLength - 1)}…` : trimmed;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Parse a tool's rendered content as JSON — for tools whose `render` emits
|
|
52
|
+
* `JSON.stringify(value, null, 2)` verbatim (review, checkpoint). Returns a
|
|
53
|
+
* plain record, or `undefined` when the content is not an object-shaped JSON
|
|
54
|
+
* document (the presenter then declines the card rather than guess).
|
|
55
|
+
*/
|
|
56
|
+
export function parseRenderedJson(result) {
|
|
57
|
+
const text = renderedText(result);
|
|
58
|
+
if (!text)
|
|
59
|
+
return undefined;
|
|
60
|
+
let parsed;
|
|
61
|
+
try {
|
|
62
|
+
parsed = JSON.parse(text);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
68
|
+
? parsed
|
|
69
|
+
: undefined;
|
|
70
|
+
}
|
|
71
|
+
/** Narrow an unknown value to a plain record (arrays/null/primitives → undefined). */
|
|
72
|
+
export function asRecord(value) {
|
|
73
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
74
|
+
? value
|
|
75
|
+
: undefined;
|
|
76
|
+
}
|
|
77
|
+
/** Finite-number narrow for defensively reading fields off a parsed result. */
|
|
78
|
+
export function asNumber(value) {
|
|
79
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
80
|
+
}
|
package/dist/tools/prune.js
CHANGED
|
@@ -21,14 +21,16 @@
|
|
|
21
21
|
* Security model:
|
|
22
22
|
* - Only operates under the resolved project `.iterate/` directory.
|
|
23
23
|
* - dryRun=true by default — the caller must explicitly opt into deletion.
|
|
24
|
-
* - Each
|
|
24
|
+
* - Each non-dry-run appends ONE aggregate summary entry to the decision
|
|
25
|
+
* log (counts per artifact class) — not one log line per deleted file.
|
|
25
26
|
*/
|
|
26
|
-
import { existsSync, readdirSync, rmSync, unlinkSync, statSync } from 'node:fs';
|
|
27
|
+
import { existsSync, readdirSync, readFileSync, rmSync, unlinkSync, statSync } from 'node:fs';
|
|
27
28
|
import { join } from 'node:path';
|
|
28
29
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
29
30
|
import { resolveProjectRootForExec } from "../config-loader.js";
|
|
30
31
|
import { writeTextAtomic, writeJsonAtomic } from "../atomic-fs.js";
|
|
31
|
-
import {
|
|
32
|
+
import { withProjectLock } from "../file-lock.js";
|
|
33
|
+
import { readDecisionEntries, appendDecisionEntry, acquireLogLockChecked, invalidateLogCountCache } from "./decision-log.js";
|
|
32
34
|
import { readRegistry, recomputeRoundCounts } from "./fix.js";
|
|
33
35
|
import { readExperienceBank, writeExperienceBank } from "./experience-store.js";
|
|
34
36
|
import { readDefenseEvents, writeDefenseEvents } from "./defense-store.js";
|
|
@@ -91,12 +93,20 @@ export function sweepExperienceBank(bank, cap) {
|
|
|
91
93
|
const total = bank.entries.length;
|
|
92
94
|
if (total <= size)
|
|
93
95
|
return { bank, removed: 0 };
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
96
|
+
// Keep by POSITION, not by id: a Set of kept ids lets duplicate ids (a
|
|
97
|
+
// hand-edited or double-written bank) defeat the cap — every original entry
|
|
98
|
+
// sharing a kept id would survive, so `removed` stayed 0 and the cap never
|
|
99
|
+
// applied. Rank positions newest-first (ties fall back to the original index
|
|
100
|
+
// so equal timestamps keep insertion order, as before), keep the first `size`
|
|
101
|
+
// positions, then re-emit the survivors in insertion order.
|
|
102
|
+
const ranked = bank.entries.map((_, i) => i);
|
|
103
|
+
ranked.sort((a, b) => {
|
|
104
|
+
const ta = bank.entries[a]?.timestamp ?? '';
|
|
105
|
+
const tb = bank.entries[b]?.timestamp ?? '';
|
|
106
|
+
return tb.localeCompare(ta) || a - b;
|
|
107
|
+
});
|
|
108
|
+
const keep = new Set(ranked.slice(0, size));
|
|
109
|
+
const ordered = bank.entries.filter((_, i) => keep.has(i));
|
|
100
110
|
return {
|
|
101
111
|
bank: { ...bank, entries: ordered, lastUpdated: new Date().toISOString() },
|
|
102
112
|
removed: total - ordered.length,
|
|
@@ -160,6 +170,27 @@ export function inspectPrune(projectRoot, retainDays) {
|
|
|
160
170
|
}
|
|
161
171
|
}
|
|
162
172
|
// 3. Stale fix backups: .bak files whose fix-id prefix is not in the registry.
|
|
173
|
+
//
|
|
174
|
+
// `readRegistry` returns an EMPTY registry for BOTH "file absent" and "file
|
|
175
|
+
// present but corrupt" — and for the corrupt case an empty active-id set
|
|
176
|
+
// would classify EVERY backup stale, so a single dryRun:false prune would
|
|
177
|
+
// delete the entire rollback safety net. Read the registry file directly to
|
|
178
|
+
// distinguish the two: absent ⇒ backups are orphaned (stale, delete as
|
|
179
|
+
// before); present-but-unreadable ⇒ set `registryError`, classify nothing,
|
|
180
|
+
// and let `executePrune`/the render surface the refusal.
|
|
181
|
+
const registryFile = fixRegistryPath(projectRoot);
|
|
182
|
+
let registryError = null;
|
|
183
|
+
if (existsSync(registryFile)) {
|
|
184
|
+
try {
|
|
185
|
+
const parsed = JSON.parse(readFileSync(registryFile, 'utf-8'));
|
|
186
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.rounds)) {
|
|
187
|
+
registryError = `refusing to delete fix backups: fix registry is present but unreadable (invalid shape): ${registryFile}`;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
registryError = `refusing to delete fix backups: fix registry is present but unreadable: ${String(err)} (${registryFile})`;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
163
194
|
const registry = readRegistry(projectRoot);
|
|
164
195
|
const activeIds = new Set();
|
|
165
196
|
for (const r of registry.rounds) {
|
|
@@ -179,15 +210,17 @@ export function inspectPrune(projectRoot, retainDays) {
|
|
|
179
210
|
catch {
|
|
180
211
|
fixEntries = [];
|
|
181
212
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
213
|
+
if (registryError === null) {
|
|
214
|
+
for (const entry of fixEntries) {
|
|
215
|
+
if (!entry.endsWith('.bak'))
|
|
216
|
+
continue;
|
|
217
|
+
// Extract the fix-id prefix (up to the first underscore after the id).
|
|
218
|
+
// e.g. "fix-abc123_2026-08-17T00-00-00-000Z.bak" → "fix-abc123"
|
|
219
|
+
const match = entry.match(/^(fix-[a-z0-9]+)_/);
|
|
220
|
+
const id = match?.[1];
|
|
221
|
+
if (id && !activeIds.has(id)) {
|
|
222
|
+
staleBackups.push(entry);
|
|
223
|
+
}
|
|
191
224
|
}
|
|
192
225
|
}
|
|
193
226
|
// 4. Empty rounds (rounds with 0 records).
|
|
@@ -224,6 +257,7 @@ export function inspectPrune(projectRoot, retainDays) {
|
|
|
224
257
|
emptyRounds,
|
|
225
258
|
totalLogEntries: entries.length,
|
|
226
259
|
registryRounds: registry.rounds.length,
|
|
260
|
+
registryError,
|
|
227
261
|
totalExperiences: bank.entries.length,
|
|
228
262
|
experienceOversize: sweptBank.removed,
|
|
229
263
|
totalDefenseEvents: defenseStream.events.length,
|
|
@@ -239,29 +273,56 @@ export function inspectPrune(projectRoot, retainDays) {
|
|
|
239
273
|
*
|
|
240
274
|
* The read-before-rewrite is a TOCTOU hot spot: a concurrent worker can append
|
|
241
275
|
* a fresh audit line between our read and our atomic rewrite, and an atomic
|
|
242
|
-
* rename would silently discard it. So instead of a single read+write we
|
|
243
|
-
*
|
|
276
|
+
* rename would silently discard it. So instead of a single read+write we take
|
|
277
|
+
* the cross-process log mutex for the whole loop AND keep a bounded
|
|
278
|
+
* compare-and-append loop behind it — after each rewrite we re-read the log and
|
|
244
279
|
* retry whenever the file grew (a concurrent append slipped in). The loop
|
|
245
280
|
* terminates when the file is stable or after `MAX_LOG_REWRITE_RETRIES`, and a
|
|
246
281
|
* failure here is surfaced as a structured error instead of truncating the log.
|
|
247
282
|
*
|
|
248
|
-
*
|
|
283
|
+
* Lock required: when the mutex cannot be taken (another process is appending
|
|
284
|
+
* and does not release within the wait budget) the rewrite is REFUSED with a
|
|
285
|
+
* structured error rather than proceeding unlocked — an unlocked rename can
|
|
286
|
+
* drop a concurrent appender's audit line (its fd points at the pre-rename
|
|
287
|
+
* inode) with no way to detect the loss afterwards.
|
|
288
|
+
*
|
|
289
|
+
* `deleted` is the TOTAL across every attempt: attempt 1 may already have
|
|
290
|
+
* removed entries before a retry is needed, and reporting only the final
|
|
291
|
+
* attempt's slice would under-count the audit entry (the pre-fix code returned
|
|
292
|
+
* `deleted: 0` on the exhaustion path even after real deletions).
|
|
293
|
+
*
|
|
294
|
+
* @param options.afterRewrite test hook run after each successful rewrite —
|
|
295
|
+
* simulates a concurrent appender landing lines mid-loop (production passes
|
|
296
|
+
* no hook).
|
|
297
|
+
* @param options.lock lock wait/stale budgets (tests pass short waits so the
|
|
298
|
+
* lock-unavailable refusal is exercised in milliseconds, not 5s).
|
|
299
|
+
* @returns the number of entries removed across all attempts, or 0 with an
|
|
300
|
+
* `error` when nothing needed removing or the rewrite was refused/failed.
|
|
249
301
|
*/
|
|
250
|
-
function rewriteDecisionLogKeepingRecent(projectRoot, cutoff) {
|
|
302
|
+
export function rewriteDecisionLogKeepingRecent(projectRoot, cutoff, options = {}) {
|
|
251
303
|
const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl');
|
|
252
304
|
// Take the cross-process log lock for the ENTIRE loop: with the mutex held,
|
|
253
305
|
// no concurrent appender can slip a fresh line into the read-before-rewrite
|
|
254
306
|
// window, so the first attempt is nearly always the only one. (The retry
|
|
255
|
-
// loop stays as defense-in-depth for the
|
|
256
|
-
|
|
307
|
+
// loop stays as defense-in-depth for a line landing via the afterRewrite
|
|
308
|
+
// hook or an already-running appender's buffered write.)
|
|
309
|
+
const { acquired, release } = acquireLogLockChecked(projectRoot, options.lock);
|
|
310
|
+
if (!acquired) {
|
|
311
|
+
return {
|
|
312
|
+
deleted: 0,
|
|
313
|
+
error: 'failed to acquire the decision-log lock — refusing to rewrite the log unlocked ' +
|
|
314
|
+
'(a concurrent appender could lose an audit line). Retry after the other operation finishes.',
|
|
315
|
+
};
|
|
316
|
+
}
|
|
257
317
|
try {
|
|
318
|
+
let deletedTotal = 0;
|
|
258
319
|
for (let attempt = 0; attempt < MAX_LOG_REWRITE_RETRIES; attempt++) {
|
|
259
320
|
try {
|
|
260
321
|
const entries = readDecisionEntries(projectRoot);
|
|
261
322
|
const kept = entries.filter((e) => e.timestamp >= cutoff);
|
|
262
323
|
const deleted = entries.length - kept.length;
|
|
263
324
|
if (deleted === 0)
|
|
264
|
-
return { deleted:
|
|
325
|
+
return { deleted: deletedTotal };
|
|
265
326
|
writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n');
|
|
266
327
|
// The append-side entry-count cache is keyed by path and assumes "same
|
|
267
328
|
// byte size ⇒ same count"; the rename above replaced the file with a
|
|
@@ -269,18 +330,23 @@ function rewriteDecisionLogKeepingRecent(projectRoot, cutoff) {
|
|
|
269
330
|
// append can report a wrong entryCount when the sizes coincidentally
|
|
270
331
|
// match.
|
|
271
332
|
invalidateLogCountCache(logPath);
|
|
333
|
+
// Accumulate THIS attempt's deletions before the re-read: whatever the
|
|
334
|
+
// later attempts (or the exhaustion path) report, these entries are
|
|
335
|
+
// already gone from disk and must count toward the audit total.
|
|
336
|
+
deletedTotal += deleted;
|
|
337
|
+
options.afterRewrite?.();
|
|
272
338
|
// A concurrent appender (cross-process, lock-unavailable path) may have
|
|
273
339
|
// landed new lines after our read. If the log grew during the write
|
|
274
340
|
// window, re-read and prune again instead of accepting a lost audit trail.
|
|
275
341
|
const after = readDecisionEntries(projectRoot);
|
|
276
342
|
if (after.length <= kept.length)
|
|
277
|
-
return { deleted };
|
|
343
|
+
return { deleted: deletedTotal };
|
|
278
344
|
}
|
|
279
345
|
catch (err) {
|
|
280
|
-
return { deleted:
|
|
346
|
+
return { deleted: deletedTotal, error: `failed to rewrite decision log: ${String(err)}` };
|
|
281
347
|
}
|
|
282
348
|
}
|
|
283
|
-
return { deleted:
|
|
349
|
+
return { deleted: deletedTotal, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` };
|
|
284
350
|
}
|
|
285
351
|
finally {
|
|
286
352
|
release();
|
|
@@ -304,9 +370,10 @@ export function executePrune(projectRoot, retainDays, report) {
|
|
|
304
370
|
errors: [],
|
|
305
371
|
};
|
|
306
372
|
// 1. Rewrite the decision log, keeping only recent entries. Atomic
|
|
307
|
-
// (temp + rename)
|
|
308
|
-
//
|
|
309
|
-
//
|
|
373
|
+
// (temp + rename) under the cross-process log mutex — REFUSED with a
|
|
374
|
+
// structured error when the lock cannot be taken (an unlocked rename could
|
|
375
|
+
// drop a concurrent appender's audit line); the bounded compare-and-append
|
|
376
|
+
// loop behind it guards the remaining read-before-rewrite window.
|
|
310
377
|
{
|
|
311
378
|
const { deleted, error } = rewriteDecisionLogKeepingRecent(projectRoot, cutoff);
|
|
312
379
|
result.deletedLogEntries = deleted;
|
|
@@ -325,30 +392,42 @@ export function executePrune(projectRoot, retainDays, report) {
|
|
|
325
392
|
result.errors.push(`failed to remove checkpoint: ${String(err)}`);
|
|
326
393
|
}
|
|
327
394
|
}
|
|
328
|
-
// 3. Delete stale backups
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
395
|
+
// 3. Delete stale backups — REFUSED outright when the registry is present
|
|
396
|
+
// but unreadable: without a readable active-id set every backup would have
|
|
397
|
+
// classified stale, and one dryRun:false prune would wipe the whole rollback
|
|
398
|
+
// safety net. Surface the refusal as a structured error instead.
|
|
399
|
+
if (report.registryError) {
|
|
400
|
+
result.errors.push(report.registryError);
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
for (const bak of report.staleBackups) {
|
|
404
|
+
try {
|
|
405
|
+
unlinkSync(join(fixesDir(projectRoot), bak));
|
|
406
|
+
result.deletedBackups.push(bak);
|
|
407
|
+
}
|
|
408
|
+
catch (err) {
|
|
409
|
+
result.errors.push(`failed to delete backup ${bak}: ${String(err)}`);
|
|
410
|
+
}
|
|
336
411
|
}
|
|
337
412
|
}
|
|
338
|
-
// 4. Trim empty rounds from the registry
|
|
413
|
+
// 4. Trim empty rounds from the registry (locked read-modify-write: the
|
|
414
|
+
// registry read and rewrite must not interleave with another process's
|
|
415
|
+
// fix/rollback or the update is lost — see src/file-lock.ts).
|
|
339
416
|
if (report.emptyRounds.length > 0) {
|
|
340
417
|
try {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
418
|
+
withProjectLock(projectRoot, 'fix-registry', () => {
|
|
419
|
+
let registry = readRegistry(projectRoot);
|
|
420
|
+
const emptyRoundNos = new Set(report.emptyRounds);
|
|
421
|
+
// Drop whole empty rounds (records.length === 0) instead of only
|
|
422
|
+
// removing their records — an empty round has no records to remove, so
|
|
423
|
+
// the old loop was a no-op that still reported trimmedEmptyRounds.
|
|
424
|
+
registry = {
|
|
425
|
+
...registry,
|
|
426
|
+
rounds: registry.rounds.filter((r) => !emptyRoundNos.has(r.round) || (r.records?.length ?? 0) > 0),
|
|
427
|
+
};
|
|
428
|
+
registry = recomputeRoundCounts(registry);
|
|
429
|
+
writeJsonAtomic(fixRegistryPath(projectRoot), registry);
|
|
430
|
+
});
|
|
352
431
|
result.trimmedEmptyRounds = report.emptyRounds.length;
|
|
353
432
|
}
|
|
354
433
|
catch (err) {
|
|
@@ -403,12 +482,27 @@ export function executePrune(projectRoot, retainDays, report) {
|
|
|
403
482
|
export function registerPruneTool(ctx) {
|
|
404
483
|
ctx.tools.register(defineTool({
|
|
405
484
|
name: 'iterate_prune',
|
|
485
|
+
// Pending-call card (#12): say up front whether this sweep is the default
|
|
486
|
+
// report-only preview or a REAL deletion — that distinction is prune's
|
|
487
|
+
// entire safety model, so it must be visible before the call runs. Pure:
|
|
488
|
+
// derived from args only.
|
|
489
|
+
presentCall: (args) => {
|
|
490
|
+
const a = args;
|
|
491
|
+
const deleting = a.dryRun === false;
|
|
492
|
+
return {
|
|
493
|
+
card: 'generic',
|
|
494
|
+
title: deleting
|
|
495
|
+
? 'Prune stale .iterate/ artifacts (delete)'
|
|
496
|
+
: 'Prune preview (dry run — nothing deleted)',
|
|
497
|
+
kind: deleting ? 'delete' : 'read',
|
|
498
|
+
};
|
|
499
|
+
},
|
|
406
500
|
description: 'Inspect or clean up old iterate runtime artifacts (.iterate/). ' +
|
|
407
501
|
'Defaults to dry-run (report-only, no deletion). Pass `dryRun: false` to actually prune. ' +
|
|
408
502
|
'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds, ' +
|
|
409
503
|
'stray temp files left by crashed atomic writes, experience-bank entries beyond the entry cap, ' +
|
|
410
504
|
'and stale defense events. ' +
|
|
411
|
-
'
|
|
505
|
+
'A non-dry-run appends ONE aggregate summary entry (counts per artifact class) to the decision log.',
|
|
412
506
|
parameters: {
|
|
413
507
|
dryRun: {
|
|
414
508
|
type: 'boolean',
|
|
@@ -442,11 +536,14 @@ export function registerPruneTool(ctx) {
|
|
|
442
536
|
const report = value.report;
|
|
443
537
|
const result = value.result;
|
|
444
538
|
if (value.dryRun) {
|
|
539
|
+
const registryError = report?.registryError;
|
|
445
540
|
const lines = [
|
|
446
541
|
`[dry-run] prune report (retainDays=${value.retainDays}):`,
|
|
447
542
|
` Decision-log entries to remove: ${report?.oldLogEntries ?? '?'} (of ${report?.totalLogEntries ?? '?'})`,
|
|
448
543
|
` Checkpoint to delete: ${report?.checkpointStale ? 'yes (stale)' : report?.hasCheckpoint ? 'no (fresh — resume point, kept)' : 'none'}`,
|
|
449
|
-
|
|
544
|
+
registryError
|
|
545
|
+
? ` Stale backups to delete: none — ${registryError}`
|
|
546
|
+
: ` Stale backups to delete: ${report?.staleBackups?.length ?? 0}`,
|
|
450
547
|
` Stray temp files to delete: ${report?.staleTemps?.length ?? 0}`,
|
|
451
548
|
` Empty rounds to trim: ${report?.emptyRounds?.length ?? 0}`,
|
|
452
549
|
` Experience entries to drop (over ${report?.totalExperiences ?? '?'} cap): ${report?.experienceOversize ?? 0} of ${report?.totalExperiences ?? '?'}`,
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
10
10
|
import { resolveProjectRootForExec } from "../config-loader.js";
|
|
11
11
|
import { readQualityGate, writeQualityGate, computeQualityGate, clearQualityGate } from "./quality-store.js";
|
|
12
|
+
import { resultHeadline } from "./present.js";
|
|
12
13
|
/** Valid severity values (kept in sync with QualityGateSnapshot counting). */
|
|
13
14
|
const VALID_SEVERITIES = new Set(['critical', 'high', 'medium', 'low']);
|
|
14
15
|
/** Validate a single finding object; returns true when well-formed. */
|
|
@@ -62,6 +63,14 @@ function sanitizeRoundSeries(raw) {
|
|
|
62
63
|
export function registerQualityGateTool(ctx) {
|
|
63
64
|
ctx.tools.register(defineTool({
|
|
64
65
|
name: 'iterate_quality_gate',
|
|
66
|
+
// Result card (#12): the certificate render already opens with its
|
|
67
|
+
// headline ("✓ Quality Gate: PASS (score: 92)") — surface that as the
|
|
68
|
+
// card title so a completed gate query isn't a bare blob. Pure: reads
|
|
69
|
+
// only the rendered result (replay-safe); failures decline the card.
|
|
70
|
+
presentResult: (_args, result) => {
|
|
71
|
+
const title = resultHeadline(result);
|
|
72
|
+
return title ? { card: 'generic', title } : undefined;
|
|
73
|
+
},
|
|
65
74
|
// `read` never writes; `compute` persists the snapshot → only read
|
|
66
75
|
// joins a parallel dispatch group.
|
|
67
76
|
isConcurrencySafe: (args) => args.operation === 'read',
|
|
@@ -81,11 +90,13 @@ export function registerQualityGateTool(ctx) {
|
|
|
81
90
|
dimensions: {
|
|
82
91
|
type: 'array',
|
|
83
92
|
items: { type: 'string' },
|
|
84
|
-
description: 'Dimensions to gate (required for compute).',
|
|
93
|
+
description: 'Dimensions to gate (required and must be non-empty for compute).',
|
|
85
94
|
},
|
|
86
95
|
findings: {
|
|
87
96
|
type: 'json',
|
|
88
|
-
description: 'Findings array
|
|
97
|
+
description: 'Findings array for compute (optional — defaults to []). Each item: ' +
|
|
98
|
+
'{ dimension, severity (critical|high|medium|low), file, line? }. Findings whose dimension ' +
|
|
99
|
+
'is not listed in `dimensions` are excluded from scoring and reported as warnings.',
|
|
89
100
|
},
|
|
90
101
|
validationResults: {
|
|
91
102
|
type: 'json',
|
|
@@ -114,6 +125,11 @@ export function registerQualityGateTool(ctx) {
|
|
|
114
125
|
kind: { type: 'string' },
|
|
115
126
|
operation: { type: 'string' },
|
|
116
127
|
snapshot: { type: 'json' },
|
|
128
|
+
warnings: {
|
|
129
|
+
type: 'array',
|
|
130
|
+
items: { type: 'string' },
|
|
131
|
+
description: 'compute only: findings excluded from scoring because their dimension is not gated.',
|
|
132
|
+
},
|
|
117
133
|
error: { type: 'string' },
|
|
118
134
|
},
|
|
119
135
|
},
|
|
@@ -139,6 +155,9 @@ export function registerQualityGateTool(ctx) {
|
|
|
139
155
|
if (snapshot.failReason) {
|
|
140
156
|
lines.push('', `Fail Reason: ${snapshot.failReason}`);
|
|
141
157
|
}
|
|
158
|
+
if (Array.isArray(value.warnings) && value.warnings.length > 0) {
|
|
159
|
+
lines.push('', 'Warnings:', ...value.warnings.map((w) => ` ! ${String(w)}`));
|
|
160
|
+
}
|
|
142
161
|
if (operation === 'compute') {
|
|
143
162
|
lines.push('', 'Quality gate snapshot computed and persisted.');
|
|
144
163
|
}
|
|
@@ -170,12 +189,40 @@ export function registerQualityGateTool(ctx) {
|
|
|
170
189
|
const dimensions = Array.isArray(args.dimensions)
|
|
171
190
|
? args.dimensions.filter((d) => typeof d === 'string' && d.length > 0)
|
|
172
191
|
: [];
|
|
192
|
+
// A compute without any gateable dimension would score an empty
|
|
193
|
+
// dimension list at overallScore 0 and PERSIST a fabricated FAIL
|
|
194
|
+
// certificate for a review that was simply never scoped. Refuse
|
|
195
|
+
// instead: no snapshot is written, and the caller gets a structured
|
|
196
|
+
// error naming the missing argument.
|
|
197
|
+
if (dimensions.length === 0) {
|
|
198
|
+
return {
|
|
199
|
+
ok: false,
|
|
200
|
+
kind: 'quality_gate',
|
|
201
|
+
operation: 'compute',
|
|
202
|
+
error: 'dimensions is required (and must be a non-empty array of dimension names) for operation "compute" — ' +
|
|
203
|
+
'without dimensions there is nothing to gate and no certificate can be computed.',
|
|
204
|
+
};
|
|
205
|
+
}
|
|
173
206
|
const findings = Array.isArray(args.findings) ? args.findings.filter(isValidFinding) : [];
|
|
174
207
|
const validationResults = Array.isArray(args.validationResults)
|
|
175
208
|
? args.validationResults.filter(isValidValidationResult)
|
|
176
209
|
: undefined;
|
|
177
210
|
const findingsByRound = sanitizeRoundSeries(args.findingsByRound);
|
|
178
211
|
const fixedByDimension = sanitizeNumberMap(args.fixedByDimension);
|
|
212
|
+
// Findings in dimensions outside the gated list are silently skipped
|
|
213
|
+
// by computeQualityGate (they never touch any dimension score) — a
|
|
214
|
+
// critical one still fails the gate globally, everything else just
|
|
215
|
+
// vanishes from the numbers. Surface those dimensions as warnings so
|
|
216
|
+
// the caller knows the score does NOT cover them; scoring is
|
|
217
|
+
// deliberately left unchanged.
|
|
218
|
+
const gated = new Set(dimensions);
|
|
219
|
+
const ungatedCounts = new Map();
|
|
220
|
+
for (const finding of findings) {
|
|
221
|
+
if (!gated.has(finding.dimension)) {
|
|
222
|
+
ungatedCounts.set(finding.dimension, (ungatedCounts.get(finding.dimension) ?? 0) + 1);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const warnings = [...ungatedCounts].map(([dimension, count]) => `finding dimension "${dimension}" is not in the gated dimensions and was excluded from scoring (${count} finding${count === 1 ? '' : 's'})`);
|
|
179
226
|
const snapshot = computeQualityGate({
|
|
180
227
|
dimensions,
|
|
181
228
|
findings,
|
|
@@ -192,6 +239,9 @@ export function registerQualityGateTool(ctx) {
|
|
|
192
239
|
kind: 'quality_gate',
|
|
193
240
|
operation: 'compute',
|
|
194
241
|
snapshot: snapshot,
|
|
242
|
+
// Typed by the output schema as string[] — do not widen this to
|
|
243
|
+
// JsonValue or the execute return type stops matching the schema.
|
|
244
|
+
warnings,
|
|
195
245
|
};
|
|
196
246
|
}
|
|
197
247
|
const snapshot = readQualityGate(projectRoot);
|