iterate-plugin 2.8.4 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/skill-prompt.js +64 -26
- package/dist/tools/checkpoint.js +16 -1
- package/dist/tools/context.js +121 -2
- package/dist/tools/decision-log.js +3 -1
- package/lib/client.js +113 -0
- package/lib/parse.js +118 -0
- package/package.json +1 -1
- package/src/client/index.ts +27 -0
- package/src/skill-prompt.ts +64 -26
- package/src/tools/checkpoint.ts +19 -1
- package/src/tools/context.ts +138 -2
- package/src/tools/decision-log.ts +3 -1
- package/src/types.ts +7 -0
package/lib/parse.js
CHANGED
|
@@ -28,6 +28,124 @@ export const SEVERITY_COLOR = {
|
|
|
28
28
|
low: '#6b7280',
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// ─── Interruption / resume + image attachment detection ──────────────────────
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Deep-scan an object tree for a decision-log `resume` marker.
|
|
35
|
+
* The normal-mode workflow appends a `resume` decision-log entry when it
|
|
36
|
+
* continues a previous interrupted run:
|
|
37
|
+
* { type: "resume", data: { resumedFromRound, resumeCount } }
|
|
38
|
+
* This is the durable client-side signal that a run was interrupted and
|
|
39
|
+
* recovered. Returns the highest `resumeCount` observed, or 0 when none.
|
|
40
|
+
*
|
|
41
|
+
* @param {unknown} obj
|
|
42
|
+
* @param {Set<unknown>} [seen]
|
|
43
|
+
* @param {number} [maxDepth=20]
|
|
44
|
+
* @returns {number}
|
|
45
|
+
*/
|
|
46
|
+
export function scanSessionForResume(obj, seen, maxDepth = 20) {
|
|
47
|
+
if (maxDepth <= 0) return 0
|
|
48
|
+
if (!obj || typeof obj !== 'object') return 0
|
|
49
|
+
|
|
50
|
+
const s = seen || new Set()
|
|
51
|
+
if (s.has(obj)) return 0
|
|
52
|
+
s.add(obj)
|
|
53
|
+
|
|
54
|
+
let best = 0
|
|
55
|
+
|
|
56
|
+
// Direct marker: { type: "resume", data: { resumeCount } }.
|
|
57
|
+
const direct = /** @type {Record<string, unknown>} */ (obj)
|
|
58
|
+
if (direct.type === 'resume') {
|
|
59
|
+
const data = /** @type {Record<string, unknown>} */ (direct.data || {})
|
|
60
|
+
if (typeof data.resumeCount === 'number' && data.resumeCount > best) {
|
|
61
|
+
best = data.resumeCount
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Nested entry: { entry: { type: "resume", data: { resumeCount } } }.
|
|
65
|
+
if (direct.entry && typeof direct.entry === 'object') {
|
|
66
|
+
const entry = /** @type {Record<string, unknown>} */ (direct.entry)
|
|
67
|
+
if (entry.type === 'resume') {
|
|
68
|
+
const data = /** @type {Record<string, unknown>} */ (entry.data || {})
|
|
69
|
+
if (typeof data.resumeCount === 'number' && data.resumeCount > best) {
|
|
70
|
+
best = data.resumeCount
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (Array.isArray(obj)) {
|
|
76
|
+
for (const item of obj) {
|
|
77
|
+
const found = scanSessionForResume(item, s, maxDepth - 1)
|
|
78
|
+
if (found > best) best = found
|
|
79
|
+
}
|
|
80
|
+
return best
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const key of Object.keys(direct)) {
|
|
84
|
+
const val = direct[key]
|
|
85
|
+
if (val && typeof val === 'object') {
|
|
86
|
+
const found = scanSessionForResume(val, s, maxDepth - 1)
|
|
87
|
+
if (found > best) best = found
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return best
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Count distinct user-attached images inside a session snapshot.
|
|
96
|
+
* Matches dsh image blocks ({ type: "image", attachment: {...} }) and
|
|
97
|
+
* raw attachment references ({ mediaType, width, height, bytes }). Dedupes by
|
|
98
|
+
* `attachmentId` when present so the same image never counts twice.
|
|
99
|
+
*
|
|
100
|
+
* @param {unknown} session
|
|
101
|
+
* @returns {number}
|
|
102
|
+
*/
|
|
103
|
+
export function countSessionImages(session) {
|
|
104
|
+
if (!session || typeof session !== 'object') return 0
|
|
105
|
+
|
|
106
|
+
const ids = new Set()
|
|
107
|
+
let count = 0
|
|
108
|
+
|
|
109
|
+
/** @param {unknown} obj */
|
|
110
|
+
const walk = (obj, depth) => {
|
|
111
|
+
if (depth <= 0 || !obj || typeof obj !== 'object') return
|
|
112
|
+
if (seen.has(obj)) return
|
|
113
|
+
seen.add(obj)
|
|
114
|
+
const o = /** @type {Record<string, unknown>} */ (obj)
|
|
115
|
+
|
|
116
|
+
// Image block: { type: "image", attachment: { ...ref } }.
|
|
117
|
+
let ref = null
|
|
118
|
+
if (o.type === 'image' && o.attachment && typeof o.attachment === 'object') {
|
|
119
|
+
ref = /** @type {Record<string, unknown>} */ (o.attachment)
|
|
120
|
+
}
|
|
121
|
+
// Raw attachment reference shape.
|
|
122
|
+
if (!ref && typeof o.mediaType === 'string' && String(o.mediaType).startsWith('image/')) {
|
|
123
|
+
ref = o
|
|
124
|
+
}
|
|
125
|
+
if (ref) {
|
|
126
|
+
const id = typeof ref.attachmentId === 'string' ? ref.attachmentId : null
|
|
127
|
+
if (id) {
|
|
128
|
+
if (!ids.has(id)) { ids.add(id); count += 1 }
|
|
129
|
+
} else {
|
|
130
|
+
count += 1
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (Array.isArray(obj)) {
|
|
135
|
+
for (const item of obj) walk(item, depth - 1)
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
for (const key of Object.keys(o)) {
|
|
139
|
+
const val = o[key]
|
|
140
|
+
if (val && typeof val === 'object') walk(val, depth - 1)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const seen = new Set()
|
|
145
|
+
walk(session, 12)
|
|
146
|
+
return count
|
|
147
|
+
}
|
|
148
|
+
|
|
31
149
|
// ─── ReviewReport detection ──────────────────────────────────────────────────
|
|
32
150
|
|
|
33
151
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iterate-plugin",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.0",
|
|
4
4
|
"description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus a dry-run pure-review mode with multi-round convergence and a meta-review that audits the report and emits a final review report.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/client/index.ts
CHANGED
|
@@ -68,6 +68,8 @@ import {
|
|
|
68
68
|
keyToVerdict,
|
|
69
69
|
allVerdictKeys,
|
|
70
70
|
buildRuntimeStatusGuide,
|
|
71
|
+
scanSessionForResume,
|
|
72
|
+
countSessionImages,
|
|
71
73
|
SEVERITY_LABEL,
|
|
72
74
|
SEVERITY_COLOR,
|
|
73
75
|
} from '../../lib/parse.js'
|
|
@@ -320,6 +322,10 @@ const ITERATE_CSS = `
|
|
|
320
322
|
.iterate-pill { display: inline-flex; align-items: center; gap: 7px; padding: 3px 11px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; color: var(--dsw-alias-state-success-primary); background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); border: 1px solid color-mix(in srgb, var(--dsw-alias-state-success-primary) 28%, transparent); }
|
|
321
323
|
.iterate-pill-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
|
322
324
|
|
|
325
|
+
/* Interruption / resume + attachment chips (dashboard) */
|
|
326
|
+
.iterate-chip-resume { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; color: var(--dsw-alias-state-warn-primary); background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent); border: 1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary) 28%, transparent); }
|
|
327
|
+
.iterate-chip-images { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; color: var(--dsw-alias-brand-primary); background: color-mix(in srgb, var(--dsw-alias-brand-primary) 12%, transparent); border: 1px solid color-mix(in srgb, var(--dsw-alias-brand-primary) 28%, transparent); }
|
|
328
|
+
|
|
323
329
|
/* Accessibility-switch toggle */
|
|
324
330
|
.iterate-switch { position: relative; width: 42px; height: 24px; border-radius: 999px; padding: 0; cursor: pointer; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); transition: background-color 160ms ease, border-color 160ms ease; }
|
|
325
331
|
.iterate-switch:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; }
|
|
@@ -552,6 +558,25 @@ function ConvergenceDashboard(props: SlotProps) {
|
|
|
552
558
|
const dims = groupByDimension(report)
|
|
553
559
|
const trend = computeTrendMetrics(report)
|
|
554
560
|
|
|
561
|
+
// Interruption / resume awareness: a decision-log `resume` entry in the
|
|
562
|
+
// session means this run continued from an interrupted checkpoint.
|
|
563
|
+
const resumeCount = scanSessionForResume(session)
|
|
564
|
+
const imageCount = countSessionImages(session)
|
|
565
|
+
const resumeChip = resumeCount > 0
|
|
566
|
+
? React.createElement('span', {
|
|
567
|
+
className: 'iterate-chip-resume',
|
|
568
|
+
key: 'resume',
|
|
569
|
+
title: '本次迭代从上一次中断的断点继续执行',
|
|
570
|
+
}, `已中断恢复 ×${String(resumeCount)}`)
|
|
571
|
+
: null
|
|
572
|
+
const imageChip = imageCount > 0
|
|
573
|
+
? React.createElement('span', {
|
|
574
|
+
className: 'iterate-chip-images',
|
|
575
|
+
key: 'images',
|
|
576
|
+
title: '会话中检测到用户附带的图片,评审将作为视觉证据参考',
|
|
577
|
+
}, `附件图片 ${String(imageCount)}`)
|
|
578
|
+
: null
|
|
579
|
+
|
|
555
580
|
const dimBadges = Object.keys(dims).slice(0, 6).map((dim) =>
|
|
556
581
|
React.createElement(
|
|
557
582
|
'span',
|
|
@@ -598,6 +623,8 @@ function ConvergenceDashboard(props: SlotProps) {
|
|
|
598
623
|
stats.medium,
|
|
599
624
|
),
|
|
600
625
|
fixBadge,
|
|
626
|
+
resumeChip,
|
|
627
|
+
imageChip,
|
|
601
628
|
React.createElement(TrendChart, { points: trend.points }),
|
|
602
629
|
...dimBadges,
|
|
603
630
|
)
|
package/src/skill-prompt.ts
CHANGED
|
@@ -13,14 +13,14 @@ You have the iterate plugin installed, which registers these tools:
|
|
|
13
13
|
- \`iterate_config\` — read iterate.config.yaml (dimensions, validation commands, personalization) or write a validated partial update (operation:"write", with automatic backup + rollback)
|
|
14
14
|
- \`iterate_validate\` — run a whitelisted validation command
|
|
15
15
|
- \`iterate_decision_log\` — append to the decision log, or read entries back for review
|
|
16
|
-
- \`iterate_context\` — read SKILL.md / ITERATE.md project context
|
|
16
|
+
- \`iterate_context\` — read SKILL.md / ITERATE.md project context; also relays user-attached image metadata (e.g. UI screenshots, error dialogs) so reviewers can treat them as visual evidence
|
|
17
17
|
- \`iterate_review\` — deterministic review engine: \`plan\` builds the review plan (for \`review.scope: changed-only\`, it resolves the git-diff file set against \`git.target_branch\` and auto-falls back to \`full\` when nothing changed); \`aggregate\` dedupes/merges findings, validates every finding against the findings schema when \`reviewer.output_schema_validation\` is on (dropping invalid entries and reporting them via \`schemaValidation\`), and computes convergence; \`meta-review\` audits a built report for internal consistency (counts, buckets, sorting, convergence math) and returns a final report with an \`approved\` / \`needs_revision\` verdict. Purely computational.
|
|
18
18
|
- \`iterate_triage\` — manage "known_intentional" entries in the config (list / apply, with dedupe + backup + rollback)
|
|
19
19
|
- \`iterate_fix\` — apply ONE atomic fix: backs up the file, enforces the atomic max_lines threshold, writes the new content, and records the fix (id + diff summary) in \`.iterate/fixes/registry.json\`
|
|
20
20
|
- \`iterate_diff\` — show the accumulated diff for a fixed file (vs its original backup) or a per-file summary of all fixes
|
|
21
21
|
- \`iterate_rollback\` — revert a fix by id: restore the file from its backup, remove the fix from the registry, log a \`revert\` entry. Use when a round's validation fails
|
|
22
22
|
- \`iterate_checkpoint\` — save / load / clear an iteration checkpoint (\`.iterate/checkpoint.json\`) so a long run can resume where it left off
|
|
23
|
-
- \`iterate_status\` — summarize the current run: mode, round, fixes applied, architectural remaining, decision-log size, checkpoint presence
|
|
23
|
+
- \`iterate_status\` — summarize the current run: mode, round, fixes applied, architectural remaining, decision-log size, checkpoint presence, and whether the run was interrupted (a checkpoint left on disk means the previous run was interrupted and can be resumed)
|
|
24
24
|
- \`iterate_history\` — inspect the runtime state in detail: decision-log entries and applied fixes (optionally scoped to a round or a fixed file)
|
|
25
25
|
- \`iterate_prune\` — remove stale runtime artifacts (\`.iterate/\` entries). Defaults to a read-only dry-run that reports what WOULD be removed; pass \`dryRun:false\` to actually prune.
|
|
26
26
|
|
|
@@ -31,13 +31,26 @@ When the user asks to review or iterate on the project (e.g. "review this projec
|
|
|
31
31
|
|
|
32
32
|
### Workflow script contract
|
|
33
33
|
Write a plain-JS script (top-level await, ends with \`return <json>\`). Available globals:
|
|
34
|
-
- \`agent(prompt, opts?): Promise<value>\` — spawn a subagent. \`opts.schema\` gives structured output (object-rooted JSON Schema: type/properties/required/additionalProperties/items/enum/const/oneOf only). Resolves \`null\` on child failure. Other opts: \`label\`, \`phase\`.
|
|
34
|
+
- \`agent(prompt, opts?): Promise<value>\` — spawn a subagent. \`opts.schema\` gives structured output (object-rooted JSON Schema: type/properties/required/additionalProperties/items/enum/const/oneOf only). Resolves \`null\` on child failure. Other opts: \`label\`, \`phase\`. Backend selection (optional): pass \`provider\` (e.g. \`"codex"\`, \`"claude"\`, \`"default"\`) to route the sub-agent to a specific provider backend, and/or \`model\` to pin a model id. When omitted, the sub-agent uses the same provider/model as the parent session.
|
|
35
35
|
- \`parallel(thunks): Promise<value[]>\` — run zero-arg async functions concurrently, await all.
|
|
36
36
|
- \`phase(title)\`, \`log(message)\` — progress narration.
|
|
37
37
|
- \`args\` — the args object passed to the workflow tool.
|
|
38
38
|
|
|
39
39
|
The script CANNOT call tools directly. Subagents are the ones who call tools.
|
|
40
40
|
|
|
41
|
+
### Sub-agent backend selection
|
|
42
|
+
Every \`agent()\` call may carry a backend hint via \`opts.provider\` / \`opts.model\`. Use it deliberately to balance cost, speed, and reliability:
|
|
43
|
+
- **Reviewers** (many, run in parallel, read-only, benefit from strict JSON): prefer a fast/cheap model when one is configured; otherwise omit the hint and inherit the session backend.
|
|
44
|
+
- **Fixers / aggregators** (few, must be reliable and follow tool results exactly): keep them on the parent's default backend unless a specific provider is known-good.
|
|
45
|
+
- **Never invent a provider/model name.** Pass a hint ONLY when the deployment actually registers that adapter (see \`ih config\` / the configured provider list). When in doubt, omit \`provider\`/\`model\` entirely — the sub-agent then runs on the same backend as the parent session, which is always a safe default.
|
|
46
|
+
- The optional \`args.subagentProvider\` / \`args.subagentModel\` allow the caller to override the whole run's sub-agent backend from the invocation; the canonical scripts below read them and spread the hint onto every spawned sub-agent (reviewers, fixers, validators, aggregators).
|
|
47
|
+
|
|
48
|
+
### User-attached image evidence
|
|
49
|
+
The user may attach images to the conversation (UI screenshots, error dialogs, design references, logs-as-pictures). When they do:
|
|
50
|
+
- You see those images natively in the session. Capture their metadata and pass it into the workflow via \`args.attachments\` — an array of objects, each with optional \`name\`, \`mediaType\`, \`width\`, \`height\`, and a \`note\` describing what the image shows and why it matters for this review.
|
|
51
|
+
- The canonical scripts below read \`args.attachments\` and relay them into every reviewer prompt so reviewers treat the attached visuals as evidence (e.g. "the screenshot in this message shows the broken layout the review should reproduce").
|
|
52
|
+
- If a reviewer needs the images relayed explicitly, it can call \`iterate_context\` with \`attachments\` to get the normalized image descriptions in its context. Never fabricate an attachment — only relay images the user actually attached.
|
|
53
|
+
|
|
41
54
|
### Dry-run mode workflow (pure review — the ONLY mode that never touches files)
|
|
42
55
|
This is iterate's read-only health-check: repeated review rounds until findings converge,
|
|
43
56
|
then produce an auditable report, then audit the report itself (meta-review) and give a
|
|
@@ -47,9 +60,15 @@ Canonical script — reproduce this structure exactly (adjust dims via the plan)
|
|
|
47
60
|
|
|
48
61
|
\`\`\`js
|
|
49
62
|
phase('plan')
|
|
63
|
+
// Optional per-run backend override for sub-agents (omit to inherit the session backend).
|
|
64
|
+
const subAgentProvider = (args && args.subagentProvider) || undefined
|
|
65
|
+
const subAgentModel = (args && args.subagentModel) || undefined
|
|
66
|
+
const backend = Object.assign({}, subAgentProvider ? { provider: subAgentProvider } : {}, subAgentModel ? { model: subAgentModel } : {})
|
|
67
|
+
// User-attached image evidence relayed into reviewer prompts (metadata only).
|
|
68
|
+
const attachments = (args && Array.isArray(args.attachments)) ? args.attachments : []
|
|
50
69
|
const planRes = await agent(
|
|
51
70
|
'Call iterate_review({operation:"plan", mode:"dry-run"}) and return the plan JSON.',
|
|
52
|
-
{ label: 'review:plan' }
|
|
71
|
+
Object.assign({ label: 'review:plan' }, backend)
|
|
53
72
|
)
|
|
54
73
|
const plan = (planRes && planRes.plan) ? planRes.plan : null
|
|
55
74
|
if (!plan || !Array.isArray(plan.dimensions)) throw new Error('plan failed: iterate_review did not return a valid plan')
|
|
@@ -71,16 +90,18 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
71
90
|
? '\\nSTRICT JSON REQUIRED: your previous output failed schema validation. Return ONLY a JSON object {"findings":[...]} where EVERY finding has dimension, file, line (non-negative integer; 0 = whole-file), severity (critical|high|medium|low), summary, failure_scenario, suggested_fix, is_atomic (boolean).'
|
|
72
91
|
: ''
|
|
73
92
|
const raw = await parallel(dims.map(dim => () => agent(
|
|
74
|
-
'Review dimension "' + dim + '".
|
|
93
|
+
'Review dimension "' + dim + '".' +
|
|
94
|
+
(attachments.length > 0 ? ' User-attached images are part of the evidence (reproduce/verify against them): ' + JSON.stringify(attachments) + '.' : '') +
|
|
95
|
+
' Already-known findings (do NOT re-report): ' +
|
|
75
96
|
JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.',
|
|
76
|
-
{ label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }
|
|
97
|
+
Object.assign({ label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }, backend)
|
|
77
98
|
)))
|
|
78
99
|
const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
|
|
79
100
|
if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
|
|
80
101
|
// Deterministic aggregate: cross-round dedupe + known_intentional filter + severity sort.
|
|
81
102
|
agg = await agent(
|
|
82
103
|
'Call iterate_review({operation:"aggregate", mode:"dry-run", rounds:' + JSON.stringify(rounds) + ', maxReviewRounds:' + maxRounds + ', knownIntentional:' + JSON.stringify(knownIntentional) + '}) and return the report JSON.',
|
|
83
|
-
{ label: 'review:aggregate:r' + r }
|
|
104
|
+
Object.assign({ label: 'review:aggregate:r' + r }, backend)
|
|
84
105
|
)
|
|
85
106
|
// reviewer.output_schema_validation (default on): aggregate returns per-round
|
|
86
107
|
// schemaValidation; retry the just-finished round (≤2 times) when invalid.
|
|
@@ -104,20 +125,20 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
104
125
|
phase('report')
|
|
105
126
|
const finalAgg = await agent(
|
|
106
127
|
'Call iterate_review({operation:"aggregate", mode:"dry-run", rounds:' + JSON.stringify(rounds) + ', maxReviewRounds:' + maxRounds + ', knownIntentional:' + JSON.stringify(knownIntentional) + '}) and return the report JSON.',
|
|
107
|
-
{ label: 'review:aggregate:final' }
|
|
128
|
+
Object.assign({ label: 'review:aggregate:final' }, backend)
|
|
108
129
|
)
|
|
109
130
|
const report = (finalAgg && finalAgg.report) ? finalAgg.report : null
|
|
110
131
|
if (!report || !report.convergence) throw new Error('aggregate failed: no valid report was produced')
|
|
111
132
|
await agent(
|
|
112
133
|
'Call iterate_decision_log({operation:"append", type:"report", round:' + report.convergence.totalRounds + ', data:{mode:"dry-run", totalFindings:' + report.summary.totalFindings + '}})',
|
|
113
|
-
{ label: 'review:log' }
|
|
134
|
+
Object.assign({ label: 'review:log' }, backend)
|
|
114
135
|
)
|
|
115
136
|
|
|
116
137
|
phase('meta-review')
|
|
117
138
|
// Audit the report itself for internal consistency, then produce the final report.
|
|
118
139
|
const metaRes = await agent(
|
|
119
140
|
'Call iterate_review({operation:"meta-review", report:' + JSON.stringify(report) + '}) and return the finalReport JSON.',
|
|
120
|
-
{ label: 'review:meta' }
|
|
141
|
+
Object.assign({ label: 'review:meta' }, backend)
|
|
121
142
|
)
|
|
122
143
|
const finalReport = metaRes && metaRes.finalReport ? metaRes.finalReport : null
|
|
123
144
|
const metaAudit = finalReport && finalReport.metaReview ? finalReport.metaReview : null
|
|
@@ -153,26 +174,42 @@ Set \`args.mode = "normal"\`. Loop: resume → plan → parallel review ×N →
|
|
|
153
174
|
Canonical script — reproduce this structure exactly (adjust dims via the plan):
|
|
154
175
|
|
|
155
176
|
\`\`\`js
|
|
156
|
-
// args = { mode: "normal", maxRounds? }
|
|
177
|
+
// args = { mode: "normal", maxRounds?, subagentProvider?, subagentModel?, attachments? }
|
|
178
|
+
// Optional per-run backend override for sub-agents (omit to inherit the session backend).
|
|
179
|
+
const subAgentProvider = (args && args.subagentProvider) || undefined
|
|
180
|
+
const subAgentModel = (args && args.subagentModel) || undefined
|
|
181
|
+
const backend = Object.assign({}, subAgentProvider ? { provider: subAgentProvider } : {}, subAgentModel ? { model: subAgentModel } : {})
|
|
182
|
+
// User-attached image evidence relayed into reviewer prompts (metadata only).
|
|
183
|
+
const attachments = (args && Array.isArray(args.attachments)) ? args.attachments : []
|
|
157
184
|
phase('resume')
|
|
158
185
|
// If a previous run was interrupted, resume from its checkpoint instead of restarting.
|
|
159
186
|
const ckRes = await agent(
|
|
160
187
|
'Call iterate_checkpoint({ operation: "load" }) and return the checkpoint JSON.',
|
|
161
|
-
{ label: 'checkpoint:load' }
|
|
188
|
+
Object.assign({ label: 'checkpoint:load' }, backend)
|
|
162
189
|
)
|
|
163
190
|
const checkpoint = (ckRes && ckRes.checkpoint) ? ckRes.checkpoint : null
|
|
164
191
|
const startRound = (checkpoint && typeof checkpoint.round === 'number') ? checkpoint.round + 1 : 1
|
|
192
|
+
// Track how many times this checkpoint has already been resumed (interruption recovery).
|
|
193
|
+
const resumeCount = (checkpoint && typeof checkpoint.resumeCount === 'number') ? checkpoint.resumeCount : 0
|
|
194
|
+
if (checkpoint) {
|
|
195
|
+
// A previous run left a checkpoint — record the recovery so the decision log
|
|
196
|
+
// shows the resume, then continue where it left off.
|
|
197
|
+
await agent(
|
|
198
|
+
'Call iterate_decision_log({operation:"append", type:"resume", round:' + startRound + ', data:{resumedFromRound:' + checkpoint.round + ', resumeCount:' + (resumeCount + 1) + '}})',
|
|
199
|
+
Object.assign({ label: 'log:resume' }, backend)
|
|
200
|
+
)
|
|
201
|
+
}
|
|
165
202
|
|
|
166
203
|
phase('plan')
|
|
167
204
|
const configRes = await agent(
|
|
168
205
|
'Call iterate_config({ validate: true }) and return the config JSON.',
|
|
169
|
-
{ label: 'config:read' }
|
|
206
|
+
Object.assign({ label: 'config:read' }, backend)
|
|
170
207
|
)
|
|
171
208
|
const cfg = (configRes && configRes.config) ? configRes.config : null
|
|
172
209
|
const atomicMaxLines = (cfg && cfg.atomic && cfg.atomic.max_lines) ? cfg.atomic.max_lines : 20
|
|
173
210
|
const planRes = await agent(
|
|
174
211
|
'Call iterate_review({operation:"plan", mode:"normal", maxReviewRounds:' + (args.maxRounds || 3) + '}) and return the plan JSON.',
|
|
175
|
-
{ label: 'review:plan' }
|
|
212
|
+
Object.assign({ label: 'review:plan' }, backend)
|
|
176
213
|
)
|
|
177
214
|
const plan = (planRes && planRes.plan) ? planRes.plan : null
|
|
178
215
|
if (!plan || !Array.isArray(plan.dimensions)) throw new Error('plan failed: iterate_review did not return a valid plan')
|
|
@@ -199,8 +236,9 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
199
236
|
: ''
|
|
200
237
|
const raw = await parallel(dims.map(dim => () => agent(
|
|
201
238
|
'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed). ' +
|
|
239
|
+
(attachments.length > 0 ? ' User-attached images are part of the evidence (reproduce/verify against them): ' + JSON.stringify(attachments) + '.' : '') +
|
|
202
240
|
'Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.',
|
|
203
|
-
{ label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }
|
|
241
|
+
Object.assign({ label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }, backend)
|
|
204
242
|
)))
|
|
205
243
|
const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
|
|
206
244
|
if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
|
|
@@ -210,7 +248,7 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
210
248
|
// show a running "fixes applied" metric for normal mode.
|
|
211
249
|
agg = await agent(
|
|
212
250
|
'Call iterate_review({operation:"aggregate", mode:"normal", rounds:' + JSON.stringify([thisRound]) + ', knownIntentional:' + JSON.stringify(knownIntentional) + ', fixedCount:' + fixedCount + '}) and return the report JSON.',
|
|
213
|
-
{ label: 'review:aggregate:r' + r }
|
|
251
|
+
Object.assign({ label: 'review:aggregate:r' + r }, backend)
|
|
214
252
|
)
|
|
215
253
|
// reviewer.output_schema_validation (default on): aggregate returns per-round
|
|
216
254
|
// schemaValidation; retry the just-finished round (≤2 times) when invalid.
|
|
@@ -240,12 +278,12 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
240
278
|
'iterate_fix({ file: "' + file + '", content: <full new file content>, finding: <that finding>, round: ' + r + ' }). ' +
|
|
241
279
|
'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file: "' + file + '" }) to verify the accumulated diff. ' +
|
|
242
280
|
'Findings: ' + JSON.stringify(byFile[file]) + '. Return the array of {id, ok, error} per iterate_fix call.',
|
|
243
|
-
{ label: 'fix:' + file, phase: 'fix', schema: {
|
|
281
|
+
Object.assign({ label: 'fix:' + file, phase: 'fix', schema: {
|
|
244
282
|
type: 'object', additionalProperties: false,
|
|
245
283
|
properties: {
|
|
246
284
|
fixes: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { id: { type: 'string' }, ok: { type: 'boolean' }, error: { type: 'string' } }, required: ['id', 'ok'] } }
|
|
247
285
|
},
|
|
248
|
-
required: ['fixes'] } }
|
|
286
|
+
required: ['fixes'] } }, backend)
|
|
249
287
|
)))
|
|
250
288
|
for (const res of fixRes) {
|
|
251
289
|
if (res && Array.isArray(res.fixes)) {
|
|
@@ -267,12 +305,12 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
267
305
|
const valRes = await agent(
|
|
268
306
|
'Read iterate.config.yaml validation.commands, then call iterate_validate({ command: <cmd> }) for EACH configured command ' +
|
|
269
307
|
'(one tool call per command). Return all results as {command, exitCode} entries.',
|
|
270
|
-
{ label: 'validate:r' + r, phase: 'validate', schema: {
|
|
308
|
+
Object.assign({ label: 'validate:r' + r, phase: 'validate', schema: {
|
|
271
309
|
type: 'object', additionalProperties: false,
|
|
272
310
|
properties: {
|
|
273
311
|
results: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { command: { type: 'string' }, exitCode: { type: 'integer' } }, required: ['command', 'exitCode'] } }
|
|
274
312
|
},
|
|
275
|
-
required: ['results'] } }
|
|
313
|
+
required: ['results'] } }, backend)
|
|
276
314
|
)
|
|
277
315
|
failedCommands = (valRes && Array.isArray(valRes.results)) ? valRes.results.filter(v => v.exitCode !== 0).map(v => v.command) : []
|
|
278
316
|
if (failedCommands.length > 0) {
|
|
@@ -281,17 +319,17 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
281
319
|
if (roundFixIds.length > 0) {
|
|
282
320
|
await agent(
|
|
283
321
|
'Call iterate_rollback({ id: <id> }) for EACH of these fix ids (one call per id): ' + JSON.stringify(roundFixIds) + '. Return the array of {id, ok, error}.',
|
|
284
|
-
{ label: 'rollback:r' + r, phase: 'rollback', schema: {
|
|
322
|
+
Object.assign({ label: 'rollback:r' + r, phase: 'rollback', schema: {
|
|
285
323
|
type: 'object', additionalProperties: false,
|
|
286
324
|
properties: {
|
|
287
325
|
results: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { id: { type: 'string' }, ok: { type: 'boolean' }, error: { type: 'string' } }, required: ['id', 'ok'] } }
|
|
288
326
|
},
|
|
289
|
-
required: ['results'] } }
|
|
327
|
+
required: ['results'] } }, backend)
|
|
290
328
|
)
|
|
291
329
|
}
|
|
292
330
|
await agent(
|
|
293
331
|
'Call iterate_decision_log({operation:"append", type:"round_failed", round:' + r + ', data:{failedCommands:' + JSON.stringify(failedCommands) + ', rolledBack:' + roundFixIds.length + '}})',
|
|
294
|
-
{ label: 'log:failed:r' + r }
|
|
332
|
+
Object.assign({ label: 'log:failed:r' + r }, backend)
|
|
295
333
|
)
|
|
296
334
|
break
|
|
297
335
|
}
|
|
@@ -299,13 +337,13 @@ for (let r = startRound; r <= maxRounds; r++) {
|
|
|
299
337
|
await agent(
|
|
300
338
|
'Call iterate_decision_log({operation:"append", type:"review_result", round:' + r +
|
|
301
339
|
', data:{atomic:' + atomic.length + ', architectural:' + remaining.length + ', fixedSoFar:' + fixedCount + '}})',
|
|
302
|
-
{ label: 'log:r' + r }
|
|
340
|
+
Object.assign({ label: 'log:r' + r }, backend)
|
|
303
341
|
)
|
|
304
342
|
|
|
305
343
|
// Persist progress so an interrupted run can resume from the next round.
|
|
306
344
|
await agent(
|
|
307
|
-
'Call iterate_checkpoint({ operation: "save", mode: "normal", round:' + r + ', maxRounds:' + maxRounds + ', fixedCount:' + fixedCount + ', architecturalCount:' + architectural.length + ', findings:' + JSON.stringify(architectural) + ' }) and return the checkpoint JSON.',
|
|
308
|
-
{ label: 'checkpoint:save:r' + r }
|
|
345
|
+
'Call iterate_checkpoint({ operation: "save", mode: "normal", round:' + r + ', maxRounds:' + maxRounds + ', fixedCount:' + fixedCount + ', architecturalCount:' + architectural.length + ', resumeCount:' + resumeCount + ', findings:' + JSON.stringify(architectural) + ' }) and return the checkpoint JSON.',
|
|
346
|
+
Object.assign({ label: 'checkpoint:save:r' + r }, backend)
|
|
309
347
|
)
|
|
310
348
|
|
|
311
349
|
if (atomic.length === 0 && remaining.length === 0) {
|
package/src/tools/checkpoint.ts
CHANGED
|
@@ -42,6 +42,7 @@ export function validateCheckpoint(input: {
|
|
|
42
42
|
maxRounds: unknown
|
|
43
43
|
fixedCount: unknown
|
|
44
44
|
architecturalCount: unknown
|
|
45
|
+
resumeCount?: unknown
|
|
45
46
|
}): string | null {
|
|
46
47
|
if (input.mode !== 'dry-run' && input.mode !== 'normal') {
|
|
47
48
|
return 'mode must be "dry-run" or "normal"'
|
|
@@ -58,6 +59,12 @@ export function validateCheckpoint(input: {
|
|
|
58
59
|
if (typeof input.architecturalCount !== 'number' || !Number.isInteger(input.architecturalCount) || input.architecturalCount < 0) {
|
|
59
60
|
return 'architecturalCount must be a non-negative integer'
|
|
60
61
|
}
|
|
62
|
+
if (
|
|
63
|
+
input.resumeCount !== undefined &&
|
|
64
|
+
(typeof input.resumeCount !== 'number' || !Number.isInteger(input.resumeCount) || input.resumeCount < 0)
|
|
65
|
+
) {
|
|
66
|
+
return 'resumeCount must be a non-negative integer'
|
|
67
|
+
}
|
|
61
68
|
return null
|
|
62
69
|
}
|
|
63
70
|
|
|
@@ -103,6 +110,10 @@ export function computeStatus(input: {
|
|
|
103
110
|
findingsCount: checkpoint?.findings.length ?? 0,
|
|
104
111
|
totalDecisionLogEntries: entries.length,
|
|
105
112
|
hasCheckpoint: checkpoint !== null,
|
|
113
|
+
// A checkpoint left on disk means the previous run was interrupted before
|
|
114
|
+
// it could clear it — this is the durable "interruption" signal.
|
|
115
|
+
interrupted: checkpoint !== null,
|
|
116
|
+
resumeCount: checkpoint?.resumeCount ?? 0,
|
|
106
117
|
checkpoint,
|
|
107
118
|
lastUpdated,
|
|
108
119
|
}
|
|
@@ -133,6 +144,7 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
|
|
|
133
144
|
maxRounds: { type: 'integer', description: 'Required for save: total round cap.' },
|
|
134
145
|
fixedCount: { type: 'integer', description: 'Required for save: number of fixes applied so far.' },
|
|
135
146
|
architecturalCount: { type: 'integer', description: 'Required for save: architectural findings left unfixed.' },
|
|
147
|
+
resumeCount: { type: 'integer', description: 'Optional for save: how many times this checkpoint has already been resumed after an interruption (default 0).' },
|
|
136
148
|
findings: { type: 'json', description: 'Optional for save: the current deduped findings to resume from.' },
|
|
137
149
|
path: { type: 'string', description: 'Project root directory (default: current working directory).' },
|
|
138
150
|
},
|
|
@@ -181,6 +193,7 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
|
|
|
181
193
|
maxRounds: args.maxRounds,
|
|
182
194
|
fixedCount: args.fixedCount,
|
|
183
195
|
architecturalCount: args.architecturalCount,
|
|
196
|
+
resumeCount: args.resumeCount,
|
|
184
197
|
})
|
|
185
198
|
if (invalid) return { operation: 'save', ok: false, error: invalid }
|
|
186
199
|
const checkpoint: IterationCheckpoint = {
|
|
@@ -189,6 +202,7 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
|
|
|
189
202
|
maxRounds: args.maxRounds as number,
|
|
190
203
|
fixedCount: args.fixedCount as number,
|
|
191
204
|
architecturalCount: args.architecturalCount as number,
|
|
205
|
+
resumeCount: (typeof args.resumeCount === 'number' ? args.resumeCount : 0),
|
|
192
206
|
findings: (Array.isArray(args.findings) ? args.findings : []) as unknown as IterationCheckpoint['findings'],
|
|
193
207
|
startedAt: readCheckpoint(projectRoot)?.startedAt ?? new Date().toISOString(),
|
|
194
208
|
updatedAt: new Date().toISOString(),
|
|
@@ -239,6 +253,8 @@ export function registerStatusTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
239
253
|
findingsCount: { type: 'integer' },
|
|
240
254
|
totalDecisionLogEntries: { type: 'integer' },
|
|
241
255
|
hasCheckpoint: { type: 'boolean' },
|
|
256
|
+
interrupted: { type: 'boolean', description: 'True when a checkpoint exists, meaning the previous run was interrupted before finishing.' },
|
|
257
|
+
resumeCount: { type: 'integer', description: 'How many times the current checkpoint has already been resumed.' },
|
|
242
258
|
lastUpdated: { type: 'string' },
|
|
243
259
|
error: { type: 'string' },
|
|
244
260
|
},
|
|
@@ -251,7 +267,7 @@ export function registerStatusTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
251
267
|
`Fixed: ${value.fixedCount} · Architectural remaining: ${value.architecturalCount}`,
|
|
252
268
|
`Findings in checkpoint: ${value.findingsCount}`,
|
|
253
269
|
`Decision-log entries: ${value.totalDecisionLogEntries}`,
|
|
254
|
-
`Checkpoint: ${value.hasCheckpoint ? 'yes' : 'no'}`,
|
|
270
|
+
`Checkpoint: ${value.hasCheckpoint ? 'yes' : 'no'}${value.interrupted ? ' (interrupted — resumable)' : ''}${value.resumeCount ? ` · resumed ${value.resumeCount}x` : ''}`,
|
|
255
271
|
value.lastUpdated ? `Last updated: ${value.lastUpdated}` : '',
|
|
256
272
|
]
|
|
257
273
|
return [{ type: 'text', text: lines.filter(Boolean).join('\n') }]
|
|
@@ -277,6 +293,8 @@ export function registerStatusTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
277
293
|
findingsCount: status.findingsCount,
|
|
278
294
|
totalDecisionLogEntries: status.totalDecisionLogEntries,
|
|
279
295
|
hasCheckpoint: status.hasCheckpoint,
|
|
296
|
+
interrupted: status.interrupted,
|
|
297
|
+
resumeCount: status.resumeCount,
|
|
280
298
|
lastUpdated: status.lastUpdated ?? undefined,
|
|
281
299
|
}
|
|
282
300
|
},
|