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.
Files changed (55) hide show
  1. package/README.md +42 -2
  2. package/README.zh-CN.md +40 -2
  3. package/dist/approval-gate.js +92 -0
  4. package/dist/config-loader.js +18 -3
  5. package/dist/config-write.js +7 -4
  6. package/dist/evidence.js +67 -1
  7. package/dist/git-scope.js +35 -6
  8. package/dist/index.js +15 -5
  9. package/dist/live.js +155 -0
  10. package/dist/meta-review.js +19 -5
  11. package/dist/method-scope.js +5 -1
  12. package/dist/paths.js +4 -0
  13. package/dist/review-scope.js +12 -8
  14. package/dist/review.js +76 -24
  15. package/dist/session-hooks.js +89 -0
  16. package/dist/skill-prompt.js +101 -19
  17. package/dist/tools/checkpoint.js +10 -3
  18. package/dist/tools/context.js +16 -4
  19. package/dist/tools/decision-log.js +29 -9
  20. package/dist/tools/fix.js +120 -3
  21. package/dist/tools/prune.js +16 -9
  22. package/dist/tools/review.js +4 -1
  23. package/dist/tools/transcript.js +324 -0
  24. package/dist/tools/triage.js +9 -6
  25. package/dist/tools/validate.js +5 -2
  26. package/dist/transcript.js +421 -0
  27. package/lib/client.js +966 -80
  28. package/lib/parse.js +302 -17
  29. package/package.json +1 -1
  30. package/src/approval-gate.ts +119 -0
  31. package/src/client/index.ts +807 -62
  32. package/src/config-loader.ts +16 -2
  33. package/src/config-write.ts +6 -4
  34. package/src/evidence.ts +69 -1
  35. package/src/git-scope.ts +34 -6
  36. package/src/index.ts +17 -6
  37. package/src/live.ts +185 -0
  38. package/src/meta-review.ts +24 -10
  39. package/src/method-scope.ts +5 -1
  40. package/src/paths.ts +5 -0
  41. package/src/review-scope.ts +11 -7
  42. package/src/review.ts +82 -25
  43. package/src/session-hooks.ts +90 -0
  44. package/src/skill-prompt.ts +101 -19
  45. package/src/tools/checkpoint.ts +10 -3
  46. package/src/tools/context.ts +14 -3
  47. package/src/tools/decision-log.ts +27 -10
  48. package/src/tools/fix.ts +114 -3
  49. package/src/tools/prune.ts +14 -11
  50. package/src/tools/review.ts +5 -2
  51. package/src/tools/transcript.ts +334 -0
  52. package/src/tools/triage.ts +9 -6
  53. package/src/tools/validate.ts +5 -2
  54. package/src/transcript.ts +475 -0
  55. 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
+ }
@@ -14,8 +14,13 @@
14
14
  * lives here.
15
15
  */
16
16
  import { sortFindings } from "./review.js";
17
- /** Number of distinct consistency checks performed by `metaReviewReport`. */
18
- export const META_REVIEW_CHECKS = 6;
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
- for (let i = 1; i <= rounds.length; i++) {
150
- if (!seenRounds.has(i)) {
151
- add('ROUND_GAP', 'medium', `Round ${i} is missing from the round sequence`, `rounds present: ${[...seenRounds].sort((a, b) => a - b).join(', ') || 'none'}.`);
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;
@@ -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
- if (out.some((s) => s.line === line && s.name === name))
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
+ }
@@ -99,22 +99,25 @@ function collectChanged(changedFiles) {
99
99
  return [...out].sort();
100
100
  }
101
101
  function collectFull(root) {
102
- // Deterministic recursive walk built on Node's fs; a code reviewer never
103
- // anchors findings to lock files, images, or vendored builds.
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 walk = (dir) => {
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
- return;
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
- walk(abs);
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
- const size = perChunk === undefined || perChunk < 1 ? DEFAULT_SCOPE_CHUNK_SIZE : perChunk;
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
- const byFile = a.file.localeCompare(b.file);
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 ?? 0) - (b.line ?? 0);
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
- /** Dedupe key: same file + same dimension + similar summary. */
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
- return `${f.file}|${f.dimension}|${normalizeSummary(f.summary)}`;
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 round.findings) {
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 <= maxRound; 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), so convergence must read the LAST PRESENT round's count using its
147
- // reported round number — not `totalRounds - 1`, which is only valid for
148
- // contiguous 1..N round numbers.
149
- const lastRound = totalRounds > 0 ? rounds[totalRounds - 1].round : 0;
150
- const lastRoundCount = lastRound > 0 ? (findingsByRound[lastRound - 1] ?? 0) : 0;
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: r.round, findings: [] };
452
+ return { round: roundNo, findings: [], readFiles };
418
453
  const bad = new Set(issues.map((iss) => iss.index));
419
454
  return {
420
- round: r.round,
421
- findings: r.findings.filter((_, fi) => !bad.has(fi)),
455
+ round: roundNo,
456
+ findings: findings.filter((_, fi) => !bad.has(fi)),
457
+ readFiles,
422
458
  };
423
459
  }
424
460
  return {
425
- round: r.round,
426
- findings: r.findings.filter((f) => Boolean(f) && typeof f === 'object' && !Array.isArray(f)),
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 (REQUIRED positive integer — the exact line you READ for an ' +
469
- 'anchored, line-targeted issue; use 0 for whole-file/module-level ' +
470
- 'issues), severity (critical/high/medium/low), summary (one line), ' +
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
+ }