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/src/config-loader.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/config-write.ts
CHANGED
|
@@ -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
|
-
|
|
175
|
-
|
|
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
|
|
43
|
-
*
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/meta-review.ts
CHANGED
|
@@ -70,8 +70,13 @@ export interface FinalReviewReport {
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
/**
|
|
74
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
package/src/method-scope.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/review-scope.ts
CHANGED
|
@@ -114,20 +114,23 @@ function collectChanged(changedFiles: string[]): string[] {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
function collectFull(root: string): string[] {
|
|
117
|
-
// Deterministic
|
|
118
|
-
//
|
|
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
|
|
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
|
-
|
|
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))
|
|
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
|
-
|
|
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[] = []
|