iterate-plugin 3.7.0 → 3.9.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 +43 -8
- package/README.zh-CN.md +26 -5
- package/dist/file-lock.js +75 -14
- package/dist/git-scope.js +22 -2
- package/dist/index.js +9 -4
- package/dist/report-render.js +452 -0
- package/dist/session-record.js +246 -0
- package/dist/skill-prompt.js +4 -3
- package/dist/tools/checkpoint.js +24 -17
- package/dist/tools/config.js +42 -33
- package/dist/tools/decision-log.js +13 -93
- package/dist/tools/report.js +239 -0
- package/dist/tools/transcript.js +260 -53
- package/dist/tools/triage.js +52 -45
- package/dist/transcript.js +2 -2
- package/lib/client.js +486 -22
- package/lib/parse.js +29 -6
- package/package.json +38 -13
- package/src/client/i18n.ts +6 -0
- package/src/client/index.ts +66 -4
- package/src/file-lock.ts +84 -14
- package/src/git-scope.ts +21 -2
- package/src/index.ts +9 -4
- package/src/report-render.ts +578 -0
- package/src/session-record.ts +333 -0
- package/src/skill-prompt.ts +4 -3
- package/src/tools/checkpoint.ts +23 -16
- package/src/tools/config.ts +40 -31
- package/src/tools/decision-log.ts +12 -80
- package/src/tools/report.ts +244 -0
- package/src/tools/transcript.ts +271 -52
- package/src/tools/triage.ts +47 -40
- package/src/transcript.ts +2 -2
- package/src/types.ts +1 -1
package/dist/skill-prompt.js
CHANGED
|
@@ -22,7 +22,8 @@ You have the iterate plugin installed, which registers these tools:
|
|
|
22
22
|
- \`iterate_status\` — summarize the current run: mode, round, fixes applied, architectural remaining, decision-log size, checkpoint presence, and whether the run was interrupted (a checkpoint on disk with no newer decision-log activity means the previous run was interrupted and can be resumed)
|
|
23
23
|
- \`iterate_history\` — inspect the runtime state in detail: decision-log entries and applied fixes (optionally scoped to a round or a fixed file)
|
|
24
24
|
- \`iterate_prune\` — remove stale runtime artifacts (\`.iterate/\` entries). Defaults to a read-only dry-run that reports what WOULD be removed; pass \`dryRun:false\` to actually prune.
|
|
25
|
-
- \`iterate_transcript\` — runtime observatory file (\`.iterate/transcript.json\`). \`read\` fetches the persisted manifest including any steering \`nudge\` for this run's reviewers; \`capture\` (call once after the final report) persists the per-reviewer threads, convergence trend, findings, fixes, checkpoint, per-round validation results (\`validations\`: command + exitCode + allowed + rejectReason), and timeline so the client observatory panel reflects the run; \`nudge\` sets/clears steering text the next round's reviewers read. Purely local, never touches source files.
|
|
25
|
+
- \`iterate_transcript\` — runtime observatory file (\`.iterate/transcript.json\`). \`read\` fetches the persisted manifest including any steering \`nudge\` for this run's reviewers — and, when the file is missing or malformed, recovers a summary manifest from the session log's run record (returns \`recovered:true\`); \`capture\` (call once after the final report) persists the per-reviewer threads, convergence trend, findings, fixes, checkpoint, per-round validation results (\`validations\`: command + exitCode + allowed + rejectReason), and timeline so the client observatory panel reflects the run, and links the run to the dsh session (\`sessionRecord\`); \`recall\` lists the run records linked to this session (newest first) so a run stays traceable after \`.iterate/\` is wiped; \`nudge\` sets/clears steering text the next round's reviewers read. Purely local, never touches source files.
|
|
26
|
+
- \`iterate_report\` — export the current run as a portable report. \`format:"markdown"\` (default) renders a PR-pasteable report (run header, quality gate, findings, fixes, validations, defense events, experience bank); \`format:"sarif"\` renders a SARIF 2.1.0 log for CI / GitHub Code Scanning (one rule per review dimension, severity → error/warning/note, file+line locations, suggested fix attached). Set \`write:true\` to persist it under \`.iterate/report.md\` / \`.iterate/report.sarif\`; \`lang\` (zh | en) selects the report language. Read-only apart from the optional report file.
|
|
26
27
|
- \`iterate_experience\` — experience bank (\`.iterate/experience.json\`): \`list\`/\`search\`/\`get\` recall verified fixes and patterns from past sessions (read the bank before fixing so proven fixes are applied first); \`add\` records a new verified fix — re-adding the same pattern+dimension bumps its hit count instead of duplicating it; \`remove\` deletes a stale or incorrect entry by \`id\` so bad experiences never resurface.
|
|
27
28
|
- \`iterate_quality_gate\` — quality certificate: \`read\` loads the persisted dimension convergence rates / verification pass rate / PASS-FAIL status; \`compute\` recomputes a fresh snapshot from this round's findings + validation results (supply \`findingsByRound\` for real convergence) and persists it to \`.iterate/quality-gate.json\`; \`clear\` removes the persisted certificate to reset a stale gate before a fresh iteration.
|
|
28
29
|
- \`iterate_defense_events\` — defense event stream (\`.iterate/defense-events.json\`): \`list\`/\`counts\` review precondition failures, rollbacks, invariant violations, and falsified assumptions; \`record\` logs a new event when a defense fires. Human-readable labels follow the project \`language\` (en/zh).
|
|
@@ -134,7 +135,7 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
134
135
|
(attachments.length > 0 ? '\\n User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
|
|
135
136
|
'\\n Already-known findings (do NOT re-report): ' +
|
|
136
137
|
JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.'
|
|
137
|
-
return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta
|
|
138
|
+
return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta?.findingsSchema }, backend))
|
|
138
139
|
}))
|
|
139
140
|
// A per-dimension reviewer subagent resolves \`null\` on child failure; the
|
|
140
141
|
// aggregate below would report an all-failed round as an empty round, and
|
|
@@ -463,7 +464,7 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
463
464
|
(steering ? '\\n STEERING — read this first: ' + steering : '') +
|
|
464
465
|
(attachments.length > 0 ? '\\n User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
|
|
465
466
|
'\\n Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.'
|
|
466
|
-
return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta
|
|
467
|
+
return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta?.findingsSchema }, backend))
|
|
467
468
|
}))
|
|
468
469
|
// ALL reviewers returning null (child failures) means this round produced
|
|
469
470
|
// no usable output — an empty aggregate must not read like a clean
|
package/dist/tools/checkpoint.js
CHANGED
|
@@ -12,6 +12,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
|
|
|
12
12
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
13
13
|
import { resolveProjectRootForExec } from "../config-loader.js";
|
|
14
14
|
import { writeJsonAtomic } from "../atomic-fs.js";
|
|
15
|
+
import { withProjectLock } from "../file-lock.js";
|
|
15
16
|
import { checkpointPath, iterateDir, transcriptPath } from "../paths.js";
|
|
16
17
|
import { readRegistry } from "./fix.js";
|
|
17
18
|
import { readDecisionEntries } from "./decision-log.js";
|
|
@@ -289,23 +290,29 @@ export function registerCheckpointTool(ctx) {
|
|
|
289
290
|
return { operation: 'load', ok: true, checkpoint: checkpoint };
|
|
290
291
|
}
|
|
291
292
|
if (args.operation === 'resume') {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
293
|
+
// Read-modify-write across processes: a concurrent resume in a
|
|
294
|
+
// sibling process would read the same resumeCount and write it back,
|
|
295
|
+
// losing one increment. Serialize on a checkpoint lock (the same
|
|
296
|
+
// advisory pattern the config/triage/transcript writers use).
|
|
297
|
+
return withProjectLock(projectRoot, 'checkpoint', () => {
|
|
298
|
+
const current = readCheckpoint(projectRoot);
|
|
299
|
+
if (!current) {
|
|
300
|
+
return { operation: 'resume', ok: false, error: 'no checkpoint to resume — run `save` first' };
|
|
301
|
+
}
|
|
302
|
+
const resumed = {
|
|
303
|
+
...current,
|
|
304
|
+
resumeCount: (current.resumeCount ?? 0) + 1,
|
|
305
|
+
updatedAt: new Date().toISOString(),
|
|
306
|
+
};
|
|
307
|
+
try {
|
|
308
|
+
mkdirSync(iterateDir(projectRoot), { recursive: true });
|
|
309
|
+
writeJsonAtomic(checkpointPath(projectRoot), resumed);
|
|
310
|
+
}
|
|
311
|
+
catch (err) {
|
|
312
|
+
return { operation: 'resume', ok: false, error: `failed to persist resumed checkpoint: ${String(err)}` };
|
|
313
|
+
}
|
|
314
|
+
return { operation: 'resume', ok: true, checkpoint: resumed };
|
|
315
|
+
});
|
|
309
316
|
}
|
|
310
317
|
if (args.operation === 'clear') {
|
|
311
318
|
const existed = existsSync(checkpointPath(projectRoot));
|
package/dist/tools/config.js
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
3
3
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
4
4
|
import { defaultConfig, loadEffectiveConfig, validateConfig, resolveProjectRootForExec } from "../config-loader.js";
|
|
5
5
|
import { applyConfigUpdates, readRawConfig, validateConfigUpdates, writeConfigFile, } from "../config-write.js";
|
|
6
|
+
import { withProjectLock } from "../file-lock.js";
|
|
6
7
|
/**
|
|
7
8
|
* Register the `iterate_config` tool.
|
|
8
9
|
* Reads and returns the iterate.config.yaml configuration, or writes a
|
|
@@ -105,42 +106,50 @@ export function registerConfigTool(ctx) {
|
|
|
105
106
|
if (updateErrors.length > 0) {
|
|
106
107
|
return { operation: 'write', ok: false, found: false, errors: updateErrors };
|
|
107
108
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
:
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
109
|
+
// Serialize the read→merge→validate→write cycle against any other
|
|
110
|
+
// config/triage writer in a sibling process: without the lock, two
|
|
111
|
+
// sessions on the same project each read a stale base and the second
|
|
112
|
+
// merge silently discards the first's update (lost update). The same
|
|
113
|
+
// lock name is shared with `iterate_triage apply` so the two writers
|
|
114
|
+
// are mutually exclusive.
|
|
115
|
+
return withProjectLock(projectRoot, 'config-write', () => {
|
|
116
|
+
let base;
|
|
117
|
+
try {
|
|
118
|
+
// Partial updates must be mergeable on a FRESH project (no config
|
|
119
|
+
// file yet): otherwise `{max_rounds: 5}` alone fails schema
|
|
120
|
+
// validation with a misleading "missing goal". Merge against the
|
|
121
|
+
// built-in defaults when there is no file to read, so the written
|
|
122
|
+
// config captures the effective config (defaults + update) and the
|
|
123
|
+
// documented "write a partial update" contract works out of the box.
|
|
124
|
+
base = existsSync(join(projectRoot, 'iterate.config.yaml'))
|
|
125
|
+
? readRawConfig(join(projectRoot, 'iterate.config.yaml'))
|
|
126
|
+
: defaultConfig();
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
return { operation: 'write', ok: false, found: false, error: `failed to read config: ${String(err)}` };
|
|
130
|
+
}
|
|
131
|
+
const merged = applyConfigUpdates(base, updates ?? {});
|
|
132
|
+
const schemaErrors = validateConfig(merged);
|
|
133
|
+
if (schemaErrors.length > 0) {
|
|
134
|
+
return {
|
|
135
|
+
operation: 'write',
|
|
136
|
+
ok: false,
|
|
137
|
+
found: false,
|
|
138
|
+
errors: schemaErrors.map((e) => `missing/required field: ${e}`),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const result = writeConfigFile(projectRoot, merged);
|
|
142
|
+
if (!result.ok)
|
|
143
|
+
return { operation: 'write', ok: false, found: false, error: result.error };
|
|
144
|
+
const { config } = loadEffectiveConfig(projectRoot);
|
|
126
145
|
return {
|
|
127
146
|
operation: 'write',
|
|
128
|
-
ok:
|
|
129
|
-
found:
|
|
130
|
-
|
|
147
|
+
ok: true,
|
|
148
|
+
found: true,
|
|
149
|
+
backupPath: result.backupPath ?? null,
|
|
150
|
+
config: config,
|
|
131
151
|
};
|
|
132
|
-
}
|
|
133
|
-
const result = writeConfigFile(projectRoot, merged);
|
|
134
|
-
if (!result.ok)
|
|
135
|
-
return { operation: 'write', ok: false, found: false, error: result.error };
|
|
136
|
-
const { config } = loadEffectiveConfig(projectRoot);
|
|
137
|
-
return {
|
|
138
|
-
operation: 'write',
|
|
139
|
-
ok: true,
|
|
140
|
-
found: true,
|
|
141
|
-
backupPath: result.backupPath ?? null,
|
|
142
|
-
config: config,
|
|
143
|
-
};
|
|
152
|
+
});
|
|
144
153
|
}
|
|
145
154
|
// ── Read operations (original behavior) ────────────────────────────
|
|
146
155
|
const { config, source } = loadEffectiveConfig(projectRoot);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { appendFileSync, readFileSync, mkdirSync, existsSync,
|
|
1
|
+
import { appendFileSync, readFileSync, mkdirSync, existsSync, statSync, } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
4
4
|
import { resolveProjectRootForExec } from "../config-loader.js";
|
|
5
|
-
import {
|
|
5
|
+
import { acquireProjectLockChecked } from "../file-lock.js";
|
|
6
6
|
const LOG_DIR = '.iterate';
|
|
7
7
|
const LOG_FILE = 'decision-log.jsonl';
|
|
8
8
|
// ─── Cross-process log mutex ────────────────────────────────────────────────
|
|
@@ -19,34 +19,15 @@ const LOG_FILE = 'decision-log.jsonl';
|
|
|
19
19
|
// be lost to a wedged append), never to a crash. The prune REWRITE is the
|
|
20
20
|
// opposite — it renames the log and therefore refuses to run when the lock
|
|
21
21
|
// cannot be taken (see `rewriteDecisionLogKeepingRecent` in prune.ts).
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
const LOCK_STALE_MS = 10000;
|
|
25
|
-
const LOCK_POLL_MS = 25;
|
|
26
|
-
/** True when a pid refers to a live process (or we lack permission to tell). */
|
|
27
|
-
function processAlive(pid) {
|
|
28
|
-
if (!Number.isInteger(pid) || pid <= 0)
|
|
29
|
-
return false;
|
|
30
|
-
try {
|
|
31
|
-
process.kill(pid, 0);
|
|
32
|
-
return true;
|
|
33
|
-
}
|
|
34
|
-
catch (err) {
|
|
35
|
-
return err.code === 'EPERM';
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
/** Synchronous busy-wait sleep (Atomics.wait is a reliable sleep in Node). */
|
|
39
|
-
function sleepSync(ms) {
|
|
40
|
-
const sab = new Int32Array(new SharedArrayBuffer(4));
|
|
41
|
-
Atomics.wait(sab, 0, 0, ms);
|
|
42
|
-
}
|
|
22
|
+
/** Lock name shared with the prune rewrite (→ `.iterate/.decision-log.lock`). */
|
|
23
|
+
const LOG_LOCK_NAME = 'decision-log';
|
|
43
24
|
/**
|
|
44
25
|
* Acquire the decision-log lock for `projectRoot`.
|
|
45
26
|
* Returns a release function (always callable; a no-op when the lock could
|
|
46
27
|
* not be taken — never wedge an append behind a vanished holder).
|
|
47
28
|
*
|
|
48
|
-
* `options.waitMs` / `options.staleMs` override the defaults (tests use
|
|
49
|
-
* waits so a live-holder timeout is exercised in milliseconds, not 5s).
|
|
29
|
+
* `options.waitMs` / `options.staleMs` override the shared defaults (tests use
|
|
30
|
+
* short waits so a live-holder timeout is exercised in milliseconds, not 5s).
|
|
50
31
|
*/
|
|
51
32
|
export function acquireLogLock(projectRoot, options = {}) {
|
|
52
33
|
return acquireLogLockChecked(projectRoot, options).release;
|
|
@@ -57,76 +38,15 @@ export function acquireLogLock(projectRoot, options = {}) {
|
|
|
57
38
|
* audit line must not be lost to a wedged append), but the rewrite renames the
|
|
58
39
|
* log under any unlocked concurrent appender — it must refuse to run without
|
|
59
40
|
* the mutex rather than silently dropping an audit line.
|
|
41
|
+
*
|
|
42
|
+
* Delegates to the shared `acquireProjectLockChecked` with lock name
|
|
43
|
+
* `decision-log`: the decision-log mutex IS that generic project lock. This
|
|
44
|
+
* module used to hand-roll an identical copy of the acquire/steal/wait logic,
|
|
45
|
+
* and the two stale/wait parameter sets could drift apart — one implementation,
|
|
46
|
+
* one set of defaults, one ownership-checked release.
|
|
60
47
|
*/
|
|
61
48
|
export function acquireLogLockChecked(projectRoot, options = {}) {
|
|
62
|
-
|
|
63
|
-
? options.waitMs
|
|
64
|
-
: LOCK_WAIT_MS;
|
|
65
|
-
const staleMs = Number.isFinite(options.staleMs) && (options.staleMs ?? 0) >= 0
|
|
66
|
-
? options.staleMs
|
|
67
|
-
: LOCK_STALE_MS;
|
|
68
|
-
const dir = join(projectRoot, LOG_DIR);
|
|
69
|
-
if (!existsSync(dir)) {
|
|
70
|
-
try {
|
|
71
|
-
mkdirSync(dir, { recursive: true });
|
|
72
|
-
}
|
|
73
|
-
catch {
|
|
74
|
-
return { acquired: false, release: () => { } };
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
const lockPath = join(dir, LOCK_FILE);
|
|
78
|
-
const start = Date.now();
|
|
79
|
-
for (;;) {
|
|
80
|
-
let fd = null;
|
|
81
|
-
try {
|
|
82
|
-
fd = openSync(lockPath, 'wx');
|
|
83
|
-
}
|
|
84
|
-
catch (err) {
|
|
85
|
-
if (err.code !== 'EEXIST')
|
|
86
|
-
return { acquired: false, release: () => { } };
|
|
87
|
-
// Lock exists — steal it when the owner is gone or the lock is ancient.
|
|
88
|
-
let stale = false;
|
|
89
|
-
try {
|
|
90
|
-
const st = statSync(lockPath);
|
|
91
|
-
if (Date.now() - st.mtimeMs > staleMs)
|
|
92
|
-
stale = true;
|
|
93
|
-
else {
|
|
94
|
-
const pid = Number(readFileSync(lockPath, 'utf-8'));
|
|
95
|
-
if (!processAlive(pid))
|
|
96
|
-
stale = true;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
catch {
|
|
100
|
-
stale = true; // unreadable/vanishing lock → retry as stale
|
|
101
|
-
}
|
|
102
|
-
if (stale) {
|
|
103
|
-
try {
|
|
104
|
-
unlinkSync(lockPath);
|
|
105
|
-
}
|
|
106
|
-
catch { /* another holder stole it */ }
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
if (Date.now() - start > waitMs)
|
|
110
|
-
return { acquired: false, release: () => { } };
|
|
111
|
-
sleepSync(LOCK_POLL_MS);
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
// Owned the lock: stamp the holder pid, then release once on return.
|
|
115
|
-
// The release is ownership-checked (see `makeLockRelease`): a lock stolen
|
|
116
|
-
// from this process after its owner went stale must not be unlinked by us.
|
|
117
|
-
try {
|
|
118
|
-
writeSync(fd, String(process.pid));
|
|
119
|
-
}
|
|
120
|
-
catch { /* pid stamp is advisory */ }
|
|
121
|
-
try {
|
|
122
|
-
closeSync(fd);
|
|
123
|
-
}
|
|
124
|
-
catch { /* best-effort */ }
|
|
125
|
-
return {
|
|
126
|
-
acquired: true,
|
|
127
|
-
release: makeLockRelease(lockPath),
|
|
128
|
-
};
|
|
129
|
-
}
|
|
49
|
+
return acquireProjectLockChecked(projectRoot, LOG_LOCK_NAME, options);
|
|
130
50
|
}
|
|
131
51
|
// ─── Entry-count cache (avoid an O(n) full re-read per append) ─────────────
|
|
132
52
|
/**
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/tools/report.ts — `iterate_report` tool.
|
|
3
|
+
*
|
|
4
|
+
* iterate_report — render the current iterate run into a portable report.
|
|
5
|
+
*
|
|
6
|
+
* Two formats, one normalized data model (shared with the client export
|
|
7
|
+
* buttons via `lib/parse.js`, so the on-screen export and this tool can never
|
|
8
|
+
* drift):
|
|
9
|
+
*
|
|
10
|
+
* - `markdown` — a PR-pasteable run report: header (mode/round/status), the
|
|
11
|
+
* quality-gate table, the findings table, the fixes table,
|
|
12
|
+
* validations, defense events, and the experience bank.
|
|
13
|
+
* - `sarif` — a SARIF 2.1.0 log so CI / GitHub Code Scanning can consume
|
|
14
|
+
* iterate findings as code-scanning results (ruleId =
|
|
15
|
+
* dimension, severity → error/warning/note, file+line
|
|
16
|
+
* locations, suggested fix attached).
|
|
17
|
+
*
|
|
18
|
+
* Reads only local state (`.iterate/transcript.json` + the quality / experience
|
|
19
|
+
* / defense stores) and writes only `.iterate/report.<ext>` when `write:true`.
|
|
20
|
+
* Malformed or missing state is rendered as-is (empty tables) — an export must
|
|
21
|
+
* never fail because a run was partial.
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
24
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
26
|
+
import { resolveProjectRootForExec } from "../config-loader.js";
|
|
27
|
+
import { iterateDir, transcriptPath } from "../paths.js";
|
|
28
|
+
import { readQualityGate } from "./quality-store.js";
|
|
29
|
+
import { readExperienceBank } from "./experience-store.js";
|
|
30
|
+
import { readDefenseEvents } from "./defense-store.js";
|
|
31
|
+
import { resultHeadline } from "./present.js";
|
|
32
|
+
import { renderRunReportMarkdown, renderRunReportSarif, REPORT_FORMATS } from "../report-render.js";
|
|
33
|
+
/** Formats accepted by the tool (kept in sync with the shared renderer). */
|
|
34
|
+
const FORMATS = new Set(REPORT_FORMATS);
|
|
35
|
+
/** Read the persisted transcript manifest, tolerating a missing/garbled file. */
|
|
36
|
+
function readManifest(projectRoot) {
|
|
37
|
+
const file = transcriptPath(projectRoot);
|
|
38
|
+
if (!existsSync(file))
|
|
39
|
+
return { manifest: {}, source: 'empty' };
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(readFileSync(file, 'utf-8'));
|
|
42
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
43
|
+
return { manifest: parsed, source: 'disk' };
|
|
44
|
+
}
|
|
45
|
+
return { manifest: {}, source: 'empty' };
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return { manifest: {}, source: 'empty' };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Register the `iterate_report` tool.
|
|
53
|
+
* Renders the current run into a Markdown or SARIF report.
|
|
54
|
+
*/
|
|
55
|
+
export function registerReportTool(ctx) {
|
|
56
|
+
ctx.tools.register(defineTool({
|
|
57
|
+
name: 'iterate_report',
|
|
58
|
+
// Result card: surface the report headline ("report: markdown · N findings")
|
|
59
|
+
// as the card title. Pure: reads only the rendered result (replay-safe).
|
|
60
|
+
presentResult: (_args, result) => {
|
|
61
|
+
const title = resultHeadline(result);
|
|
62
|
+
return title ? { card: 'generic', title } : undefined;
|
|
63
|
+
},
|
|
64
|
+
// `write:true` persists a file; a pure render joins a parallel group.
|
|
65
|
+
isConcurrencySafe: (args) => args.write !== true,
|
|
66
|
+
description: 'Export the current iterate run as a portable report. ' +
|
|
67
|
+
'`format:"markdown"` (default) renders a PR-pasteable report: run header (mode / round / status), ' +
|
|
68
|
+
'quality-gate table, findings table, fixes table, per-round validations, defense events, and the ' +
|
|
69
|
+
'experience bank. `format:"sarif"` renders a SARIF 2.1.0 log for CI / GitHub Code Scanning ' +
|
|
70
|
+
'(one rule per review dimension; finding severity maps to error/warning/note; findings carry ' +
|
|
71
|
+
'file + line locations and the suggested fix). ' +
|
|
72
|
+
'Set `write:true` to also persist the report under `.iterate/report.md` / `.iterate/report.sarif` ' +
|
|
73
|
+
'and get the path back. `lang` selects the report language (zh | en). ' +
|
|
74
|
+
'Read-only apart from the optional report file; never touches source files.',
|
|
75
|
+
parameters: {
|
|
76
|
+
format: {
|
|
77
|
+
type: 'string',
|
|
78
|
+
description: 'Report format: "markdown" (default) or "sarif".',
|
|
79
|
+
enum: ['markdown', 'sarif'],
|
|
80
|
+
},
|
|
81
|
+
write: {
|
|
82
|
+
type: 'boolean',
|
|
83
|
+
description: 'When true, persist the report to `.iterate/report.md` / `.iterate/report.sarif` and return its path. Default: false.',
|
|
84
|
+
},
|
|
85
|
+
lang: {
|
|
86
|
+
type: 'string',
|
|
87
|
+
description: 'Report language: "zh" (default) or "en". Affects labels/headings only.',
|
|
88
|
+
enum: ['zh', 'en'],
|
|
89
|
+
},
|
|
90
|
+
path: {
|
|
91
|
+
type: 'string',
|
|
92
|
+
description: 'Project root directory (default: current working directory).',
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
output: {
|
|
96
|
+
schema: {
|
|
97
|
+
type: 'object',
|
|
98
|
+
additionalProperties: false,
|
|
99
|
+
properties: {
|
|
100
|
+
ok: { type: 'boolean', required: true },
|
|
101
|
+
kind: { type: 'string' },
|
|
102
|
+
format: { type: 'string' },
|
|
103
|
+
/** The rendered report text (Markdown, or pretty-printed SARIF JSON). */
|
|
104
|
+
content: { type: 'string' },
|
|
105
|
+
/** The SARIF log object, when format is "sarif". */
|
|
106
|
+
sarif: { type: 'json' },
|
|
107
|
+
/** Whether a source transcript manifest was found on disk. */
|
|
108
|
+
source: { type: 'string' },
|
|
109
|
+
written: { type: 'boolean' },
|
|
110
|
+
reportPath: { type: 'string' },
|
|
111
|
+
findings: { type: 'number' },
|
|
112
|
+
error: { type: 'string' },
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
render: (_args, value) => {
|
|
116
|
+
if (!value.ok)
|
|
117
|
+
return [{ type: 'text', text: `report export failed: ${value.error}` }];
|
|
118
|
+
const content = typeof value.content === 'string' ? value.content : '';
|
|
119
|
+
const header = [
|
|
120
|
+
`report: ${String(value.format ?? 'markdown')} · ${String(value.findings ?? 0)} findings · source=${String(value.source ?? 'empty')}`,
|
|
121
|
+
value.written ? `written to ${String(value.reportPath ?? '')}` : '',
|
|
122
|
+
].filter(Boolean).join(' · ');
|
|
123
|
+
return [{ type: 'text', text: `${header}\n\n${content}` }];
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
async execute(args, exec) {
|
|
127
|
+
const resolved = resolveProjectRootForExec(exec, args.path);
|
|
128
|
+
if (!resolved.ok)
|
|
129
|
+
return { ok: false, kind: 'report', error: resolved.reason };
|
|
130
|
+
const projectRoot = resolved.root;
|
|
131
|
+
const rawFormat = typeof args.format === 'string' ? args.format : 'markdown';
|
|
132
|
+
const format = FORMATS.has(rawFormat) ? rawFormat : 'markdown';
|
|
133
|
+
const lang = args.lang === 'en' ? 'en' : 'zh';
|
|
134
|
+
const { manifest, source } = readManifest(projectRoot);
|
|
135
|
+
let qualityGate = null;
|
|
136
|
+
let experienceBank = null;
|
|
137
|
+
let defenseEvents = null;
|
|
138
|
+
try {
|
|
139
|
+
qualityGate = readQualityGate(projectRoot);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
qualityGate = null;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
experienceBank = readExperienceBank(projectRoot);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
experienceBank = null;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
defenseEvents = readDefenseEvents(projectRoot);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
defenseEvents = null;
|
|
155
|
+
}
|
|
156
|
+
const extra = { qualityGate, experienceBank, defenseEvents };
|
|
157
|
+
let content;
|
|
158
|
+
let sarif;
|
|
159
|
+
let findings = 0;
|
|
160
|
+
try {
|
|
161
|
+
if (format === 'sarif') {
|
|
162
|
+
const doc = renderRunReportSarif(manifest, extra, {
|
|
163
|
+
toolVersion: pluginVersion(),
|
|
164
|
+
});
|
|
165
|
+
sarif = doc;
|
|
166
|
+
content = JSON.stringify(doc, null, 2);
|
|
167
|
+
findings = Array.isArray(doc?.runs?.[0]?.results) ? doc.runs[0].results.length : 0;
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
content = renderRunReportMarkdown(manifest, extra, { lang });
|
|
171
|
+
findings = countMarkdownFindings(content);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
kind: 'report',
|
|
178
|
+
format,
|
|
179
|
+
error: `failed to render report: ${err instanceof Error ? err.message : String(err)}`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
let written = false;
|
|
183
|
+
let reportPath;
|
|
184
|
+
if (args.write === true) {
|
|
185
|
+
try {
|
|
186
|
+
const dir = iterateDir(projectRoot);
|
|
187
|
+
mkdirSync(dir, { recursive: true });
|
|
188
|
+
reportPath = `${dir}/report.${format === 'sarif' ? 'sarif' : 'md'}`;
|
|
189
|
+
writeFileSync(reportPath, content, 'utf-8');
|
|
190
|
+
written = true;
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
return {
|
|
194
|
+
ok: false,
|
|
195
|
+
kind: 'report',
|
|
196
|
+
format,
|
|
197
|
+
error: `failed to write report: ${err instanceof Error ? err.message : String(err)}`,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
ok: true,
|
|
203
|
+
kind: 'report',
|
|
204
|
+
format,
|
|
205
|
+
content,
|
|
206
|
+
...(sarif !== undefined ? { sarif } : {}),
|
|
207
|
+
source,
|
|
208
|
+
written,
|
|
209
|
+
...(reportPath ? { reportPath } : {}),
|
|
210
|
+
findings,
|
|
211
|
+
};
|
|
212
|
+
},
|
|
213
|
+
}));
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Best-effort plugin version for the SARIF driver. Resolved from the package
|
|
217
|
+
* manifest next to this module (`src/tools` and `dist/tools` are both two
|
|
218
|
+
* levels below the root); an unreadable/unparsable manifest yields ''.
|
|
219
|
+
*/
|
|
220
|
+
function pluginVersion() {
|
|
221
|
+
try {
|
|
222
|
+
const raw = readFileSync(new URL('../../package.json', import.meta.url), 'utf-8');
|
|
223
|
+
const pkg = JSON.parse(raw);
|
|
224
|
+
return typeof pkg.version === 'string' ? pkg.version : '';
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return '';
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/** Count the findings table rows in a rendered Markdown report (for the card). */
|
|
231
|
+
function countMarkdownFindings(markdown) {
|
|
232
|
+
const m = /##\s+.*?\((\d+)\)/.exec(markdown);
|
|
233
|
+
if (m && m[1]) {
|
|
234
|
+
const n = Number(m[1]);
|
|
235
|
+
if (Number.isFinite(n))
|
|
236
|
+
return n;
|
|
237
|
+
}
|
|
238
|
+
return 0;
|
|
239
|
+
}
|