iterate-plugin 2.6.0 → 2.7.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 +10 -0
- package/dist/config-loader.js +171 -0
- package/dist/config-write.js +174 -0
- package/dist/index.js +58 -0
- package/dist/meta-review.js +181 -0
- package/dist/paths.js +32 -0
- package/dist/review.js +328 -0
- package/dist/skill-prompt.js +337 -0
- package/dist/tools/checkpoint.js +260 -0
- package/dist/tools/config.js +134 -0
- package/dist/tools/context.js +160 -0
- package/dist/tools/decision-log.js +162 -0
- package/dist/tools/fix.js +553 -0
- package/dist/tools/history.js +138 -0
- package/dist/tools/prune.js +268 -0
- package/dist/tools/review.js +159 -0
- package/dist/tools/triage.js +333 -0
- package/dist/tools/validate.js +164 -0
- package/dist/types.js +1 -0
- package/package.json +7 -3
- package/src/meta-review.ts +14 -1
- package/src/review.ts +33 -5
package/README.md
CHANGED
|
@@ -55,6 +55,16 @@ dsh plugin --profile web add iterate-plugin
|
|
|
55
55
|
pnpm add iterate-plugin
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
+
### 从 GitHub 安装(dsh 生态第三方安装方式)
|
|
59
|
+
|
|
60
|
+
dsh 官方支持从 GitHub 插件仓库直接安装:`dsh plugin --profile web add "github:owner/repo#ref"`(仓库根即插件,声明 `dsh.bundle` 后自动启用)。本插件在 [iterate-plugin 独立仓库](https://github.com/jingzhao-l/iterate-plugin) 维护仓库根即插件的发布位,由主仓库通过 `git subtree` 同步,内容与 npm 包一致:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
dsh plugin --profile web add "github:jingzhao-l/iterate-plugin#main"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
安装完成后需重启 dsh 服务(建议 `dsh web --patch`)并刷新页面,宿主与客户端 UI 层才会加载。
|
|
67
|
+
|
|
58
68
|
### 本地开发 / 源码挂载
|
|
59
69
|
|
|
60
70
|
```bash
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join, resolve, sep } from 'node:path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
/**
|
|
5
|
+
* Load and parse iterate.config.yaml from the project root.
|
|
6
|
+
* Returns null if the file is missing or invalid.
|
|
7
|
+
*/
|
|
8
|
+
export function loadConfig(projectRoot) {
|
|
9
|
+
try {
|
|
10
|
+
const content = readFileSync(join(projectRoot, 'iterate.config.yaml'), 'utf-8');
|
|
11
|
+
const parsed = yaml.load(content);
|
|
12
|
+
if (!parsed || typeof parsed !== 'object')
|
|
13
|
+
return null;
|
|
14
|
+
return parsed;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Sensible defaults for every config field. These are the "Master" config:
|
|
22
|
+
* when a project has no iterate.config.yaml (or only partial overrides), every
|
|
23
|
+
* missing key is filled from here so the plugin is usable out of the box while
|
|
24
|
+
* never inventing trusted validation commands (they must be configured).
|
|
25
|
+
*/
|
|
26
|
+
export function defaultConfig() {
|
|
27
|
+
return {
|
|
28
|
+
goal: 'Improve code quality and maintainability',
|
|
29
|
+
max_rounds: 7,
|
|
30
|
+
language: 'en',
|
|
31
|
+
dimensions: [
|
|
32
|
+
'correctness',
|
|
33
|
+
'security',
|
|
34
|
+
'performance',
|
|
35
|
+
'architecture',
|
|
36
|
+
'style-tests',
|
|
37
|
+
'tech-debt',
|
|
38
|
+
'spec-compliance',
|
|
39
|
+
'frontend-backend',
|
|
40
|
+
'ui-ux',
|
|
41
|
+
],
|
|
42
|
+
review: { scope: 'full' },
|
|
43
|
+
atomic: { max_lines: 20, max_adjacent_methods: 3 },
|
|
44
|
+
git: {
|
|
45
|
+
target_branch: 'main',
|
|
46
|
+
use_worktree: false,
|
|
47
|
+
push_per_round: false,
|
|
48
|
+
auto_merge: false,
|
|
49
|
+
},
|
|
50
|
+
validation: { command_whitelist: [], commands: {} },
|
|
51
|
+
reviewer: { output_schema_validation: true },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Recursively merge `override` on top of `base`.
|
|
56
|
+
* - Missing keys in `base` are added from `override`.
|
|
57
|
+
* - Present keys in `override` win.
|
|
58
|
+
* - Plain objects are merged recursively; arrays and scalars are replaced
|
|
59
|
+
* wholesale by the override (arrays are NOT concatenated).
|
|
60
|
+
* Returns a NEW object; neither input is mutated.
|
|
61
|
+
*/
|
|
62
|
+
export function mergeConfig(base, override) {
|
|
63
|
+
if (!override || typeof override !== 'object')
|
|
64
|
+
return { ...base };
|
|
65
|
+
const out = { ...base };
|
|
66
|
+
for (const [key, value] of Object.entries(override)) {
|
|
67
|
+
if (value === undefined)
|
|
68
|
+
continue;
|
|
69
|
+
const baseValue = out[key];
|
|
70
|
+
if (baseValue &&
|
|
71
|
+
typeof baseValue === 'object' &&
|
|
72
|
+
!Array.isArray(baseValue) &&
|
|
73
|
+
value &&
|
|
74
|
+
typeof value === 'object' &&
|
|
75
|
+
!Array.isArray(value)) {
|
|
76
|
+
out[key] = mergeConfig(baseValue, value);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
out[key] = value;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Load the EFFECTIVE config for a project: project-root overrides merged on top
|
|
86
|
+
* of the built-in defaults ("Master + Overrides"). Never returns null — a
|
|
87
|
+
* project without a config file simply runs on the defaults (with an empty
|
|
88
|
+
* validation command set, so nothing untrusted can ever execute).
|
|
89
|
+
*/
|
|
90
|
+
export function loadEffectiveConfig(projectRoot) {
|
|
91
|
+
const override = loadConfig(projectRoot);
|
|
92
|
+
if (!override) {
|
|
93
|
+
return { config: defaultConfig(), source: 'defaults', override: null };
|
|
94
|
+
}
|
|
95
|
+
const merged = mergeConfig(defaultConfig(), override);
|
|
96
|
+
return { config: merged, source: 'override', override };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Check whether a command is in the predefined commands list.
|
|
100
|
+
* A command is allowed if it is EXACTLY (after trim) listed in any
|
|
101
|
+
* module's command array in `validation.commands`.
|
|
102
|
+
* This replaces the old prefix-based whitelist at runtime — the
|
|
103
|
+
* `command_whitelist` is still used for config-time validation only.
|
|
104
|
+
*/
|
|
105
|
+
export function isCommandAllowed(command, predefinedCommands) {
|
|
106
|
+
const trimmed = command.trim();
|
|
107
|
+
return predefinedCommands.includes(trimmed);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Flatten all commands from `validation.commands` into a single string array.
|
|
111
|
+
* Used for runtime exact-match checking.
|
|
112
|
+
*/
|
|
113
|
+
export function flattenCommands(commands) {
|
|
114
|
+
if (!commands || typeof commands !== 'object')
|
|
115
|
+
return [];
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const v of Object.values(commands)) {
|
|
118
|
+
if (Array.isArray(v))
|
|
119
|
+
out.push(...v);
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Validate that the config has all required fields.
|
|
125
|
+
* Returns an array of missing field paths.
|
|
126
|
+
*/
|
|
127
|
+
export function validateConfig(config) {
|
|
128
|
+
const errors = [];
|
|
129
|
+
if (!config || typeof config !== 'object') {
|
|
130
|
+
errors.push('root');
|
|
131
|
+
return errors;
|
|
132
|
+
}
|
|
133
|
+
const c = config;
|
|
134
|
+
if (!c.goal)
|
|
135
|
+
errors.push('goal');
|
|
136
|
+
if (!Array.isArray(c.dimensions))
|
|
137
|
+
errors.push('dimensions');
|
|
138
|
+
if (!c.validation || typeof c.validation !== 'object') {
|
|
139
|
+
errors.push('validation');
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
const v = c.validation;
|
|
143
|
+
if (!Array.isArray(v.command_whitelist))
|
|
144
|
+
errors.push('validation.command_whitelist');
|
|
145
|
+
if (!v.commands || typeof v.commands !== 'object')
|
|
146
|
+
errors.push('validation.commands');
|
|
147
|
+
}
|
|
148
|
+
return errors;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Resolve a caller-supplied project root to a safe absolute path.
|
|
152
|
+
*
|
|
153
|
+
* Every tool accepts a model-controlled `path` argument. Before it is used in
|
|
154
|
+
* any file read/write or as a command `cwd`, it must be sanitized:
|
|
155
|
+
* - an empty/missing `path` falls back to the current working directory;
|
|
156
|
+
* - the path is resolved to an absolute path (collapsing `..` and symlinks);
|
|
157
|
+
* - the filesystem root (`/`) is refused — it would let a prompt point tools
|
|
158
|
+
* at arbitrary system directories (path-traversal escape).
|
|
159
|
+
*
|
|
160
|
+
* Returns `{ ok: true, root }` on success, or `{ ok: false, reason }` when the
|
|
161
|
+
* path is unsafe; callers must short-circuit on the failure and return a
|
|
162
|
+
* structured error instead of proceeding.
|
|
163
|
+
*/
|
|
164
|
+
export function resolveProjectRoot(input) {
|
|
165
|
+
const raw = (input ?? '').trim();
|
|
166
|
+
const root = raw ? resolve(raw) : resolve(process.cwd());
|
|
167
|
+
if (!root || root === sep) {
|
|
168
|
+
return { ok: false, reason: 'Refusing filesystem root as project root.' };
|
|
169
|
+
}
|
|
170
|
+
return { ok: true, root };
|
|
171
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/config-write.ts — shared helpers for safely WRITING iterate.config.yaml.
|
|
3
|
+
*
|
|
4
|
+
* Used by the `iterate_config` write operation. Provides:
|
|
5
|
+
* - validateConfigUpdates : validate a caller-supplied partial update
|
|
6
|
+
* - applyConfigUpdates : merge a partial update into the current config
|
|
7
|
+
* - writeConfigFile : backup + write + rollback on failure
|
|
8
|
+
*
|
|
9
|
+
* The security posture mirrors the triage tool: never overwrite a malformed
|
|
10
|
+
* config, always back up before writing, roll back on failure.
|
|
11
|
+
*/
|
|
12
|
+
import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import yaml from 'js-yaml';
|
|
15
|
+
/** Config file name (must match config-loader). */
|
|
16
|
+
export const CONFIG_FILE = 'iterate.config.yaml';
|
|
17
|
+
/** Backup suffix helper (filesystem-safe timestamp). */
|
|
18
|
+
export function configBackupSuffix(now = new Date()) {
|
|
19
|
+
return now.toISOString().replace(/[:.]/g, '-');
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Validate a partial config update.
|
|
23
|
+
* Returns an array of error strings (empty when the update is valid).
|
|
24
|
+
*/
|
|
25
|
+
export function validateConfigUpdates(updates) {
|
|
26
|
+
const errors = [];
|
|
27
|
+
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
|
|
28
|
+
return ['updates must be a JSON object'];
|
|
29
|
+
}
|
|
30
|
+
if ('goal' in updates && typeof updates.goal !== 'string') {
|
|
31
|
+
errors.push('updates.goal must be a string');
|
|
32
|
+
}
|
|
33
|
+
if ('language' in updates && updates.language !== 'zh' && updates.language !== 'en') {
|
|
34
|
+
errors.push('updates.language must be "zh" or "en"');
|
|
35
|
+
}
|
|
36
|
+
if ('dimensions' in updates) {
|
|
37
|
+
if (!Array.isArray(updates.dimensions) || updates.dimensions.some((d) => typeof d !== 'string' || d.trim().length === 0)) {
|
|
38
|
+
errors.push('updates.dimensions must be an array of non-empty strings');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if ('max_rounds' in updates) {
|
|
42
|
+
if (typeof updates.max_rounds !== 'number' || !Number.isInteger(updates.max_rounds) || updates.max_rounds < 1) {
|
|
43
|
+
errors.push('updates.max_rounds must be a positive integer');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if ('review' in updates) {
|
|
47
|
+
const r = updates.review;
|
|
48
|
+
if (!r || typeof r !== 'object') {
|
|
49
|
+
errors.push('updates.review must be an object');
|
|
50
|
+
}
|
|
51
|
+
else if (r.scope !== undefined && r.scope !== 'full' && r.scope !== 'changed-only') {
|
|
52
|
+
errors.push('updates.review.scope must be "full" or "changed-only"');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if ('atomic' in updates) {
|
|
56
|
+
const a = updates.atomic;
|
|
57
|
+
if (!a || typeof a !== 'object') {
|
|
58
|
+
errors.push('updates.atomic must be an object');
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
if (a.max_lines !== undefined && (typeof a.max_lines !== 'number' || !Number.isInteger(a.max_lines) || a.max_lines < 1)) {
|
|
62
|
+
errors.push('updates.atomic.max_lines must be a positive integer');
|
|
63
|
+
}
|
|
64
|
+
if (a.max_adjacent_methods !== undefined && (typeof a.max_adjacent_methods !== 'number' || a.max_adjacent_methods < 0)) {
|
|
65
|
+
errors.push('updates.atomic.max_adjacent_methods must be a non-negative number');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if ('git' in updates) {
|
|
70
|
+
const g = updates.git;
|
|
71
|
+
if (!g || typeof g !== 'object') {
|
|
72
|
+
errors.push('updates.git must be an object');
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
if (g.target_branch !== undefined && typeof g.target_branch !== 'string') {
|
|
76
|
+
errors.push('updates.git.target_branch must be a string');
|
|
77
|
+
}
|
|
78
|
+
for (const boolKey of ['use_worktree', 'push_per_round', 'auto_merge']) {
|
|
79
|
+
if (g[boolKey] !== undefined && typeof g[boolKey] !== 'boolean') {
|
|
80
|
+
errors.push(`updates.git.${boolKey} must be a boolean`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if ('validation' in updates) {
|
|
86
|
+
const v = updates.validation;
|
|
87
|
+
if (!v || typeof v !== 'object') {
|
|
88
|
+
errors.push('updates.validation must be an object');
|
|
89
|
+
}
|
|
90
|
+
else if ('commands' in v && v.commands !== undefined && typeof v.commands !== 'object') {
|
|
91
|
+
errors.push('updates.validation.commands must be an object of command arrays');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if ('personalization' in updates && (!updates.personalization || typeof updates.personalization !== 'object')) {
|
|
95
|
+
errors.push('updates.personalization must be an object');
|
|
96
|
+
}
|
|
97
|
+
if ('onboarding' in updates && (!updates.onboarding || typeof updates.onboarding !== 'object')) {
|
|
98
|
+
errors.push('updates.onboarding must be an object');
|
|
99
|
+
}
|
|
100
|
+
return errors;
|
|
101
|
+
}
|
|
102
|
+
/** Recursively merge `updates` over `base` (arrays replaced wholesale). */
|
|
103
|
+
export function applyConfigUpdates(base, updates) {
|
|
104
|
+
const out = { ...base };
|
|
105
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
106
|
+
if (value === undefined)
|
|
107
|
+
continue;
|
|
108
|
+
const baseValue = out[key];
|
|
109
|
+
if (baseValue &&
|
|
110
|
+
typeof baseValue === 'object' &&
|
|
111
|
+
!Array.isArray(baseValue) &&
|
|
112
|
+
value &&
|
|
113
|
+
typeof value === 'object' &&
|
|
114
|
+
!Array.isArray(value)) {
|
|
115
|
+
out[key] = applyConfigUpdates(baseValue, value);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
out[key] = value;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Read the raw config object from disk (empty object when missing).
|
|
125
|
+
* Throws when the file exists but cannot be parsed as a YAML mapping
|
|
126
|
+
* (never overwrite a malformed config).
|
|
127
|
+
*/
|
|
128
|
+
export function readRawConfig(configPath) {
|
|
129
|
+
if (!existsSync(configPath))
|
|
130
|
+
return {};
|
|
131
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
132
|
+
let parsed;
|
|
133
|
+
try {
|
|
134
|
+
parsed = yaml.load(content);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
throw new Error('existing iterate.config.yaml is not a valid YAML mapping');
|
|
138
|
+
}
|
|
139
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
140
|
+
throw new Error('existing iterate.config.yaml is not a valid YAML mapping');
|
|
141
|
+
}
|
|
142
|
+
return parsed;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Write a config object to disk with backup + rollback.
|
|
146
|
+
* Returns `{ ok: true, backupPath }` or `{ ok: false, error }`.
|
|
147
|
+
*/
|
|
148
|
+
export function writeConfigFile(projectRoot, config) {
|
|
149
|
+
const configPath = join(projectRoot, CONFIG_FILE);
|
|
150
|
+
const hadFile = existsSync(configPath);
|
|
151
|
+
const backupPath = hadFile ? `${configPath}.bak-${configBackupSuffix()}` : null;
|
|
152
|
+
if (backupPath) {
|
|
153
|
+
try {
|
|
154
|
+
copyFileSync(configPath, backupPath);
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
return { ok: false, error: `failed to create backup: ${String(err)}` };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
writeFileSync(configPath, yaml.dump(config, { noRefs: true }), 'utf-8');
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
try {
|
|
165
|
+
if (backupPath)
|
|
166
|
+
copyFileSync(backupPath, configPath);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
// Rollback failure is reported, never swallowed silently.
|
|
170
|
+
}
|
|
171
|
+
return { ok: false, error: `failed to write config: ${String(err)}` };
|
|
172
|
+
}
|
|
173
|
+
return { ok: true, backupPath };
|
|
174
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* iterate-plugin — dsh plugin for the iterate autonomous closed-loop workflow
|
|
3
|
+
*
|
|
4
|
+
* Architecture:
|
|
5
|
+
* - The plugin registers 6 tools (config, validate, decision-log, context, review, triage)
|
|
6
|
+
* - The plugin injects a system prompt section teaching the iterate workflow pattern
|
|
7
|
+
* - The model (prompted by the skill) writes a workflow script using dsh's `workflow` tool
|
|
8
|
+
* - The workflow script uses `agent()` / `parallel()` / `phase()` / `log()` to orchestrate
|
|
9
|
+
* - Subagents use the 6 tools to do real work (read config, run validation, log decisions, review, triage)
|
|
10
|
+
*
|
|
11
|
+
* Tool invocation model:
|
|
12
|
+
* - Workflow script CANNOT call tools directly (sandboxed vm, no Node API)
|
|
13
|
+
* - Workflow script spawns subagents via `agent(prompt, opts)`
|
|
14
|
+
* - Subagents are full agent sessions with access to all registered tools
|
|
15
|
+
* - The script is pure orchestration: fan-out, aggregate, loop, stop
|
|
16
|
+
*
|
|
17
|
+
* Key files:
|
|
18
|
+
* - src/index.ts — Plugin entry: register tools + inject skill prompt
|
|
19
|
+
* - src/tools/ — 6 tool implementations + meta-review/review engines
|
|
20
|
+
* - src/config-loader.ts — YAML config loading
|
|
21
|
+
* - src/types.ts — Shared types
|
|
22
|
+
*/
|
|
23
|
+
import { registerConfigTool } from "./tools/config.js";
|
|
24
|
+
import { registerValidateTool } from "./tools/validate.js";
|
|
25
|
+
import { registerDecisionLogTool } from "./tools/decision-log.js";
|
|
26
|
+
import { registerContextTool } from "./tools/context.js";
|
|
27
|
+
import { registerReviewTool } from "./tools/review.js";
|
|
28
|
+
import { registerTriageTool } from "./tools/triage.js";
|
|
29
|
+
import { registerFixTool, registerDiffTool, registerRollbackTool } from "./tools/fix.js";
|
|
30
|
+
import { registerCheckpointTool, registerStatusTool } from "./tools/checkpoint.js";
|
|
31
|
+
import { registerHistoryTool } from "./tools/history.js";
|
|
32
|
+
import { registerPruneTool } from "./tools/prune.js";
|
|
33
|
+
import { ITERATE_SKILL_PROMPT } from "./skill-prompt.js";
|
|
34
|
+
export const name = 'iterate-plugin';
|
|
35
|
+
export const inject = ['tools', 'systemPrompt'];
|
|
36
|
+
export function apply(ctx) {
|
|
37
|
+
// 1. Register the 11 tools
|
|
38
|
+
registerConfigTool(ctx);
|
|
39
|
+
registerValidateTool(ctx);
|
|
40
|
+
registerDecisionLogTool(ctx);
|
|
41
|
+
registerContextTool(ctx);
|
|
42
|
+
registerReviewTool(ctx);
|
|
43
|
+
registerTriageTool(ctx);
|
|
44
|
+
registerFixTool(ctx);
|
|
45
|
+
registerDiffTool(ctx);
|
|
46
|
+
registerRollbackTool(ctx);
|
|
47
|
+
registerCheckpointTool(ctx);
|
|
48
|
+
registerStatusTool(ctx);
|
|
49
|
+
registerHistoryTool(ctx);
|
|
50
|
+
registerPruneTool(ctx);
|
|
51
|
+
// 2. Inject the iterate skill prompt as a system prompt section
|
|
52
|
+
// This teaches the model how to write iterate workflow scripts using the tools.
|
|
53
|
+
ctx.systemPrompt.section({
|
|
54
|
+
name: 'iterate-skill',
|
|
55
|
+
order: 100,
|
|
56
|
+
text: ITERATE_SKILL_PROMPT,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Meta-review engine: review a ReviewReport and produce a final review report.
|
|
3
|
+
*
|
|
4
|
+
* This is the "纯反复审查" closing step: after the review loop converges on
|
|
5
|
+
* zero new findings, we don't just trust the aggregated report — we audit the
|
|
6
|
+
* report itself for internal consistency (counts, severity buckets, dimension
|
|
7
|
+
* sums, sort order, convergence math). The result is a deterministic
|
|
8
|
+
* `MetaReviewResult` plus a `FinalReviewReport` that pairs the source report
|
|
9
|
+
* with a verdict.
|
|
10
|
+
*
|
|
11
|
+
* Like `review.ts`, this module contains NO I/O and NO agent spawning — it is
|
|
12
|
+
* the pure, testable core. The workflow script (skill-prompt.ts) orchestrates
|
|
13
|
+
* the actual subagent-driven meta-review critique; all deterministic math
|
|
14
|
+
* lives here.
|
|
15
|
+
*/
|
|
16
|
+
import { sortFindings } from "./review.js";
|
|
17
|
+
/** Number of distinct consistency checks performed by `metaReviewReport`. */
|
|
18
|
+
export const META_REVIEW_CHECKS = 6;
|
|
19
|
+
/**
|
|
20
|
+
* Audit a ReviewReport for internal consistency.
|
|
21
|
+
*
|
|
22
|
+
* Checks (all deterministic, no I/O):
|
|
23
|
+
* 1. COUNT_MATCH: summary.totalFindings === findings.length
|
|
24
|
+
* 2. SEVERITY_SUM: summary severity buckets (critical+high+medium+low) total
|
|
25
|
+
* to summary.totalFindings AND match the actual per-severity counts.
|
|
26
|
+
* 3. DIMENSION_SUM: summary.byDimension values sum to totalFindings and every
|
|
27
|
+
* finding's dimension is present in report.dimensions.
|
|
28
|
+
* 4. SORT_ORDER: findings are severity-sorted (most severe first).
|
|
29
|
+
* 5. CONVERGENCE: findingsByRound sums to totalFindings and the `converged`
|
|
30
|
+
* flag is consistent with the last round's new-finding count.
|
|
31
|
+
* 6. ROUND_SHAPE: every round has a positive round number; no round is
|
|
32
|
+
* missing from the sequence. A round with zero findings is only flagged
|
|
33
|
+
* when it is NOT the last round — an empty FINAL round means the review
|
|
34
|
+
* converged (the last pass found nothing new), which is the expected,
|
|
35
|
+
* successful termination of a dry-run, not a defect.
|
|
36
|
+
*
|
|
37
|
+
* Returns a MetaReviewResult; `passed` is true only when all checks pass.
|
|
38
|
+
*/
|
|
39
|
+
export function metaReviewReport(report) {
|
|
40
|
+
const issues = [];
|
|
41
|
+
const add = (code, severity, summary, detail) => {
|
|
42
|
+
issues.push({ code, severity, summary, detail });
|
|
43
|
+
};
|
|
44
|
+
// Guard: a null/undefined report is a hard failure, not a crash.
|
|
45
|
+
if (!report || typeof report !== 'object') {
|
|
46
|
+
return {
|
|
47
|
+
passed: false,
|
|
48
|
+
verdict: 'revise',
|
|
49
|
+
checksRun: META_REVIEW_CHECKS,
|
|
50
|
+
issues: [
|
|
51
|
+
{
|
|
52
|
+
code: 'REPORT_UNDEFINED',
|
|
53
|
+
severity: 'critical',
|
|
54
|
+
summary: 'Report is missing or not an object',
|
|
55
|
+
detail: 'metaReviewReport received no valid ReviewReport to audit.',
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const findings = Array.isArray(report.findings) ? report.findings : [];
|
|
61
|
+
const summary = report.summary ?? {};
|
|
62
|
+
const total = Number(summary.totalFindings ?? 0);
|
|
63
|
+
const dimensions = Array.isArray(report.dimensions) ? report.dimensions : [];
|
|
64
|
+
// 1. COUNT_MATCH
|
|
65
|
+
if (total !== findings.length) {
|
|
66
|
+
add('COUNT_MATCH', 'high', `summary.totalFindings (${total}) does not match findings.length (${findings.length})`, `The report claims ${total} findings but lists ${findings.length}.`);
|
|
67
|
+
}
|
|
68
|
+
// 2. SEVERITY_SUM
|
|
69
|
+
const sevCounts = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
70
|
+
for (const f of findings) {
|
|
71
|
+
const s = f?.severity;
|
|
72
|
+
if (s && s in sevCounts)
|
|
73
|
+
sevCounts[s]++;
|
|
74
|
+
}
|
|
75
|
+
const bucketSum = sevCounts.critical + sevCounts.high + sevCounts.medium + sevCounts.low;
|
|
76
|
+
const declaredSeveritySum = Number(summary.critical ?? 0) +
|
|
77
|
+
Number(summary.high ?? 0) +
|
|
78
|
+
Number(summary.medium ?? 0) +
|
|
79
|
+
Number(summary.low ?? 0);
|
|
80
|
+
if (declaredSeveritySum !== total || bucketSum !== total) {
|
|
81
|
+
add('SEVERITY_SUM', 'high', 'Severity bucket counts are inconsistent with totalFindings', `declared buckets sum to ${declaredSeveritySum}, actual buckets sum to ${bucketSum}, ` +
|
|
82
|
+
`but totalFindings is ${total}.`);
|
|
83
|
+
}
|
|
84
|
+
// 3. DIMENSION_SUM
|
|
85
|
+
const byDim = summary.byDimension ?? {};
|
|
86
|
+
let dimSum = 0;
|
|
87
|
+
for (const v of Object.values(byDim))
|
|
88
|
+
dimSum += Number(v) || 0;
|
|
89
|
+
if (dimSum !== total) {
|
|
90
|
+
add('DIMENSION_SUM', 'high', 'byDimension counts do not sum to totalFindings', `byDimension sums to ${dimSum}, but totalFindings is ${total}.`);
|
|
91
|
+
}
|
|
92
|
+
const invalidDim = findings.find((f) => !dimensions.includes(f?.dimension));
|
|
93
|
+
if (invalidDim) {
|
|
94
|
+
add('DIMENSION_UNKNOWN', 'medium', `Finding references unknown dimension "${invalidDim.dimension}"`, `dimension "${invalidDim.dimension}" is not in report.dimensions ` +
|
|
95
|
+
`(${dimensions.join(', ') || 'none'}).`);
|
|
96
|
+
}
|
|
97
|
+
// 4. SORT_ORDER
|
|
98
|
+
const sorted = sortFindings(findings);
|
|
99
|
+
const isSorted = sorted.every((f, i) => f === findings[i]);
|
|
100
|
+
if (!isSorted) {
|
|
101
|
+
add('SORT_ORDER', 'low', 'Findings are not severity-sorted', 'findings should be ordered most-severe first (critical > high > medium > low).');
|
|
102
|
+
}
|
|
103
|
+
// 5. CONVERGENCE
|
|
104
|
+
const findingsByRound = Array.isArray(report.convergence?.findingsByRound)
|
|
105
|
+
? report.convergence.findingsByRound
|
|
106
|
+
: [];
|
|
107
|
+
const convSum = findingsByRound.reduce((a, b) => a + Number(b) || 0, 0);
|
|
108
|
+
if (convSum !== total) {
|
|
109
|
+
add('CONVERGENCE_SUM', 'high', 'convergence.findingsByRound does not sum to totalFindings', `findingsByRound ${JSON.stringify(findingsByRound)} sums to ${convSum}, ` +
|
|
110
|
+
`but totalFindings is ${total}.`);
|
|
111
|
+
}
|
|
112
|
+
// `findingsByRound` is indexed by the actual round number (round r → index
|
|
113
|
+
// r-1), so the "last round" is the LAST RECORDED round's reported number, not
|
|
114
|
+
// the array's last index (the array is sized to the highest round, which only
|
|
115
|
+
// equals the record count for contiguous 1..N round numbers). Read the flag
|
|
116
|
+
// consistency the same way buildReviewReport/computeConvergence set it.
|
|
117
|
+
const reportRounds = Array.isArray(report.rounds) ? report.rounds : [];
|
|
118
|
+
const lastRecordedRound = reportRounds.length > 0 && typeof reportRounds[reportRounds.length - 1]?.round === 'number'
|
|
119
|
+
? reportRounds[reportRounds.length - 1].round
|
|
120
|
+
: null;
|
|
121
|
+
const lastRoundNew = lastRecordedRound !== null && lastRecordedRound > 0
|
|
122
|
+
? Number(findingsByRound[lastRecordedRound - 1] ?? 0)
|
|
123
|
+
: null;
|
|
124
|
+
const expectedConverged = lastRoundNew === 0;
|
|
125
|
+
if (report.convergence?.converged !== expectedConverged) {
|
|
126
|
+
add('CONVERGENCE_FLAG', 'medium', 'convergence.converged flag is inconsistent with the last round', `last round reported ${lastRoundNew} new findings, so converged should be ` +
|
|
127
|
+
`${expectedConverged}, but it is ${report.convergence?.converged}.`);
|
|
128
|
+
}
|
|
129
|
+
// 6. ROUND_SHAPE
|
|
130
|
+
const rounds = Array.isArray(report.rounds) ? report.rounds : [];
|
|
131
|
+
const seenRounds = new Set();
|
|
132
|
+
for (const [index, r] of rounds.entries()) {
|
|
133
|
+
if (!r || typeof r.round !== 'number' || r.round < 1) {
|
|
134
|
+
add('ROUND_NUMBER', 'medium', 'A round has a missing or non-positive round number', `round: ${JSON.stringify(r)}`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
seenRounds.add(r.round);
|
|
138
|
+
const isLastRound = index === rounds.length - 1;
|
|
139
|
+
if (!Array.isArray(r.findings) || (r.findings.length === 0 && !isLastRound)) {
|
|
140
|
+
add('ROUND_EMPTY', 'low', `Round ${r.round} has no findings`, 'A recorded round should contain at least one finding — except a final converged round, ' +
|
|
141
|
+
'which finding nothing new is the expected success signal.');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (let i = 1; i <= rounds.length; i++) {
|
|
145
|
+
if (!seenRounds.has(i)) {
|
|
146
|
+
add('ROUND_GAP', 'medium', `Round ${i} is missing from the round sequence`, `rounds present: ${[...seenRounds].sort((a, b) => a - b).join(', ') || 'none'}.`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const passed = issues.length === 0;
|
|
150
|
+
return {
|
|
151
|
+
passed,
|
|
152
|
+
verdict: passed ? 'approved' : 'revise',
|
|
153
|
+
checksRun: META_REVIEW_CHECKS,
|
|
154
|
+
issues,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Build the final review report: pair the source report with its meta-review
|
|
159
|
+
* verdict and a rolled-up summary. Pure and deterministic.
|
|
160
|
+
*/
|
|
161
|
+
export function buildFinalReviewReport(report) {
|
|
162
|
+
const meta = metaReviewReport(report);
|
|
163
|
+
const summary = report?.summary ?? {};
|
|
164
|
+
const verdict = meta.passed ? 'approved' : 'needs_revision';
|
|
165
|
+
return {
|
|
166
|
+
verdict,
|
|
167
|
+
source: report,
|
|
168
|
+
metaReview: meta,
|
|
169
|
+
summary: {
|
|
170
|
+
totalFindings: Number(summary.totalFindings ?? 0),
|
|
171
|
+
critical: Number(summary.critical ?? 0),
|
|
172
|
+
high: Number(summary.high ?? 0),
|
|
173
|
+
medium: Number(summary.medium ?? 0),
|
|
174
|
+
low: Number(summary.low ?? 0),
|
|
175
|
+
converged: Boolean(report?.convergence?.converged),
|
|
176
|
+
totalRounds: Number(report?.convergence?.totalRounds ?? 0),
|
|
177
|
+
reportIssues: meta.issues.length,
|
|
178
|
+
verdict,
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared filesystem layout for the iterate plugin's runtime state.
|
|
3
|
+
*
|
|
4
|
+
* All runtime artifacts live under `<projectRoot>/.iterate/`:
|
|
5
|
+
* .iterate/decision-log.jsonl — append-only decision log
|
|
6
|
+
* .iterate/fixes/ — fix system: backups + fix registry
|
|
7
|
+
* .iterate/checkpoint.json — iteration checkpoint (resume support)
|
|
8
|
+
*
|
|
9
|
+
* Kept separate from config-loader so every tool points at the same dirs.
|
|
10
|
+
*/
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
/** Runtime state root for a project (e.g. `<projectRoot>/.iterate`). */
|
|
13
|
+
export function iterateDir(projectRoot) {
|
|
14
|
+
return join(projectRoot, '.iterate');
|
|
15
|
+
}
|
|
16
|
+
/** Fix-system directory (backups + registry). */
|
|
17
|
+
export function fixesDir(projectRoot) {
|
|
18
|
+
return join(iterateDir(projectRoot), 'fixes');
|
|
19
|
+
}
|
|
20
|
+
/** Fix-registry file (JSON). */
|
|
21
|
+
export function fixRegistryPath(projectRoot) {
|
|
22
|
+
return join(fixesDir(projectRoot), 'registry.json');
|
|
23
|
+
}
|
|
24
|
+
/** Fix-backup file for one fix id + timestamp. */
|
|
25
|
+
export function fixBackupPath(projectRoot, id, timestamp) {
|
|
26
|
+
const safe = id.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
27
|
+
return join(fixesDir(projectRoot), `${safe}_${timestamp.replace(/[:.]/g, '-')}.bak`);
|
|
28
|
+
}
|
|
29
|
+
/** Iteration checkpoint file (JSON). */
|
|
30
|
+
export function checkpointPath(projectRoot) {
|
|
31
|
+
return join(iterateDir(projectRoot), 'checkpoint.json');
|
|
32
|
+
}
|