iterate-plugin 2.10.0 → 2.12.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 +42 -2
- package/README.zh-CN.md +40 -2
- package/dist/approval-gate.js +92 -0
- package/dist/config-loader.js +18 -3
- package/dist/config-write.js +7 -4
- package/dist/evidence.js +67 -1
- package/dist/git-scope.js +35 -6
- package/dist/index.js +15 -5
- package/dist/live.js +155 -0
- package/dist/meta-review.js +19 -5
- package/dist/method-scope.js +5 -1
- package/dist/paths.js +4 -0
- package/dist/review-scope.js +12 -8
- package/dist/review.js +76 -24
- package/dist/session-hooks.js +89 -0
- package/dist/skill-prompt.js +101 -19
- package/dist/tools/checkpoint.js +10 -3
- package/dist/tools/context.js +16 -4
- package/dist/tools/decision-log.js +29 -9
- package/dist/tools/fix.js +120 -3
- package/dist/tools/prune.js +16 -9
- package/dist/tools/review.js +4 -1
- package/dist/tools/transcript.js +324 -0
- package/dist/tools/triage.js +9 -6
- package/dist/tools/validate.js +5 -2
- package/dist/transcript.js +421 -0
- package/lib/client.js +966 -80
- package/lib/parse.js +302 -17
- package/package.json +1 -1
- package/src/approval-gate.ts +119 -0
- package/src/client/index.ts +807 -62
- package/src/config-loader.ts +16 -2
- package/src/config-write.ts +6 -4
- package/src/evidence.ts +69 -1
- package/src/git-scope.ts +34 -6
- package/src/index.ts +17 -6
- package/src/live.ts +185 -0
- package/src/meta-review.ts +24 -10
- package/src/method-scope.ts +5 -1
- package/src/paths.ts +5 -0
- package/src/review-scope.ts +11 -7
- package/src/review.ts +82 -25
- package/src/session-hooks.ts +90 -0
- package/src/skill-prompt.ts +101 -19
- package/src/tools/checkpoint.ts +10 -3
- package/src/tools/context.ts +14 -3
- package/src/tools/decision-log.ts +27 -10
- package/src/tools/fix.ts +114 -3
- package/src/tools/prune.ts +14 -11
- package/src/tools/review.ts +5 -2
- package/src/tools/transcript.ts +334 -0
- package/src/tools/triage.ts +9 -6
- package/src/tools/validate.ts +5 -2
- package/src/transcript.ts +475 -0
- package/src/types.ts +129 -0
package/dist/live.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/live.ts — live reviewer-activity feed for the iterate observatory (F1 live).
|
|
3
|
+
*
|
|
4
|
+
* Watches `tools/result` and, for tool calls we can attribute to a project root
|
|
5
|
+
* (the caller agent's session cwd), appends one line to an append-only NDJSON
|
|
6
|
+
* file `.iterate/transcript-live.ndjson`. The `iterate_transcript` tool then
|
|
7
|
+
* mixes the most recent entries into its `read` / `capture` results so the
|
|
8
|
+
* client observatory shows what reviewers are doing in near-real-time (which
|
|
9
|
+
* files they read, which fixes/rollbacks/diffs land, where the run is).
|
|
10
|
+
*
|
|
11
|
+
* Why project-scoped (not per-thread):
|
|
12
|
+
* Tool executions carry the calling agent's session cwd but NOT the workflow
|
|
13
|
+
* sub-agent's `dimension` / `round` label, so we cannot reliably attribute a
|
|
14
|
+
* read to a specific reviewer thread without inventing data. We therefore
|
|
15
|
+
* record honest project-level activity and never fabricate an attribution.
|
|
16
|
+
* Per-thread narration stays the job of the final `iterate_transcript capture`.
|
|
17
|
+
*
|
|
18
|
+
* Safety:
|
|
19
|
+
* - Read-only observer: never mutates source files; writes only the NDJSON
|
|
20
|
+
* live file under `.iterate/`.
|
|
21
|
+
* - The live file is byte-capped (rewrite to last N lines when it grows too
|
|
22
|
+
* large) so it can never grow unbounded.
|
|
23
|
+
* - Any capture failure is swallowed (fire-and-forget) so it can never block
|
|
24
|
+
* or crash a tool call.
|
|
25
|
+
*/
|
|
26
|
+
import { mkdir, readFile, writeFile, stat, appendFile, rename } from 'node:fs/promises';
|
|
27
|
+
import { existsSync } from 'node:fs';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
import { resolveProjectRoot } from "./config-loader.js";
|
|
30
|
+
/** Keep at most this many live activity entries. */
|
|
31
|
+
export const LIVE_MAX_ENTRIES = 300;
|
|
32
|
+
/** Rewrite the live file when its byte size exceeds this threshold. */
|
|
33
|
+
export const LIVE_MAX_BYTES = 64 * 1024;
|
|
34
|
+
/** File path of the live NDJSON feed for a project root. */
|
|
35
|
+
export function liveFilePath(projectRoot) {
|
|
36
|
+
return join(projectRoot, '.iterate', 'transcript-live.ndjson');
|
|
37
|
+
}
|
|
38
|
+
/** Resolve the project root a tool execution belongs to, if any. */
|
|
39
|
+
function projectRootOf(exec) {
|
|
40
|
+
const cwd = exec.agent?.session?.header?.cwd;
|
|
41
|
+
if (!cwd)
|
|
42
|
+
return null;
|
|
43
|
+
const resolved = resolveProjectRoot(undefined, cwd);
|
|
44
|
+
return resolved.ok ? resolved.root : null;
|
|
45
|
+
}
|
|
46
|
+
/** Classify a settled tool call into a live activity entry, or null to skip. */
|
|
47
|
+
export function classifyTool(name, args, projectRoot) {
|
|
48
|
+
// `read_file` is the dsh-native file reader reviewers use to inspect code.
|
|
49
|
+
if (name === 'read_file') {
|
|
50
|
+
const file = args && typeof args === 'object' && typeof args.path === 'string'
|
|
51
|
+
? args.path
|
|
52
|
+
: '';
|
|
53
|
+
return file ? { ts: new Date().toISOString(), type: 'read', tool: name, target: file } : null;
|
|
54
|
+
}
|
|
55
|
+
// The iterate plugin's own tools — surface what the workflow is doing live.
|
|
56
|
+
const records = {
|
|
57
|
+
iterate_fix: 'fix',
|
|
58
|
+
iterate_rollback: 'rollback',
|
|
59
|
+
iterate_diff: 'diff',
|
|
60
|
+
iterate_review: 'review',
|
|
61
|
+
iterate_triage: 'triage',
|
|
62
|
+
iterate_checkpoint: 'checkpoint',
|
|
63
|
+
iterate_validate: 'validate',
|
|
64
|
+
iterate_decision_log: 'log',
|
|
65
|
+
iterate_history: 'info',
|
|
66
|
+
iterate_prune: 'prune',
|
|
67
|
+
iterate_transcript: 'log',
|
|
68
|
+
iterate_status: 'info',
|
|
69
|
+
iterate_config: 'info',
|
|
70
|
+
iterate_context: 'info',
|
|
71
|
+
};
|
|
72
|
+
const type = records[name];
|
|
73
|
+
if (!type)
|
|
74
|
+
return null;
|
|
75
|
+
let target = '';
|
|
76
|
+
if (args && typeof args === 'object') {
|
|
77
|
+
const a = args;
|
|
78
|
+
if (typeof a.file === 'string' && a.file)
|
|
79
|
+
target = a.file;
|
|
80
|
+
else if (typeof a.path === 'string' && a.path)
|
|
81
|
+
target = a.path;
|
|
82
|
+
else if (typeof a.operation === 'string' && a.operation)
|
|
83
|
+
target = a.operation;
|
|
84
|
+
else if (name === 'iterate_rollback' && typeof a.id === 'string' && a.id) {
|
|
85
|
+
target = `fix ${a.id}`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (!target)
|
|
89
|
+
target = name;
|
|
90
|
+
return { ts: new Date().toISOString(), type, tool: name, target };
|
|
91
|
+
}
|
|
92
|
+
/** Append one activity record to the project's live feed (byte-capped). */
|
|
93
|
+
export async function appendLive(projectRoot, entry) {
|
|
94
|
+
const file = liveFilePath(projectRoot);
|
|
95
|
+
const line = JSON.stringify(entry) + '\n';
|
|
96
|
+
await mkdir(join(projectRoot, '.iterate'), { recursive: true });
|
|
97
|
+
// Amortized O(1): only read+rewrite when the file has grown past the cap.
|
|
98
|
+
try {
|
|
99
|
+
const st = await stat(file).catch(() => null);
|
|
100
|
+
if (st && st.size > LIVE_MAX_BYTES) {
|
|
101
|
+
const raw = await readFile(file, 'utf-8');
|
|
102
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
103
|
+
const tail = lines.slice(-LIVE_MAX_ENTRIES);
|
|
104
|
+
const tmp = `${file}.trim.tmp`;
|
|
105
|
+
await writeFile(tmp, tail.join('\n') + '\n', 'utf-8');
|
|
106
|
+
await rename(tmp, file);
|
|
107
|
+
}
|
|
108
|
+
await appendFile(file, line, 'utf-8');
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// Fire-and-forget: never let live capture break a tool call.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** Read the live feed (newest first), capped at the last LIVE_MAX_ENTRIES. */
|
|
115
|
+
export async function readLive(projectRoot) {
|
|
116
|
+
const file = liveFilePath(projectRoot);
|
|
117
|
+
if (!existsSync(file))
|
|
118
|
+
return [];
|
|
119
|
+
try {
|
|
120
|
+
const raw = await readFile(file, 'utf-8');
|
|
121
|
+
const entries = [];
|
|
122
|
+
for (const line of raw.split('\n')) {
|
|
123
|
+
if (!line.trim())
|
|
124
|
+
continue;
|
|
125
|
+
try {
|
|
126
|
+
const parsed = JSON.parse(line);
|
|
127
|
+
if (parsed && typeof parsed.ts === 'string' && typeof parsed.type === 'string') {
|
|
128
|
+
entries.push(parsed);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// skip malformed lines
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return entries.slice(-LIVE_MAX_ENTRIES).reverse();
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Register a `tools/result` observer that captures reviewer activity into the
|
|
143
|
+
* project's live feed. Fire-and-forget; failures are swallowed.
|
|
144
|
+
*/
|
|
145
|
+
export function registerLiveCapture(ctx) {
|
|
146
|
+
ctx.on('tools/result', (exec) => {
|
|
147
|
+
const root = projectRootOf(exec);
|
|
148
|
+
if (!root)
|
|
149
|
+
return;
|
|
150
|
+
const entry = classifyTool(exec.name, exec.arguments, root);
|
|
151
|
+
if (!entry)
|
|
152
|
+
return;
|
|
153
|
+
void appendLive(root, entry);
|
|
154
|
+
});
|
|
155
|
+
}
|
package/dist/meta-review.js
CHANGED
|
@@ -14,8 +14,13 @@
|
|
|
14
14
|
* lives here.
|
|
15
15
|
*/
|
|
16
16
|
import { sortFindings } from "./review.js";
|
|
17
|
-
/**
|
|
18
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Number of distinct consistency checks performed by `metaReviewReport`.
|
|
19
|
+
* The check set is: COUNT_MATCH, SEVERITY_SUM, DIMENSION_SUM, DIMENSION_UNKNOWN,
|
|
20
|
+
* SORT_ORDER, CONVERGENCE_SUM, CONVERGENCE_FLAG, ROUND_NUMBER, ROUND_EMPTY,
|
|
21
|
+
* ROUND_GAP.
|
|
22
|
+
*/
|
|
23
|
+
export const META_REVIEW_CHECKS = 10;
|
|
19
24
|
/**
|
|
20
25
|
* How many uncovered scope files are listed in a COVERAGE_GAP hint before the
|
|
21
26
|
* remainder is folded into a "+N more" suffix.
|
|
@@ -146,9 +151,18 @@ export function metaReviewReport(report) {
|
|
|
146
151
|
'which finding nothing new is the expected success signal.');
|
|
147
152
|
}
|
|
148
153
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
154
|
+
// ROUND_GAP: only flag gaps WITHIN the range of actually-present round
|
|
155
|
+
// numbers. Non-contiguous starts (e.g. a resumed run beginning at round 5)
|
|
156
|
+
// and arbitrary round numbering are supported by the aggregate engine, so
|
|
157
|
+
// missing 1..N prefixes are NOT defects. Checks min..max of present rounds.
|
|
158
|
+
const present = [...seenRounds].sort((a, b) => a - b);
|
|
159
|
+
if (present.length > 0) {
|
|
160
|
+
const min = present[0];
|
|
161
|
+
const max = present[present.length - 1];
|
|
162
|
+
for (let i = min; i <= max; i++) {
|
|
163
|
+
if (!seenRounds.has(i)) {
|
|
164
|
+
add('ROUND_GAP', 'medium', `Round ${i} is missing from the round sequence`, `rounds present: ${present.join(', ')}.`);
|
|
165
|
+
}
|
|
152
166
|
}
|
|
153
167
|
}
|
|
154
168
|
const passed = issues.length === 0;
|
package/dist/method-scope.js
CHANGED
|
@@ -70,6 +70,8 @@ const SIGNATURE_PATTERNS = [
|
|
|
70
70
|
export function collectMethodSignatures(text) {
|
|
71
71
|
const lines = text.split('\n');
|
|
72
72
|
const out = [];
|
|
73
|
+
// Set lookup instead of scanning the growing array (O(S²) → O(S)).
|
|
74
|
+
const seen = new Set();
|
|
73
75
|
for (let i = 0; i < lines.length; i++) {
|
|
74
76
|
const raw = lines[i];
|
|
75
77
|
const line = i + 1;
|
|
@@ -81,8 +83,10 @@ export function collectMethodSignatures(text) {
|
|
|
81
83
|
if (!name || RESERVED_WORDS.has(name) || CALLABLE_NOISE.has(name))
|
|
82
84
|
continue;
|
|
83
85
|
// Avoid two patterns claiming the same line (e.g. TS method + arrow).
|
|
84
|
-
|
|
86
|
+
const key = `${line}|${name}`;
|
|
87
|
+
if (seen.has(key))
|
|
85
88
|
break;
|
|
89
|
+
seen.add(key);
|
|
86
90
|
out.push({ name, line });
|
|
87
91
|
break;
|
|
88
92
|
}
|
package/dist/paths.js
CHANGED
|
@@ -30,3 +30,7 @@ export function fixBackupPath(projectRoot, id, timestamp) {
|
|
|
30
30
|
export function checkpointPath(projectRoot) {
|
|
31
31
|
return join(iterateDir(projectRoot), 'checkpoint.json');
|
|
32
32
|
}
|
|
33
|
+
/** Runtime-observatory transcript file (JSON). */
|
|
34
|
+
export function transcriptPath(projectRoot) {
|
|
35
|
+
return join(iterateDir(projectRoot), 'transcript.json');
|
|
36
|
+
}
|
package/dist/review-scope.js
CHANGED
|
@@ -99,22 +99,25 @@ function collectChanged(changedFiles) {
|
|
|
99
99
|
return [...out].sort();
|
|
100
100
|
}
|
|
101
101
|
function collectFull(root) {
|
|
102
|
-
// Deterministic
|
|
103
|
-
//
|
|
102
|
+
// Deterministic iterative walk (explicit stack — unbounded recursion could
|
|
103
|
+
// overflow on pathologically deep trees); a code reviewer never anchors
|
|
104
|
+
// findings to lock files, images, or vendored builds.
|
|
104
105
|
const out = [];
|
|
105
|
-
const
|
|
106
|
+
const stack = [root];
|
|
107
|
+
while (stack.length > 0) {
|
|
108
|
+
const dir = stack.pop();
|
|
106
109
|
let entries;
|
|
107
110
|
try {
|
|
108
111
|
entries = readdirSync(dir, { withFileTypes: true });
|
|
109
112
|
}
|
|
110
113
|
catch {
|
|
111
|
-
|
|
114
|
+
continue;
|
|
112
115
|
}
|
|
113
116
|
for (const entry of entries) {
|
|
114
117
|
const abs = join(dir, entry.name);
|
|
115
118
|
if (entry.isDirectory()) {
|
|
116
119
|
if (!isIgnoredDir(entry.name))
|
|
117
|
-
|
|
120
|
+
stack.push(abs);
|
|
118
121
|
continue;
|
|
119
122
|
}
|
|
120
123
|
if (!entry.isFile())
|
|
@@ -124,13 +127,14 @@ function collectFull(root) {
|
|
|
124
127
|
const rel = abs.startsWith(root + SEP) ? abs.slice(root.length + 1) : abs;
|
|
125
128
|
out.push(rel.split(SEP).join(SEP));
|
|
126
129
|
}
|
|
127
|
-
}
|
|
128
|
-
walk(root);
|
|
130
|
+
}
|
|
129
131
|
return out.sort();
|
|
130
132
|
}
|
|
131
133
|
/** Split `files` into stable batches, keeping directory runs together. */
|
|
132
134
|
export function chunkFiles(files, perChunk) {
|
|
133
|
-
|
|
135
|
+
// Number.isFinite: NaN fails `perChunk < 1` and would yield one unbounded
|
|
136
|
+
// chunk (current.length >= NaN is never true).
|
|
137
|
+
const size = Number.isFinite(perChunk) && perChunk >= 1 ? perChunk : DEFAULT_SCOPE_CHUNK_SIZE;
|
|
134
138
|
const ordered = [...files].sort();
|
|
135
139
|
const chunks = [];
|
|
136
140
|
let current = [];
|
package/dist/review.js
CHANGED
|
@@ -35,10 +35,12 @@ export function sortFindings(findings) {
|
|
|
35
35
|
const bySeverity = rankA - rankB;
|
|
36
36
|
if (bySeverity !== 0)
|
|
37
37
|
return bySeverity;
|
|
38
|
-
|
|
38
|
+
// Defensive coercion: `file`/`line` can be wrong-typed when schema
|
|
39
|
+
// validation is disabled — String()/Number() keep the comparator total.
|
|
40
|
+
const byFile = String(a.file ?? '').localeCompare(String(b.file ?? ''));
|
|
39
41
|
if (byFile !== 0)
|
|
40
42
|
return byFile;
|
|
41
|
-
return (a.line
|
|
43
|
+
return (Number(a.line) || 0) - (Number(b.line) || 0);
|
|
42
44
|
});
|
|
43
45
|
}
|
|
44
46
|
/** Normalize a summary so near-identical duplicates collapse to one key. */
|
|
@@ -48,9 +50,15 @@ export function normalizeSummary(summary) {
|
|
|
48
50
|
.toLowerCase()
|
|
49
51
|
.replace(/[\s\n\t]+/g, ' ');
|
|
50
52
|
}
|
|
51
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Dedupe key: same file + same dimension + similar summary + explicit line.
|
|
55
|
+
* Including the line keeps two genuine issues with identical wording at
|
|
56
|
+
* different locations from collapsing into one (the line is omitted only when
|
|
57
|
+
* neither side anchors one, i.e. whole-file findings).
|
|
58
|
+
*/
|
|
52
59
|
export function findingKey(f) {
|
|
53
|
-
|
|
60
|
+
const line = typeof f.line === 'number' && f.line > 0 ? f.line : 0;
|
|
61
|
+
return `${f.file}|${f.dimension}|${line}|${normalizeSummary(f.summary)}`;
|
|
54
62
|
}
|
|
55
63
|
/**
|
|
56
64
|
* Remove duplicate findings within a list.
|
|
@@ -109,14 +117,22 @@ export function aggregateRounds(rounds, maxReviewRounds) {
|
|
|
109
117
|
const firstRoundByKey = new Map();
|
|
110
118
|
const merged = [];
|
|
111
119
|
// Guard: round numbers are expected to be positive integers. Skip malformed
|
|
112
|
-
// entries defensively rather than letting `firstRoundByKey` key on NaN/0
|
|
120
|
+
// entries defensively rather than letting `firstRoundByKey` key on NaN/0 or
|
|
121
|
+
// crashing on null / non-array findings.
|
|
122
|
+
// Hard ceiling: round numbers are model-authored JSON; an absurd round (e.g.
|
|
123
|
+
// 1e9) would otherwise allocate an array of that size below (OOM). Round
|
|
124
|
+
// numbers above the configured cap are clamped to the cap.
|
|
113
125
|
let maxRound = 0;
|
|
126
|
+
const roundCap = Math.max(1, maxReviewRounds);
|
|
114
127
|
for (const round of rounds) {
|
|
128
|
+
if (!round || typeof round !== 'object')
|
|
129
|
+
continue;
|
|
115
130
|
if (typeof round.round !== 'number' || !Number.isInteger(round.round) || round.round < 1)
|
|
116
131
|
continue;
|
|
132
|
+
const findings = Array.isArray(round.findings) ? round.findings : [];
|
|
117
133
|
if (round.round > maxRound)
|
|
118
134
|
maxRound = round.round;
|
|
119
|
-
for (const f of
|
|
135
|
+
for (const f of findings) {
|
|
120
136
|
const key = findingKey(f);
|
|
121
137
|
if (seen.has(key))
|
|
122
138
|
continue;
|
|
@@ -125,8 +141,10 @@ export function aggregateRounds(rounds, maxReviewRounds) {
|
|
|
125
141
|
merged.push(f);
|
|
126
142
|
}
|
|
127
143
|
}
|
|
144
|
+
// Clamp the allocation bound so a hostile round number cannot OOM the tool.
|
|
145
|
+
const effectiveMax = Math.min(maxRound, Math.max(1, roundCap * 2));
|
|
128
146
|
const findingsByRound = [];
|
|
129
|
-
for (let r = 1; r <=
|
|
147
|
+
for (let r = 1; r <= effectiveMax; r++) {
|
|
130
148
|
let count = 0;
|
|
131
149
|
for (const key of firstRoundByKey.keys()) {
|
|
132
150
|
if (firstRoundByKey.get(key) === r)
|
|
@@ -143,11 +161,20 @@ export function computeConvergence(rounds, maxReviewRounds) {
|
|
|
143
161
|
const { findingsByRound } = aggregateRounds(rounds, maxReviewRounds);
|
|
144
162
|
const totalRounds = rounds.length;
|
|
145
163
|
// `findingsByRound` is indexed by the actual round number (round r → index
|
|
146
|
-
// r-1),
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
164
|
+
// r-1), sized to the highest present round (clamped). Convergence must read
|
|
165
|
+
// the HIGHEST PRESENT round's count — not the last array element (rounds
|
|
166
|
+
// may arrive unsorted) and not `totalRounds - 1` (only valid for contiguous
|
|
167
|
+
// 1..N). The count index is bounded by the array length aggregateRounds
|
|
168
|
+
// actually allocated.
|
|
169
|
+
let lastRound = 0;
|
|
170
|
+
for (const round of rounds) {
|
|
171
|
+
if (!round || typeof round.round !== 'number' || !Number.isInteger(round.round) || round.round < 1)
|
|
172
|
+
continue;
|
|
173
|
+
if (round.round > lastRound)
|
|
174
|
+
lastRound = round.round;
|
|
175
|
+
}
|
|
176
|
+
const idx = Math.min(lastRound, findingsByRound.length) - 1;
|
|
177
|
+
const lastRoundCount = idx >= 0 ? (findingsByRound[idx] ?? 0) : 0;
|
|
151
178
|
const converged = totalRounds > 0 && lastRoundCount === 0;
|
|
152
179
|
return {
|
|
153
180
|
totalRounds,
|
|
@@ -193,8 +220,9 @@ function summarize(findings) {
|
|
|
193
220
|
export function buildReviewReport(input) {
|
|
194
221
|
// 1. Filter known-intentional per round (before cross-round dedupe).
|
|
195
222
|
const filteredRounds = input.rounds.map((r) => ({
|
|
196
|
-
round: r.round,
|
|
197
|
-
findings: filterKnownIntentional(r.findings, input.knownIntentional),
|
|
223
|
+
round: typeof r?.round === 'number' ? r.round : 0,
|
|
224
|
+
findings: filterKnownIntentional(Array.isArray(r?.findings) ? r.findings : [], input.knownIntentional),
|
|
225
|
+
readFiles: Array.isArray(r?.readFiles) ? r.readFiles : [],
|
|
198
226
|
}));
|
|
199
227
|
// 2. Cross-round dedupe + per-round "first seen" tracking.
|
|
200
228
|
const { findings, findingsByRound } = aggregateRounds(filteredRounds, input.maxReviewRounds);
|
|
@@ -221,6 +249,9 @@ export function buildReviewReport(input) {
|
|
|
221
249
|
maxReviewRounds: input.maxReviewRounds,
|
|
222
250
|
rounds: filteredRounds,
|
|
223
251
|
findings: sorted,
|
|
252
|
+
// Aggregate of every round's self-reported reads, so the meta-review
|
|
253
|
+
// coverage gate can compare against the assigned inventory.
|
|
254
|
+
readFiles: [].concat(...filteredRounds.map((r) => r.readFiles ?? [])),
|
|
224
255
|
convergence: {
|
|
225
256
|
totalRounds: filteredRounds.length,
|
|
226
257
|
findingsByRound,
|
|
@@ -394,8 +425,8 @@ export function validateFindingsSchema(input) {
|
|
|
394
425
|
*/
|
|
395
426
|
export function validateRoundsSchema(rounds) {
|
|
396
427
|
return rounds.map((r) => {
|
|
397
|
-
const issues = validateFindingsSchema(r.findings);
|
|
398
|
-
return { round: r.round, valid: issues.length === 0, issues };
|
|
428
|
+
const issues = validateFindingsSchema(Array.isArray(r?.findings) ? r.findings : []);
|
|
429
|
+
return { round: typeof r?.round === 'number' ? r.round : 0, valid: issues.length === 0, issues };
|
|
399
430
|
});
|
|
400
431
|
}
|
|
401
432
|
/**
|
|
@@ -411,19 +442,25 @@ export function validateRoundsSchema(rounds) {
|
|
|
411
442
|
*/
|
|
412
443
|
export function sanitizeRounds(rounds, schemaValidation) {
|
|
413
444
|
return rounds.map((r, i) => {
|
|
445
|
+
// Defensive: malformed rounds must never crash the deterministic core.
|
|
446
|
+
const findings = Array.isArray(r?.findings) ? r.findings : [];
|
|
447
|
+
const roundNo = typeof r?.round === 'number' ? r.round : 0;
|
|
448
|
+
const readFiles = Array.isArray(r?.readFiles) ? r.readFiles : [];
|
|
414
449
|
if (schemaValidation) {
|
|
415
450
|
const issues = schemaValidation[i]?.issues ?? [];
|
|
416
451
|
if (issues.some((iss) => iss.index === -1))
|
|
417
|
-
return { round:
|
|
452
|
+
return { round: roundNo, findings: [], readFiles };
|
|
418
453
|
const bad = new Set(issues.map((iss) => iss.index));
|
|
419
454
|
return {
|
|
420
|
-
round:
|
|
421
|
-
findings:
|
|
455
|
+
round: roundNo,
|
|
456
|
+
findings: findings.filter((_, fi) => !bad.has(fi)),
|
|
457
|
+
readFiles,
|
|
422
458
|
};
|
|
423
459
|
}
|
|
424
460
|
return {
|
|
425
|
-
round:
|
|
426
|
-
findings:
|
|
461
|
+
round: roundNo,
|
|
462
|
+
findings: findings.filter((f) => Boolean(f) && typeof f === 'object' && !Array.isArray(f)),
|
|
463
|
+
readFiles,
|
|
427
464
|
};
|
|
428
465
|
});
|
|
429
466
|
}
|
|
@@ -435,6 +472,9 @@ export function sanitizeRounds(rounds, schemaValidation) {
|
|
|
435
472
|
export function reviewerTaskPrompt(input) {
|
|
436
473
|
const parts = [];
|
|
437
474
|
parts.push(`You are the "${input.dimension}" reviewer for the iterate review.`, `Goal: ${input.goal}`, `Scope: ${input.scope === 'full' ? 'entire codebase' : 'changed files only'}.`);
|
|
475
|
+
if (input.focus) {
|
|
476
|
+
parts.push(`FOCUS: ${input.focus}`);
|
|
477
|
+
}
|
|
438
478
|
if (input.scopeFiles && input.scopeFiles.length > 0) {
|
|
439
479
|
parts.push('COVERAGE RULE (mandatory): below is the exact file inventory you are ' +
|
|
440
480
|
'assigned to review. You MUST open EVERY file in this inventory with ' +
|
|
@@ -465,9 +505,9 @@ export function reviewerTaskPrompt(input) {
|
|
|
465
505
|
'disqualifying failure, and fabricated line numbers are treated as ' +
|
|
466
506
|
'poisoned evidence. Anchor every finding to real code.');
|
|
467
507
|
parts.push(`Return a JSON object: {"findings": [...], "readFiles": [...]}.`, `Each finding: dimension (must be "${input.dimension}"), file (relative path), ` +
|
|
468
|
-
'line (
|
|
469
|
-
'
|
|
470
|
-
'
|
|
508
|
+
'line (optional; the exact line you READ for a line-targeted issue; ' +
|
|
509
|
+
'0 or omitted for whole-file/module-level issues), ' +
|
|
510
|
+
'severity (critical/high/medium/low), summary (one line), ' +
|
|
471
511
|
'failure_scenario (how/when it fails, backed by the code you actually ' +
|
|
472
512
|
'read), suggested_fix (the concrete fix), ' +
|
|
473
513
|
`is_atomic (true if the fix is <= ${input.maxLines} lines within a SINGLE file/function, else false).`, `Write summaries and details in ${input.outputLanguage}.`);
|
|
@@ -508,6 +548,17 @@ export function buildReviewPlan(input) {
|
|
|
508
548
|
batches = [undefined];
|
|
509
549
|
}
|
|
510
550
|
const dimensionTasks = [];
|
|
551
|
+
// personalization.dimension_focus: [{dimension, focus}] — appended to the
|
|
552
|
+
// matching dimension's reviewer prompt.
|
|
553
|
+
const focusMap = new Map();
|
|
554
|
+
const pf = input.config.personalization;
|
|
555
|
+
if (pf && Array.isArray(pf.dimension_focus)) {
|
|
556
|
+
for (const entry of pf.dimension_focus) {
|
|
557
|
+
if (entry && typeof entry.dimension === 'string' && typeof entry.focus === 'string' && entry.focus) {
|
|
558
|
+
focusMap.set(entry.dimension, entry.focus);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
511
562
|
for (const d of dimensions) {
|
|
512
563
|
batches.forEach((batch, index) => {
|
|
513
564
|
const dimensionId = batches.length === 1 ? d : `${d}#${index + 1}`;
|
|
@@ -523,6 +574,7 @@ export function buildReviewPlan(input) {
|
|
|
523
574
|
maxLines,
|
|
524
575
|
changedFiles: effectiveChangedOnly ? changedFiles : undefined,
|
|
525
576
|
scopeFiles: batch,
|
|
577
|
+
focus: focusMap.get(d),
|
|
526
578
|
}),
|
|
527
579
|
findingsSchema: findingsSchema(),
|
|
528
580
|
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/session-hooks.ts — dsh pipeline hooks for the iterate observatory (F8).
|
|
3
|
+
*
|
|
4
|
+
* Wires the {@link decideApproval} policy gate to dsh's `tools/pre-execute`
|
|
5
|
+
* waterfall. This is the AUTHORITATIVE approval seam for destructive iterate
|
|
6
|
+
* tools (`iterate_fix` / `iterate_rollback` / `iterate_prune` with dryRun:false):
|
|
7
|
+
*
|
|
8
|
+
* - `allow` policy → the call runs.
|
|
9
|
+
* - `deny` policy → the call is refused (fail-closed), surfaced as an
|
|
10
|
+
* error to the model.
|
|
11
|
+
* - `ask` policy → return `{ kind: 'ask', reason }`; dsh's own
|
|
12
|
+
* scheduler routes it through the `approval` service
|
|
13
|
+
* (see `@deepseek-ai/dsh-user-approval`), which
|
|
14
|
+
* prompts the human and audits an approve/deny pair
|
|
15
|
+
* on the session.
|
|
16
|
+
*
|
|
17
|
+
* We deliberately do NOT also add `approved` flags inside the tool bodies:
|
|
18
|
+
* the pre-execute waterfall consumes the human decision before the tool runs,
|
|
19
|
+
* so a second tool-internal gate would double-ask. This one gate is enough and
|
|
20
|
+
* stays dsh-native.
|
|
21
|
+
*
|
|
22
|
+
* Safety properties:
|
|
23
|
+
* - Read-only tools and non-iterate tools are always allowed (the gate only
|
|
24
|
+
* inspects the three destructive iterate toolnames).
|
|
25
|
+
* - If the project root / observatory config cannot be resolved, the policy
|
|
26
|
+
* degrades to `ask` (fail-safe: destructive writes always require consent).
|
|
27
|
+
*/
|
|
28
|
+
import { loadEffectiveConfig, resolveProjectRoot } from "./config-loader.js";
|
|
29
|
+
import { decideApproval, isDestructiveIterateTool } from "./approval-gate.js";
|
|
30
|
+
/**
|
|
31
|
+
* Build the per-call approval decision for a tool execution.
|
|
32
|
+
* Returns a dsh `PreToolDecision` so the caller can short-circuit the caller.
|
|
33
|
+
*/
|
|
34
|
+
export function gateDecision(exec) {
|
|
35
|
+
// Importing the decision, and only inspecting our own tools, keeps unrelated
|
|
36
|
+
// tooling untouched. Anything we cannot classify is allowed by default.
|
|
37
|
+
if (!isDestructiveIterateTool(exec.name))
|
|
38
|
+
return { kind: 'allow' };
|
|
39
|
+
// Resolve the project root (use the call's own `path` arg, else the agent's
|
|
40
|
+
// session cwd) to read the effective observatory policy.
|
|
41
|
+
const argPath = typeof exec.arguments === 'object' && exec.arguments && !Array.isArray(exec.arguments)
|
|
42
|
+
&& typeof exec.arguments.path === 'string'
|
|
43
|
+
? exec.arguments.path
|
|
44
|
+
: undefined;
|
|
45
|
+
const sessionCwd = exec.agent?.session?.header?.cwd;
|
|
46
|
+
const resolved = resolveProjectRoot(argPath, sessionCwd);
|
|
47
|
+
let policy = 'ask';
|
|
48
|
+
if (resolved.ok) {
|
|
49
|
+
const { config } = loadEffectiveConfig(resolved.root);
|
|
50
|
+
const p = config.observatory?.approval;
|
|
51
|
+
if (p === 'deny')
|
|
52
|
+
policy = 'deny';
|
|
53
|
+
else if (p === 'allow')
|
|
54
|
+
policy = 'allow';
|
|
55
|
+
// anything else (including a corrupt/missing `ask`) → 'ask'
|
|
56
|
+
}
|
|
57
|
+
const decision = decideApproval(exec, policy);
|
|
58
|
+
if (decision.kind === 'deny')
|
|
59
|
+
return { kind: 'deny', reason: decision.reason };
|
|
60
|
+
if (decision.kind === 'ask')
|
|
61
|
+
return { kind: 'ask', reason: decision.reason };
|
|
62
|
+
return { kind: 'allow' };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Register the `tools/pre-execute` waterfall listener that applies the
|
|
66
|
+
* observatory approval gate to every destructive iterate tool call.
|
|
67
|
+
*/
|
|
68
|
+
export function registerSessionHooks(ctx) {
|
|
69
|
+
ctx.on('tools/pre-execute', (exec, next) => {
|
|
70
|
+
// Never let a throwing gate break the pipeline — degrade to allow.
|
|
71
|
+
let decision;
|
|
72
|
+
try {
|
|
73
|
+
decision = gateDecision(exec);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return next();
|
|
77
|
+
}
|
|
78
|
+
if (decision.kind === 'ask') {
|
|
79
|
+
// Delegate the actual human-consent prompt + audit to dsh's approval
|
|
80
|
+
// service via the scheduler's `ask` path. `next()` here would short-circuit
|
|
81
|
+
// to allow, which would bypass consent — so return our ask decision.
|
|
82
|
+
return Promise.resolve(decision);
|
|
83
|
+
}
|
|
84
|
+
if (decision.kind === 'deny') {
|
|
85
|
+
return Promise.resolve(decision);
|
|
86
|
+
}
|
|
87
|
+
return next();
|
|
88
|
+
});
|
|
89
|
+
}
|