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
@@ -1,4 +1,4 @@
1
- import { readFileSync } from 'node:fs'
1
+ import { existsSync, readFileSync } from 'node:fs'
2
2
  import { homedir } from 'node:os'
3
3
  import { join, resolve, sep } from 'node:path'
4
4
  import yaml from 'js-yaml'
@@ -56,6 +56,10 @@ export function defaultConfig(): IterateConfig {
56
56
  coverage_validation: true,
57
57
  scope_chunk_size: 25,
58
58
  },
59
+ observatory: {
60
+ capture: true,
61
+ approval: 'ask',
62
+ },
59
63
  }
60
64
  }
61
65
 
@@ -75,6 +79,10 @@ export function mergeConfig(
75
79
  const out: Record<string, unknown> = { ...base }
76
80
  for (const [key, value] of Object.entries(override)) {
77
81
  if (value === undefined) continue
82
+ // Prototype-pollution guard: a YAML `__proto__`/`constructor`/`prototype`
83
+ // key must never be plain-assigned — js-yaml stores __proto__ as an own
84
+ // data property, and `out[key] = value` would invoke the __proto__ setter.
85
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue
78
86
  const baseValue = out[key]
79
87
  if (
80
88
  baseValue &&
@@ -242,7 +250,13 @@ function effectiveCwd(sessionCwd?: string): string {
242
250
  if (encoded && encoded.startsWith('--') && encoded.endsWith('--')) {
243
251
  try {
244
252
  const decoded = decodeURIComponent(encoded.slice(2, -2).replace(/~/g, '%'))
245
- if (decoded && decoded.startsWith(sep)) return decoded
253
+ // The workspace encoding drops the leading root separator (`/Volumes/…`
254
+ // → `Volumes-…`), so re-attach it when absent. `~<hex>` → `%<hex>` is
255
+ // the documented percent spelling; '-' doubles as the '/' separator, so
256
+ // literal dashes in a path cannot round-trip — verify the result exists
257
+ // and fall through otherwise.
258
+ const candidate = decoded && !decoded.startsWith(sep) ? sep + decoded : decoded
259
+ if (candidate && candidate.startsWith(sep) && existsSync(candidate)) return candidate
246
260
  } catch {
247
261
  // malformed encoding — fall through to cwd
248
262
  }
@@ -10,7 +10,7 @@
10
10
  * config, always back up before writing, roll back on failure.
11
11
  */
12
12
 
13
- import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
13
+ import { copyFileSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
14
14
  import { join } from 'node:path'
15
15
  import yaml from 'js-yaml'
16
16
 
@@ -169,12 +169,14 @@ export function writeConfigFile(
169
169
  try {
170
170
  writeFileSync(configPath, yaml.dump(config, { noRefs: true }), 'utf-8')
171
171
  } catch (err) {
172
+ let rollbackError = ''
172
173
  try {
173
174
  if (backupPath) copyFileSync(backupPath, configPath)
174
- } catch {
175
- // Rollback failure is reported, never swallowed silently.
175
+ else if (existsSync(configPath)) rmSync(configPath, { force: true })
176
+ } catch (rbErr) {
177
+ rollbackError = `; rollback also failed: ${String(rbErr)}`
176
178
  }
177
- return { ok: false, error: `failed to write config: ${String(err)}` }
179
+ return { ok: false, error: `failed to write config: ${String(err)}${rollbackError}` }
178
180
  }
179
181
 
180
182
  return { ok: true, backupPath }
package/src/evidence.ts CHANGED
@@ -23,13 +23,21 @@
23
23
  * filesystem half (`verifyFinding`) to stay unit-testable without touching disk.
24
24
  */
25
25
 
26
- import { existsSync, readFileSync } from 'node:fs'
26
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
27
27
  import { resolve, sep } from 'node:path'
28
28
  import type { ReviewFinding } from './types.ts'
29
29
 
30
30
  /** Sentinel for whole-file findings (line 0 or omitted means the whole file). */
31
31
  export const WHOLE_FILE_LINE = 0
32
32
 
33
+ /**
34
+ * Hard cap on a single evidence file read. `verifyFinding` only needs the
35
+ * line count + a NUL check; reading an unbounded file (or a device file
36
+ * reached through a symlink) is a memory/hang hazard, so anything larger is
37
+ * treated as not line-addressable.
38
+ */
39
+ const MAX_EVIDENCE_BYTES = 10 * 1024 * 1024
40
+
33
41
  export type EvidenceError = 'file_not_found' | 'line_out_of_range'
34
42
 
35
43
  /** Per-finding attestation result. */
@@ -79,6 +87,22 @@ export function resolveWithin(root: string, rel: string): string | null {
79
87
  return resolved
80
88
  }
81
89
 
90
+ /** True when `candidate` is `root` itself or lexically inside `root`. */
91
+ function isWithin(root: string, candidate: string): boolean {
92
+ if (candidate === root) return true
93
+ const prefix = root.endsWith(sep) ? root : root + sep
94
+ return candidate.startsWith(prefix)
95
+ }
96
+
97
+ /** best-effort realpath; falls back to the lexical path on any failure. */
98
+ function safeRealpath(p: string): string {
99
+ try {
100
+ return realpathSync(p)
101
+ } catch {
102
+ return p
103
+ }
104
+ }
105
+
82
106
  /**
83
107
  * Pure check that `line` (if anchored) exists in `text`.
84
108
  * Whole-file findings (undefined/0) are always bounds-valid.
@@ -116,6 +140,50 @@ export function verifyFinding(
116
140
  }
117
141
  }
118
142
 
143
+ // Symlink containment: resolveWithin is lexical only, but existsSync /
144
+ // readFileSync follow symlinks. Verify the REAL path stays inside the REAL
145
+ // project root so a finding path can never read (or line-count) a file
146
+ // outside the project via a symlinked directory or file.
147
+ const rootReal = safeRealpath(root)
148
+ const real = safeRealpath(resolved)
149
+ if (!isWithin(rootReal, real)) {
150
+ return {
151
+ file: relFile,
152
+ line,
153
+ lineTotal: null,
154
+ resolvedPath: resolved,
155
+ verified: false,
156
+ error: 'file_not_found',
157
+ }
158
+ }
159
+
160
+ // Regular-file + size guard: a directory, device file (/dev/zero), FIFO or
161
+ // multi-GB file is not a line-addressable text target. statSync follows
162
+ // symlinks, so a link to a device still lands here and is rejected.
163
+ let st
164
+ try {
165
+ st = statSync(resolved)
166
+ } catch {
167
+ return {
168
+ file: relFile,
169
+ line,
170
+ lineTotal: null,
171
+ resolvedPath: resolved,
172
+ verified: false,
173
+ error: 'file_not_found',
174
+ }
175
+ }
176
+ if (!st.isFile() || st.size > MAX_EVIDENCE_BYTES) {
177
+ return {
178
+ file: relFile,
179
+ line,
180
+ lineTotal: null,
181
+ resolvedPath: resolved,
182
+ verified: false,
183
+ error: 'line_out_of_range',
184
+ }
185
+ }
186
+
119
187
  let raw: Buffer
120
188
  try {
121
189
  raw = readFileSync(resolved)
package/src/git-scope.ts CHANGED
@@ -38,14 +38,30 @@ export interface GitScopeResult {
38
38
  }
39
39
 
40
40
  /**
41
- * Parse `git diff --name-only` stdout into a list of relative paths.
42
- * Pure: strips blank lines, trims whitespace, drops quotes (git can quote
43
- * paths with special characters).
41
+ * Parse `git diff --name-only -z` stdout into a list of relative paths.
42
+ * Pure. NUL-delimited mode is machine-safe (handles any filename); when no
43
+ * NUL is present (callers that did not pass -z) fall back to newline-split
44
+ * with C-style quote/escape unescaping for core.quotePath output.
44
45
  */
45
46
  export function parseChangedFiles(stdout: string): string[] {
47
+ if (stdout.includes('\0')) {
48
+ return stdout.split('\0').map((s) => s.trim()).filter((s) => s.length > 0)
49
+ }
46
50
  return stdout
47
51
  .split('\n')
48
- .map((line) => line.trim().replace(/^"|"$/g, ''))
52
+ .map((line) => {
53
+ const trimmed = line.trim()
54
+ // git core.quotePath wraps paths with special characters in "..."; the
55
+ // content uses C-style escapes (\" \\ \t \n and \ooo octal for non-ASCII).
56
+ const quoted = trimmed.match(/^"(.*)"$/)
57
+ if (!quoted) return trimmed
58
+ return quoted[1]!
59
+ .replace(/\\"/g, '"')
60
+ .replace(/\\\\/g, '\\')
61
+ .replace(/\\t/g, '\t')
62
+ .replace(/\\n/g, '\n')
63
+ .replace(/\\([0-7]{3})/g, (_m, oct: string) => String.fromCharCode(parseInt(oct, 8)))
64
+ })
49
65
  .filter((line) => line.length > 0)
50
66
  }
51
67
 
@@ -118,9 +134,21 @@ export async function resolveChangedFiles(
118
134
  root: string,
119
135
  targetBranch: string,
120
136
  ): Promise<GitScopeResult> {
121
- const { ok, stdout, stderr } = await runGit(['diff', '--name-only', targetBranch, '--'], root)
137
+ // Option-injection guard: a branch name starting with '-' would be parsed by
138
+ // git as an option (e.g. --output=...), not a ref. Reject it outright.
139
+ if (typeof targetBranch !== 'string' || targetBranch.trim() === '' || targetBranch.startsWith('-')) {
140
+ return {
141
+ scope: 'full',
142
+ changedFiles: [],
143
+ fallbackToFull: true,
144
+ error: `invalid target branch "${String(targetBranch)}"`,
145
+ }
146
+ }
147
+ // -z: NUL-delimited names — machine-safe for any filename (spaces, quotes,
148
+ // non-ASCII), and never confused with option-like content.
149
+ const { ok, stdout, stderr } = await runGit(['diff', '--name-only', '-z', targetBranch, '--'], root)
122
150
  if (!ok) {
123
- const reason = stderr.trim() || `git diff --name-only ${targetBranch} failed`
151
+ const reason = stderr.trim() || `git diff --name-only -z ${targetBranch} failed`
124
152
  return { scope: 'full', changedFiles: [], fallbackToFull: true, error: reason }
125
153
  }
126
154
  const existing = filterExistingFiles(root, parseChangedFiles(stdout))
package/src/index.ts CHANGED
@@ -2,13 +2,15 @@
2
2
  * iterate-plugin — dsh plugin for the iterate autonomous closed-loop workflow
3
3
  *
4
4
  * Architecture:
5
- * - The plugin registers 13 tools (config, validate, decision-log, context, review,
6
- * triage, fix, diff, rollback, checkpoint, status, history, prune)
5
+ * - The plugin registers 14 tools (config, validate, decision-log, context, review,
6
+ * triage, fix, diff, rollback, checkpoint, status, history, prune, transcript)
7
7
  * - The plugin injects a system prompt section teaching the iterate workflow pattern
8
8
  * - The model (prompted by the skill) writes a workflow script using dsh's `workflow` tool
9
9
  * - The workflow script uses `agent()` / `parallel()` / `phase()` / `log()` to orchestrate
10
- * - Subagents use the 13 tools to do real work (read config, run validation, log decisions,
11
- * review, triage, apply/rollback/fixing, checkpoint, status, history, prune)
10
+ * - Subagents use the 14 tools to do real work (read config, run validation, log decisions,
11
+ * review, triage, apply/rollback/fixing, checkpoint, status, history, prune, transcript)
12
+ * - A `tools/pre-execute` hook gates destructive iterate calls behind human approval
13
+ * (F8 observatory approval policy: ask / deny / allow).
12
14
  *
13
15
  * Tool invocation model:
14
16
  * - Workflow script CANNOT call tools directly (sandboxed vm, no Node API)
@@ -34,13 +36,16 @@ import { registerFixTool, registerDiffTool, registerRollbackTool } from './tools
34
36
  import { registerCheckpointTool, registerStatusTool } from './tools/checkpoint.ts'
35
37
  import { registerHistoryTool } from './tools/history.ts'
36
38
  import { registerPruneTool } from './tools/prune.ts'
39
+ import { registerTranscriptTool } from './tools/transcript.ts'
40
+ import { registerSessionHooks } from './session-hooks.ts'
41
+ import { registerLiveCapture } from './live.ts'
37
42
  import { ITERATE_SKILL_PROMPT } from './skill-prompt.ts'
38
43
 
39
44
  export const name = 'iterate-plugin'
40
- export const inject = ['tools', 'systemPrompt']
45
+ export const inject = ['tools', 'systemPrompt'] as const
41
46
 
42
47
  export function apply(ctx: Context): void {
43
- // 1. Register the 13 tools
48
+ // 1. Register the 14 tools
44
49
  registerConfigTool(ctx)
45
50
  registerValidateTool(ctx)
46
51
  registerDecisionLogTool(ctx)
@@ -54,6 +59,12 @@ export function apply(ctx: Context): void {
54
59
  registerStatusTool(ctx)
55
60
  registerHistoryTool(ctx)
56
61
  registerPruneTool(ctx)
62
+ registerTranscriptTool(ctx)
63
+
64
+ // 2. Wire the observatory approval gate onto dsh's tools/pre-execute waterfall,
65
+ // and the live reviewer-activity feed onto tools/result.
66
+ registerSessionHooks(ctx)
67
+ registerLiveCapture(ctx)
57
68
 
58
69
  // 2. Inject the iterate skill prompt as a system prompt section
59
70
  // This teaches the model how to write iterate workflow scripts using the tools.
package/src/live.ts ADDED
@@ -0,0 +1,185 @@
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
+
27
+ import { mkdir, readFile, writeFile, stat, appendFile, rename } from 'node:fs/promises'
28
+ import { existsSync } from 'node:fs'
29
+ import { join } from 'node:path'
30
+ import type { Context } from '@deepseek-ai/cordis'
31
+ import type { ToolExecution } from '@deepseek-ai/dsh-tools'
32
+ import { resolveProjectRoot } from './config-loader.ts'
33
+
34
+ /** Keep at most this many live activity entries. */
35
+ export const LIVE_MAX_ENTRIES = 300
36
+ /** Rewrite the live file when its byte size exceeds this threshold. */
37
+ export const LIVE_MAX_BYTES = 64 * 1024
38
+
39
+ /** One live activity record. */
40
+ export interface LiveActivityEntry {
41
+ /** ISO 8601 timestamp of the tool result. */
42
+ ts: string
43
+ /** Coarse category used by the client for coloring/grouping. */
44
+ type:
45
+ | 'read'
46
+ | 'fix'
47
+ | 'rollback'
48
+ | 'diff'
49
+ | 'review'
50
+ | 'triage'
51
+ | 'checkpoint'
52
+ | 'validate'
53
+ | 'log'
54
+ | 'prune'
55
+ | 'info'
56
+ /** The tool that produced the activity. */
57
+ tool: string
58
+ /**
59
+ * The affected target: a source file path (relative to project root) for
60
+ * read/fix/rollback/diff, else a summary string (e.g. the review operation).
61
+ */
62
+ target: string
63
+ }
64
+
65
+ /** File path of the live NDJSON feed for a project root. */
66
+ export function liveFilePath(projectRoot: string): string {
67
+ return join(projectRoot, '.iterate', 'transcript-live.ndjson')
68
+ }
69
+
70
+ /** Resolve the project root a tool execution belongs to, if any. */
71
+ function projectRootOf(exec: ToolExecution): string | null {
72
+ const cwd = exec.agent?.session?.header?.cwd
73
+ if (!cwd) return null
74
+ const resolved = resolveProjectRoot(undefined, cwd)
75
+ return resolved.ok ? resolved.root : null
76
+ }
77
+
78
+ /** Classify a settled tool call into a live activity entry, or null to skip. */
79
+ export function classifyTool(
80
+ name: string,
81
+ args: unknown,
82
+ projectRoot: string,
83
+ ): LiveActivityEntry | null {
84
+ // `read_file` is the dsh-native file reader reviewers use to inspect code.
85
+ if (name === 'read_file') {
86
+ const file =
87
+ args && typeof args === 'object' && typeof (args as Record<string, unknown>).path === 'string'
88
+ ? (args as Record<string, unknown>).path as string
89
+ : ''
90
+ return file ? { ts: new Date().toISOString(), type: 'read', tool: name, target: file } : null
91
+ }
92
+
93
+ // The iterate plugin's own tools — surface what the workflow is doing live.
94
+ const records: Record<string, LiveActivityEntry['type']> = {
95
+ iterate_fix: 'fix',
96
+ iterate_rollback: 'rollback',
97
+ iterate_diff: 'diff',
98
+ iterate_review: 'review',
99
+ iterate_triage: 'triage',
100
+ iterate_checkpoint: 'checkpoint',
101
+ iterate_validate: 'validate',
102
+ iterate_decision_log: 'log',
103
+ iterate_history: 'info',
104
+ iterate_prune: 'prune',
105
+ iterate_transcript: 'log',
106
+ iterate_status: 'info',
107
+ iterate_config: 'info',
108
+ iterate_context: 'info',
109
+ }
110
+ const type = records[name]
111
+ if (!type) return null
112
+
113
+ let target = ''
114
+ if (args && typeof args === 'object') {
115
+ const a = args as Record<string, unknown>
116
+ if (typeof a.file === 'string' && a.file) target = a.file
117
+ else if (typeof a.path === 'string' && a.path) target = a.path
118
+ else if (typeof a.operation === 'string' && a.operation) target = a.operation
119
+ else if (name === 'iterate_rollback' && typeof a.id === 'string' && a.id) {
120
+ target = `fix ${a.id}`
121
+ }
122
+ }
123
+ if (!target) target = name
124
+ return { ts: new Date().toISOString(), type, tool: name, target }
125
+ }
126
+
127
+ /** Append one activity record to the project's live feed (byte-capped). */
128
+ export async function appendLive(projectRoot: string, entry: LiveActivityEntry): Promise<void> {
129
+ const file = liveFilePath(projectRoot)
130
+ const line = JSON.stringify(entry) + '\n'
131
+ await mkdir(join(projectRoot, '.iterate'), { recursive: true })
132
+ // Amortized O(1): only read+rewrite when the file has grown past the cap.
133
+ try {
134
+ const st = await stat(file).catch(() => null)
135
+ if (st && st.size > LIVE_MAX_BYTES) {
136
+ const raw = await readFile(file, 'utf-8')
137
+ const lines = raw.split('\n').filter(Boolean)
138
+ const tail = lines.slice(-LIVE_MAX_ENTRIES)
139
+ const tmp = `${file}.trim.tmp`
140
+ await writeFile(tmp, tail.join('\n') + '\n', 'utf-8')
141
+ await rename(tmp, file)
142
+ }
143
+ await appendFile(file, line, 'utf-8')
144
+ } catch {
145
+ // Fire-and-forget: never let live capture break a tool call.
146
+ }
147
+ }
148
+
149
+ /** Read the live feed (newest first), capped at the last LIVE_MAX_ENTRIES. */
150
+ export async function readLive(projectRoot: string): Promise<LiveActivityEntry[]> {
151
+ const file = liveFilePath(projectRoot)
152
+ if (!existsSync(file)) return []
153
+ try {
154
+ const raw = await readFile(file, 'utf-8')
155
+ const entries: LiveActivityEntry[] = []
156
+ for (const line of raw.split('\n')) {
157
+ if (!line.trim()) continue
158
+ try {
159
+ const parsed = JSON.parse(line) as LiveActivityEntry
160
+ if (parsed && typeof parsed.ts === 'string' && typeof parsed.type === 'string') {
161
+ entries.push(parsed)
162
+ }
163
+ } catch {
164
+ // skip malformed lines
165
+ }
166
+ }
167
+ return entries.slice(-LIVE_MAX_ENTRIES).reverse()
168
+ } catch {
169
+ return []
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Register a `tools/result` observer that captures reviewer activity into the
175
+ * project's live feed. Fire-and-forget; failures are swallowed.
176
+ */
177
+ export function registerLiveCapture(ctx: Context): void {
178
+ ctx.on('tools/result', (exec: ToolExecution) => {
179
+ const root = projectRootOf(exec)
180
+ if (!root) return
181
+ const entry = classifyTool(exec.name, exec.arguments, root)
182
+ if (!entry) return
183
+ void appendLive(root, entry)
184
+ })
185
+ }
@@ -70,8 +70,13 @@ export interface FinalReviewReport {
70
70
  }
71
71
  }
72
72
 
73
- /** Number of distinct consistency checks performed by `metaReviewReport`. */
74
- export const META_REVIEW_CHECKS = 6
73
+ /**
74
+ * Number of distinct consistency checks performed by `metaReviewReport`.
75
+ * The check set is: COUNT_MATCH, SEVERITY_SUM, DIMENSION_SUM, DIMENSION_UNKNOWN,
76
+ * SORT_ORDER, CONVERGENCE_SUM, CONVERGENCE_FLAG, ROUND_NUMBER, ROUND_EMPTY,
77
+ * ROUND_GAP.
78
+ */
79
+ export const META_REVIEW_CHECKS = 10
75
80
 
76
81
  /**
77
82
  * How many uncovered scope files are listed in a COVERAGE_GAP hint before the
@@ -266,14 +271,23 @@ export function metaReviewReport(report: ReviewReport): MetaReviewResult {
266
271
  )
267
272
  }
268
273
  }
269
- for (let i = 1; i <= rounds.length; i++) {
270
- if (!seenRounds.has(i)) {
271
- add(
272
- 'ROUND_GAP',
273
- 'medium',
274
- `Round ${i} is missing from the round sequence`,
275
- `rounds present: ${[...seenRounds].sort((a, b) => a - b).join(', ') || 'none'}.`,
276
- )
274
+ // ROUND_GAP: only flag gaps WITHIN the range of actually-present round
275
+ // numbers. Non-contiguous starts (e.g. a resumed run beginning at round 5)
276
+ // and arbitrary round numbering are supported by the aggregate engine, so
277
+ // missing 1..N prefixes are NOT defects. Checks min..max of present rounds.
278
+ const present = [...seenRounds].sort((a, b) => a - b)
279
+ if (present.length > 0) {
280
+ const min = present[0]!
281
+ const max = present[present.length - 1]!
282
+ for (let i = min; i <= max; i++) {
283
+ if (!seenRounds.has(i)) {
284
+ add(
285
+ 'ROUND_GAP',
286
+ 'medium',
287
+ `Round ${i} is missing from the round sequence`,
288
+ `rounds present: ${present.join(', ')}.`,
289
+ )
290
+ }
277
291
  }
278
292
  }
279
293
 
@@ -96,6 +96,8 @@ const SIGNATURE_PATTERNS: Array<{ kind: string; re: RegExp; nameIndex: number }>
96
96
  export function collectMethodSignatures(text: string): MethodSignature[] {
97
97
  const lines = text.split('\n')
98
98
  const out: MethodSignature[] = []
99
+ // Set lookup instead of scanning the growing array (O(S²) → O(S)).
100
+ const seen = new Set<string>()
99
101
  for (let i = 0; i < lines.length; i++) {
100
102
  const raw = lines[i]!
101
103
  const line = i + 1
@@ -105,7 +107,9 @@ export function collectMethodSignatures(text: string): MethodSignature[] {
105
107
  const name = m[p.nameIndex]
106
108
  if (!name || RESERVED_WORDS.has(name) || CALLABLE_NOISE.has(name)) continue
107
109
  // Avoid two patterns claiming the same line (e.g. TS method + arrow).
108
- if (out.some((s) => s.line === line && s.name === name)) break
110
+ const key = `${line}|${name}`
111
+ if (seen.has(key)) break
112
+ seen.add(key)
109
113
  out.push({ name, line })
110
114
  break
111
115
  }
package/src/paths.ts CHANGED
@@ -36,3 +36,8 @@ export function fixBackupPath(projectRoot: string, id: string, timestamp: string
36
36
  export function checkpointPath(projectRoot: string): string {
37
37
  return join(iterateDir(projectRoot), 'checkpoint.json')
38
38
  }
39
+
40
+ /** Runtime-observatory transcript file (JSON). */
41
+ export function transcriptPath(projectRoot: string): string {
42
+ return join(iterateDir(projectRoot), 'transcript.json')
43
+ }
@@ -114,20 +114,23 @@ function collectChanged(changedFiles: string[]): string[] {
114
114
  }
115
115
 
116
116
  function collectFull(root: string): string[] {
117
- // Deterministic recursive walk built on Node's fs; a code reviewer never
118
- // anchors findings to lock files, images, or vendored builds.
117
+ // Deterministic iterative walk (explicit stack unbounded recursion could
118
+ // overflow on pathologically deep trees); a code reviewer never anchors
119
+ // findings to lock files, images, or vendored builds.
119
120
  const out: string[] = []
120
- const walk = (dir: string): void => {
121
+ const stack: string[] = [root]
122
+ while (stack.length > 0) {
123
+ const dir = stack.pop()!
121
124
  let entries: import('node:fs').Dirent[]
122
125
  try {
123
126
  entries = readdirSync(dir, { withFileTypes: true })
124
127
  } catch {
125
- return
128
+ continue
126
129
  }
127
130
  for (const entry of entries) {
128
131
  const abs = join(dir, entry.name)
129
132
  if (entry.isDirectory()) {
130
- if (!isIgnoredDir(entry.name)) walk(abs)
133
+ if (!isIgnoredDir(entry.name)) stack.push(abs)
131
134
  continue
132
135
  }
133
136
  if (!entry.isFile()) continue
@@ -136,13 +139,14 @@ function collectFull(root: string): string[] {
136
139
  out.push(rel.split(SEP).join(SEP))
137
140
  }
138
141
  }
139
- walk(root)
140
142
  return out.sort()
141
143
  }
142
144
 
143
145
  /** Split `files` into stable batches, keeping directory runs together. */
144
146
  export function chunkFiles(files: string[], perChunk?: number): string[][] {
145
- const size = perChunk === undefined || perChunk < 1 ? DEFAULT_SCOPE_CHUNK_SIZE : perChunk
147
+ // Number.isFinite: NaN fails `perChunk < 1` and would yield one unbounded
148
+ // chunk (current.length >= NaN is never true).
149
+ const size = Number.isFinite(perChunk) && (perChunk as number) >= 1 ? (perChunk as number) : DEFAULT_SCOPE_CHUNK_SIZE
146
150
  const ordered = [...files].sort()
147
151
  const chunks: string[][] = []
148
152
  let current: string[] = []