iterate-plugin 2.3.6
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/LICENSE +21 -0
- package/README.md +143 -0
- package/cordis.patch.yml +9 -0
- package/package.json +59 -0
- package/src/config-loader.ts +162 -0
- package/src/index.ts +50 -0
- package/src/meta-review.ts +289 -0
- package/src/review.ts +360 -0
- package/src/skill-prompt.ts +227 -0
- package/src/tools/config.ts +90 -0
- package/src/tools/context.ts +170 -0
- package/src/tools/decision-log.ts +163 -0
- package/src/tools/review.ts +174 -0
- package/src/tools/validate.ts +159 -0
- package/src/types.ts +100 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The iterate skill prompt injected into the system prompt.
|
|
3
|
+
*
|
|
4
|
+
* This teaches the model how to write a correct `workflow` script that
|
|
5
|
+
* performs the iterate autonomous closed-loop (or dry-run pure review),
|
|
6
|
+
* using the 5 registered tools via subagents.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const ITERATE_SKILL_PROMPT = `
|
|
10
|
+
## Iterate Workflow (autonomous code iteration)
|
|
11
|
+
|
|
12
|
+
You have the iterate plugin installed, which registers these tools:
|
|
13
|
+
- \`iterate_config\` — read iterate.config.yaml (dimensions, validation commands, personalization)
|
|
14
|
+
- \`iterate_validate\` — run a whitelisted validation command
|
|
15
|
+
- \`iterate_decision_log\` — append to the decision log
|
|
16
|
+
- \`iterate_context\` — read SKILL.md / ITERATE.md project context
|
|
17
|
+
- \`iterate_review\` — deterministic review engine: \`plan\` builds the review plan; \`aggregate\` dedupes/merges findings and computes convergence. Purely computational.
|
|
18
|
+
|
|
19
|
+
### When to use
|
|
20
|
+
When the user asks to review or iterate on the project (e.g. "review this project", "iterate on error handling", "check the codebase for issues", "dry-run review", "反复审查"), run an iterate **workflow** by calling the \`workflow\` tool.
|
|
21
|
+
- If the user says "review only" / "dry run" / "不要改文件" / "反复审查" → use \`mode: "dry-run"\`.
|
|
22
|
+
- Otherwise → use \`mode: "normal"\`.
|
|
23
|
+
|
|
24
|
+
### Workflow script contract
|
|
25
|
+
Write a plain-JS script (top-level await, ends with \`return <json>\`). Available globals:
|
|
26
|
+
- \`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\`.
|
|
27
|
+
- \`parallel(thunks): Promise<value[]>\` — run zero-arg async functions concurrently, await all.
|
|
28
|
+
- \`phase(title)\`, \`log(message)\` — progress narration.
|
|
29
|
+
- \`args\` — the args object passed to the workflow tool.
|
|
30
|
+
|
|
31
|
+
The script CANNOT call tools directly. Subagents are the ones who call tools.
|
|
32
|
+
|
|
33
|
+
### Dry-run mode workflow (pure review — the ONLY mode that never touches files)
|
|
34
|
+
This is iterate's read-only health-check: repeated review rounds until findings converge,
|
|
35
|
+
then produce an auditable report, then audit the report itself (meta-review) and give a
|
|
36
|
+
final review report. NO file writes, NO git, NO branches, NO worktree.
|
|
37
|
+
|
|
38
|
+
Canonical script — reproduce this structure exactly (adjust dims via the plan):
|
|
39
|
+
|
|
40
|
+
\`\`\`js
|
|
41
|
+
phase('plan')
|
|
42
|
+
const planRes = await agent(
|
|
43
|
+
'Call iterate_review({operation:"plan", mode:"dry-run"}) and return the plan JSON.',
|
|
44
|
+
{ label: 'review:plan' }
|
|
45
|
+
)
|
|
46
|
+
const plan = (planRes && planRes.plan) ? planRes.plan : null
|
|
47
|
+
if (!plan || !Array.isArray(plan.dimensions)) throw new Error('plan failed: iterate_review did not return a valid plan')
|
|
48
|
+
const dims = plan.dimensions.map(d => d.id)
|
|
49
|
+
const maxRounds = plan.maxReviewRounds
|
|
50
|
+
const known = [] // cumulative deduped findings across rounds
|
|
51
|
+
const rounds = [] // raw per-round findings
|
|
52
|
+
|
|
53
|
+
phase('review')
|
|
54
|
+
for (let r = 1; r <= maxRounds; r++) {
|
|
55
|
+
log('round ' + r + ' of ' + maxRounds + ' — finding NEW issues only')
|
|
56
|
+
const raw = await parallel(dims.map(dim => () => agent(
|
|
57
|
+
'Review dimension "' + dim + '". Already-known findings (do NOT re-report): ' +
|
|
58
|
+
JSON.stringify(known) + '\\nReturn the findings JSON object.',
|
|
59
|
+
{ label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }
|
|
60
|
+
)))
|
|
61
|
+
const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
|
|
62
|
+
rounds.push(thisRound)
|
|
63
|
+
known.push(...thisRound.findings) // rough accumulation; final dedupe is deterministic in aggregate
|
|
64
|
+
// Check convergence deterministically
|
|
65
|
+
const agg = await agent(
|
|
66
|
+
'Call iterate_review({operation:"aggregate", mode:"dry-run", rounds:' + JSON.stringify(rounds) + ', maxReviewRounds:' + maxRounds + '}) and return the report JSON.',
|
|
67
|
+
{ label: 'review:aggregate:r' + r }
|
|
68
|
+
)
|
|
69
|
+
if (agg && agg.report && agg.report.convergence.findingsByRound[r-1] === 0) {
|
|
70
|
+
log('round ' + r + ' found 0 new findings — converged')
|
|
71
|
+
break
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
phase('report')
|
|
76
|
+
const finalAgg = await agent(
|
|
77
|
+
'Call iterate_review({operation:"aggregate", mode:"dry-run", rounds:' + JSON.stringify(rounds) + ', maxReviewRounds:' + maxRounds + '}) and return the report JSON.',
|
|
78
|
+
{ label: 'review:aggregate:final' }
|
|
79
|
+
)
|
|
80
|
+
const report = (finalAgg && finalAgg.report) ? finalAgg.report : null
|
|
81
|
+
if (!report || !report.convergence) throw new Error('aggregate failed: no valid report was produced')
|
|
82
|
+
await agent(
|
|
83
|
+
'Call iterate_decision_log({operation:"append", type:"report", round:' + report.convergence.totalRounds + ', data:{mode:"dry-run", totalFindings:' + report.summary.totalFindings + '}})',
|
|
84
|
+
{ label: 'review:log' }
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
phase('meta-review')
|
|
88
|
+
// Audit the report itself for internal consistency, then produce the final report.
|
|
89
|
+
const metaRes = await agent(
|
|
90
|
+
'Call iterate_review({operation:"meta-review", report:' + JSON.stringify(report) + '}) and return the finalReport JSON.',
|
|
91
|
+
{ label: 'review:meta' }
|
|
92
|
+
)
|
|
93
|
+
const finalReport = metaRes && metaRes.finalReport ? metaRes.finalReport : null
|
|
94
|
+
const metaAudit = finalReport && finalReport.metaReview ? finalReport.metaReview : null
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
mode: 'dry-run',
|
|
98
|
+
goal: report.goal,
|
|
99
|
+
rounds: rounds.length,
|
|
100
|
+
converged: report.convergence.converged,
|
|
101
|
+
stoppedReason: report.convergence.stoppedReason,
|
|
102
|
+
findingsByRound: report.convergence.findingsByRound,
|
|
103
|
+
totalFindings: report.summary.totalFindings,
|
|
104
|
+
bySeverity: { critical: report.summary.critical, high: report.summary.high, medium: report.summary.medium, low: report.summary.low },
|
|
105
|
+
byDimension: report.summary.byDimension,
|
|
106
|
+
report,
|
|
107
|
+
metaReview: metaAudit ? { verdict: finalReport.verdict, issues: metaAudit.issues || [], checksRun: metaAudit.checksRun || 0 } : null,
|
|
108
|
+
finalReport
|
|
109
|
+
}
|
|
110
|
+
\`\`\`
|
|
111
|
+
|
|
112
|
+
Key rules for dry-run:
|
|
113
|
+
- **NEVER call a fixer / never edit files / never create branches or worktree.** Reviewers read only.
|
|
114
|
+
- Each round feeds the already-known findings to reviewers so they hunt NEW issues only → that is what drives convergence.
|
|
115
|
+
- Stop when a round reports 0 new findings (converged) or maxReviewRounds is reached.
|
|
116
|
+
- The report (with per-round convergence stats + suggested fix priorities) is the deliverable.
|
|
117
|
+
- **Meta-review**: after building the report, audit it with \`iterate_review({operation:"meta-review"})\` for internal consistency (counts, severity buckets, dimension sums, sort order, convergence math). The \`finalReport.verdict\` is \`approved\` only when the report passes every check; otherwise \`needs_revision\`. Surface the final report and its verdict as the closing deliverable.
|
|
118
|
+
- Only a single \`report\` entry may be appended to the decision log; nothing else is written.
|
|
119
|
+
|
|
120
|
+
### Normal-mode workflow (autonomous closed loop)
|
|
121
|
+
Set \`args.mode = "normal"\`. Loop: plan → parallel review ×N → fix atomic issues → validate → loop → auto-stop when zero findings remain.
|
|
122
|
+
Canonical script — reproduce this structure exactly (adjust dims via the plan):
|
|
123
|
+
|
|
124
|
+
\`\`\`js
|
|
125
|
+
// args = { mode: "normal", maxRounds? }
|
|
126
|
+
phase('plan')
|
|
127
|
+
await agent(
|
|
128
|
+
'Call iterate_config({ validate: true }) and return the config JSON.',
|
|
129
|
+
{ label: 'config:read' }
|
|
130
|
+
)
|
|
131
|
+
const planRes = await agent(
|
|
132
|
+
'Call iterate_review({operation:"plan", mode:"normal", maxReviewRounds:' + (args.maxRounds || 3) + '}) and return the plan JSON.',
|
|
133
|
+
{ label: 'review:plan' }
|
|
134
|
+
)
|
|
135
|
+
const plan = (planRes && planRes.plan) ? planRes.plan : null
|
|
136
|
+
if (!plan || !Array.isArray(plan.dimensions)) throw new Error('plan failed: iterate_review did not return a valid plan')
|
|
137
|
+
const dims = plan.dimensions.map(d => d.id)
|
|
138
|
+
const maxRounds = plan.maxReviewRounds
|
|
139
|
+
const rounds = [] // findings per review round (each on the then-current code state)
|
|
140
|
+
const architectural = [] // findings deliberately left unfixed (reported at the end)
|
|
141
|
+
let fixedCount = 0
|
|
142
|
+
let converged = false
|
|
143
|
+
|
|
144
|
+
phase('loop')
|
|
145
|
+
for (let r = 1; r <= maxRounds; r++) {
|
|
146
|
+
log('round ' + r + ' of ' + maxRounds + ' — review current state, fix atomics, validate')
|
|
147
|
+
const raw = await parallel(dims.map(dim => () => agent(
|
|
148
|
+
'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed). ' +
|
|
149
|
+
'Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + '\\nReturn the findings JSON object.',
|
|
150
|
+
{ label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }
|
|
151
|
+
)))
|
|
152
|
+
const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
|
|
153
|
+
rounds.push(thisRound)
|
|
154
|
+
|
|
155
|
+
// Deterministic dedupe / known_intentional filter / severity sort for this round.
|
|
156
|
+
const agg = await agent(
|
|
157
|
+
'Call iterate_review({operation:"aggregate", mode:"normal", rounds:' + JSON.stringify([thisRound]) + '}) and return the report JSON.',
|
|
158
|
+
{ label: 'review:aggregate:r' + r }
|
|
159
|
+
)
|
|
160
|
+
const findings = (agg && agg.report && agg.report.findings) ? agg.report.findings : thisRound.findings
|
|
161
|
+
const atomic = findings.filter(f => f.is_atomic === true)
|
|
162
|
+
const remaining = findings.filter(f => f.is_atomic !== true)
|
|
163
|
+
|
|
164
|
+
if (atomic.length > 0) {
|
|
165
|
+
await parallel(atomic.map(f => () => agent(
|
|
166
|
+
'Fix this finding with the smallest possible change (single file, single function, <=20 lines). ' +
|
|
167
|
+
JSON.stringify(f) + '. Verify the edit locally before finishing.',
|
|
168
|
+
{ label: 'fix:' + f.file + ':' + (f.line || 0), phase: 'fix' }
|
|
169
|
+
)))
|
|
170
|
+
fixedCount += atomic.length
|
|
171
|
+
}
|
|
172
|
+
architectural.push(...remaining)
|
|
173
|
+
|
|
174
|
+
await agent(
|
|
175
|
+
'Call iterate_validate for each command in iterate.config.yaml validation.commands and return all {command, exitCode} results.',
|
|
176
|
+
{ label: 'validate:r' + r, phase: 'validate' }
|
|
177
|
+
)
|
|
178
|
+
await agent(
|
|
179
|
+
'Call iterate_decision_log({operation:"append", type:"review_result", round:' + r +
|
|
180
|
+
', data:{atomic:' + atomic.length + ', architectural:' + remaining.length + ', fixedSoFar:' + fixedCount + '}})',
|
|
181
|
+
{ label: 'log:r' + r }
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if (atomic.length === 0 && remaining.length === 0) {
|
|
185
|
+
log('round ' + r + ' found nothing to fix — converged')
|
|
186
|
+
converged = true
|
|
187
|
+
break
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
phase('report')
|
|
192
|
+
await agent(
|
|
193
|
+
'Call iterate_decision_log({operation:"append", type:"report", round:' + rounds.length +
|
|
194
|
+
', data:{mode:"normal", fixed:' + fixedCount + ', architectural:' + architectural.length + '}})',
|
|
195
|
+
{ label: 'report:log' }
|
|
196
|
+
)
|
|
197
|
+
return {
|
|
198
|
+
mode: 'normal',
|
|
199
|
+
goal: plan.goal,
|
|
200
|
+
roundsExecuted: rounds.length,
|
|
201
|
+
maxRounds: maxRounds,
|
|
202
|
+
converged: converged,
|
|
203
|
+
findingsFixed: fixedCount,
|
|
204
|
+
remainingArchitecturalCount: architectural.length,
|
|
205
|
+
remainingArchitectural: architectural
|
|
206
|
+
}
|
|
207
|
+
\`\`\`
|
|
208
|
+
|
|
209
|
+
Key rules for normal mode:
|
|
210
|
+
- Fixers are the ONLY agents allowed to write files; reviewers read only. Architectural findings are reported, never auto-fixed.
|
|
211
|
+
- Aggregate the current round deterministically (\`report.findings\`) before fixing, so fixes act on deduped/filtered/sorted findings.
|
|
212
|
+
- Validate after every round of fixes; validation results are logged, not silently dropped.
|
|
213
|
+
- Stop when a round produces nothing to fix (converged) or maxReviewRounds is reached.
|
|
214
|
+
- Every round and the final report go to the append-only decision log.
|
|
215
|
+
|
|
216
|
+
### Finding schema (for reviewer agents)
|
|
217
|
+
{ "dimension": string, "file": string (relative path), "line": number (optional),
|
|
218
|
+
"severity": "critical" | "high" | "medium" | "low", "summary": string (one line),
|
|
219
|
+
"failure_scenario": string (how/when it fails), "suggested_fix": string (the concrete fix),
|
|
220
|
+
"is_atomic": boolean (true if fix ≤ max_lines within a single file/function) }
|
|
221
|
+
Atomic = is_atomic true (single file, single function, ≤20 lines change). Architectural = everything else.
|
|
222
|
+
|
|
223
|
+
### Workflow meta
|
|
224
|
+
Always pass \`meta: { name: "iterate", description: "Autonomous iterate loop" }\`.
|
|
225
|
+
|
|
226
|
+
Always end with a clear summary: total findings, count by severity, fixes applied (normal) or convergence stats (dry-run), and remaining architectural findings.
|
|
227
|
+
`
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
+
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
|
3
|
+
import { loadEffectiveConfig, validateConfig } from '../config-loader.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Register the `iterate_config` tool.
|
|
7
|
+
* Reads and returns the iterate.config.yaml configuration.
|
|
8
|
+
* Model-facing: returns JSON with the full config, a specific section, or validation errors.
|
|
9
|
+
*/
|
|
10
|
+
export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<typeof defineTool>) => void } }): void {
|
|
11
|
+
ctx.tools.register(
|
|
12
|
+
defineTool({
|
|
13
|
+
name: 'iterate_config',
|
|
14
|
+
description:
|
|
15
|
+
'Read the iterate.config.yaml configuration from the project root. ' +
|
|
16
|
+
'Returns the full parsed config, a specific section, or validation errors. ' +
|
|
17
|
+
'Use this to discover available dimensions, validation commands, git settings, and personalization rules.',
|
|
18
|
+
|
|
19
|
+
parameters: {
|
|
20
|
+
path: {
|
|
21
|
+
type: 'string',
|
|
22
|
+
description: 'Project root directory (default: current working directory).',
|
|
23
|
+
},
|
|
24
|
+
section: {
|
|
25
|
+
type: 'string',
|
|
26
|
+
description:
|
|
27
|
+
'Optional config section to return: dimensions, validation, git, atomic, review, personalization, onboarding, or goal.',
|
|
28
|
+
},
|
|
29
|
+
validate: {
|
|
30
|
+
type: 'boolean',
|
|
31
|
+
description: 'If true, validate the config schema and return any missing fields.',
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
output: {
|
|
36
|
+
schema: {
|
|
37
|
+
type: 'object',
|
|
38
|
+
additionalProperties: false,
|
|
39
|
+
properties: {
|
|
40
|
+
found: { type: 'boolean', required: true },
|
|
41
|
+
valid: { type: 'boolean' },
|
|
42
|
+
errors: { type: 'array', items: { type: 'string' } },
|
|
43
|
+
section: { type: 'string' },
|
|
44
|
+
data: { type: 'json' },
|
|
45
|
+
config: { type: 'json' },
|
|
46
|
+
availableSections: { type: 'array', items: { type: 'string' } },
|
|
47
|
+
error: { type: 'string' },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
render: (_args, value) => [
|
|
51
|
+
{ type: 'text', text: JSON.stringify(value, null, 2) },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
async execute(args) {
|
|
56
|
+
const projectRoot = args.path ?? process.cwd()
|
|
57
|
+
// Effective config = defaults (Master) merged with any project-root
|
|
58
|
+
// overrides. Never null: a project without a config file runs on the
|
|
59
|
+
// built-in defaults, so the workflow stays usable out of the box.
|
|
60
|
+
const { config, source } = loadEffectiveConfig(projectRoot)
|
|
61
|
+
const hasOverride = source === 'override'
|
|
62
|
+
|
|
63
|
+
if (args.validate) {
|
|
64
|
+
const errors = validateConfig(config)
|
|
65
|
+
return {
|
|
66
|
+
found: hasOverride,
|
|
67
|
+
valid: errors.length === 0,
|
|
68
|
+
errors: errors.length > 0 ? errors : undefined,
|
|
69
|
+
section: 'validation_report',
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (args.section) {
|
|
74
|
+
const configRecord = config as unknown as Record<string, unknown>
|
|
75
|
+
const section = configRecord[args.section]
|
|
76
|
+
if (section === undefined) {
|
|
77
|
+
return {
|
|
78
|
+
found: hasOverride,
|
|
79
|
+
error: `Section "${args.section}" not found in config.`,
|
|
80
|
+
availableSections: Object.keys(configRecord),
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { found: hasOverride, section: args.section, data: section as JsonValue }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { found: hasOverride, config: config as unknown as JsonValue }
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
89
|
+
)
|
|
90
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
2
|
+
import { join, dirname, resolve } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
5
|
+
|
|
6
|
+
/** How many ancestor directories we walk up looking for a SKILL.md. */
|
|
7
|
+
const MAX_SKILL_DIR_LOOKUP_DEPTH = 12
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The directory this source file lives in (…/src/tools). The plugin's own
|
|
11
|
+
* package root is one level up (…/src), and the skill root is typically a few
|
|
12
|
+
* levels above that. We use it as the anchor for auto-detecting where the
|
|
13
|
+
* original SKILL.md lives.
|
|
14
|
+
*/
|
|
15
|
+
const PLUGIN_SRC_DIR = dirname(fileURLToPath(import.meta.url))
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Walk up from the plugin's own location until a directory containing SKILL.md
|
|
19
|
+
* is found. This is how the plugin locates the ORIGINAL skill (skill 目录)
|
|
20
|
+
* without any hardcoded absolute path — it works whether the plugin is mounted
|
|
21
|
+
* from the source tree or bundled next to the skill.
|
|
22
|
+
*
|
|
23
|
+
* Returns the absolute directory containing SKILL.md, or null if none found
|
|
24
|
+
* within `MAX_SKILL_DIR_LOOKUP_DEPTH` ancestors.
|
|
25
|
+
*/
|
|
26
|
+
function findSkillRoot(startDir: string): string | null {
|
|
27
|
+
let dir = resolve(startDir)
|
|
28
|
+
for (let depth = 0; depth < MAX_SKILL_DIR_LOOKUP_DEPTH; depth++) {
|
|
29
|
+
if (existsSync(join(dir, 'SKILL.md'))) return dir
|
|
30
|
+
const parent = dirname(dir)
|
|
31
|
+
if (parent === dir) break // reached the filesystem root
|
|
32
|
+
dir = parent
|
|
33
|
+
}
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Exported for unit tests. See the private `findSkillRoot` above. */
|
|
38
|
+
export { findSkillRoot }
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read a file from a candidate directory, returning its content or null.
|
|
42
|
+
*/
|
|
43
|
+
function readProjectFile(projectRoot: string, filename: string): string | null {
|
|
44
|
+
const filePath = join(projectRoot, filename)
|
|
45
|
+
if (!existsSync(filePath)) return null
|
|
46
|
+
try {
|
|
47
|
+
return readFileSync(filePath, 'utf-8')
|
|
48
|
+
} catch {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Locate the first existing SKILL.md across the candidate directories, in
|
|
55
|
+
* priority order:
|
|
56
|
+
* 1. explicit skillDir (custom path / 自定义路径)
|
|
57
|
+
* 2. auto-detected skill root walking up from the plugin (skill 目录)
|
|
58
|
+
* 3. project root (项目根)
|
|
59
|
+
* Returns the file content plus the directory it was found in, or null.
|
|
60
|
+
*/
|
|
61
|
+
function findSkillMd(candidates: string[]): { content: string; sourceDir: string } | null {
|
|
62
|
+
for (const dir of candidates) {
|
|
63
|
+
if (!dir) continue
|
|
64
|
+
const content = readProjectFile(dir, 'SKILL.md')
|
|
65
|
+
if (content !== null) return { content, sourceDir: dir }
|
|
66
|
+
}
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Exported for unit tests. See the private `findSkillMd` above. */
|
|
71
|
+
export { findSkillMd }
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Register the `iterate_context` tool.
|
|
75
|
+
* Reads SKILL.md (original skill instructions) from the skill directory,
|
|
76
|
+
* project root, or a custom path, and ITERATE.md from the project root.
|
|
77
|
+
* Provides the model with the original skill instructions and project knowledge base.
|
|
78
|
+
*/
|
|
79
|
+
export function registerContextTool(ctx: { tools: { register: (def: ReturnType<typeof defineTool>) => void } }): void {
|
|
80
|
+
ctx.tools.register(
|
|
81
|
+
defineTool({
|
|
82
|
+
name: 'iterate_context',
|
|
83
|
+
description:
|
|
84
|
+
'Read project context files (SKILL.md and/or ITERATE.md). ' +
|
|
85
|
+
'SKILL.md contains the original iterate skill instructions; it is searched in ' +
|
|
86
|
+
'the skill directory (auto-detected), the project root, or an explicit `skillDir`. ' +
|
|
87
|
+
'ITERATE.md contains the project-specific knowledge base and onboarding information. ' +
|
|
88
|
+
'Use this to understand the skill workflow and project context.',
|
|
89
|
+
|
|
90
|
+
parameters: {
|
|
91
|
+
files: {
|
|
92
|
+
type: 'string',
|
|
93
|
+
required: true,
|
|
94
|
+
description:
|
|
95
|
+
'Comma-separated list of files to read: "skill", "project", or "skill,project" for both.',
|
|
96
|
+
},
|
|
97
|
+
path: {
|
|
98
|
+
type: 'string',
|
|
99
|
+
description: 'Project root directory (default: current working directory).',
|
|
100
|
+
},
|
|
101
|
+
skillDir: {
|
|
102
|
+
type: 'string',
|
|
103
|
+
description:
|
|
104
|
+
'Custom directory to search for SKILL.md (highest priority). ' +
|
|
105
|
+
'When omitted, SKILL.md is auto-detected from the skill directory, then the project root.',
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
output: {
|
|
110
|
+
schema: {
|
|
111
|
+
type: 'object',
|
|
112
|
+
additionalProperties: false,
|
|
113
|
+
properties: {
|
|
114
|
+
found: { type: 'boolean', required: true },
|
|
115
|
+
skill: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
116
|
+
project: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
117
|
+
skillSource: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
118
|
+
searched: { type: 'array', items: { type: 'string' } },
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
render: (_args, value) => {
|
|
122
|
+
const parts: string[] = []
|
|
123
|
+
if (value.skill) parts.push(`--- SKILL.md (${value.skillSource ?? '?source?'}) ---\n${value.skill}`)
|
|
124
|
+
if (value.project) parts.push(`--- ITERATE.md ---\n${value.project}`)
|
|
125
|
+
if (!value.skill && !value.project) {
|
|
126
|
+
parts.push('No files found. Searched: ' + (value.searched?.join(', ') ?? 'none'))
|
|
127
|
+
}
|
|
128
|
+
return [{ type: 'text', text: parts.join('\n\n') }]
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
async execute(args) {
|
|
133
|
+
const projectRoot = args.path ?? process.cwd()
|
|
134
|
+
const requested = (args.files ?? '')
|
|
135
|
+
.split(',')
|
|
136
|
+
.map((s) => s.trim().toLowerCase())
|
|
137
|
+
.filter(Boolean)
|
|
138
|
+
|
|
139
|
+
const result: {
|
|
140
|
+
found: boolean
|
|
141
|
+
skill?: string | null
|
|
142
|
+
project?: string | null
|
|
143
|
+
skillSource?: string | null
|
|
144
|
+
searched: string[]
|
|
145
|
+
} = { found: true, searched: [] }
|
|
146
|
+
|
|
147
|
+
if (requested.includes('skill') || requested.includes('skill.md')) {
|
|
148
|
+
// Candidate dirs in priority order: custom path → auto-detected skill
|
|
149
|
+
// root → project root. This is how "skill 目录、项目根、自定义路径"
|
|
150
|
+
// are all supported.
|
|
151
|
+
const skillRoot = findSkillRoot(PLUGIN_SRC_DIR)
|
|
152
|
+
const candidates: string[] = []
|
|
153
|
+
if (args.skillDir) candidates.push(args.skillDir)
|
|
154
|
+
if (skillRoot) candidates.push(skillRoot)
|
|
155
|
+
candidates.push(projectRoot)
|
|
156
|
+
result.searched = candidates
|
|
157
|
+
|
|
158
|
+
const found = findSkillMd(candidates)
|
|
159
|
+
result.skill = found ? found.content : null
|
|
160
|
+
result.skillSource = found ? found.sourceDir : null
|
|
161
|
+
}
|
|
162
|
+
if (requested.includes('project') || requested.includes('iterate.md')) {
|
|
163
|
+
result.project = readProjectFile(projectRoot, 'ITERATE.md')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return result
|
|
167
|
+
},
|
|
168
|
+
}),
|
|
169
|
+
)
|
|
170
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { appendFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
4
|
+
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
|
5
|
+
import type { DecisionLogEntry } from '../types.ts'
|
|
6
|
+
|
|
7
|
+
const LOG_DIR = '.iterate'
|
|
8
|
+
const LOG_FILE = 'decision-log.jsonl'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolve the log file path, creating the directory if needed.
|
|
12
|
+
*/
|
|
13
|
+
function logPath(projectRoot: string): string {
|
|
14
|
+
const dir = join(projectRoot, LOG_DIR)
|
|
15
|
+
if (!existsSync(dir)) {
|
|
16
|
+
mkdirSync(dir, { recursive: true })
|
|
17
|
+
}
|
|
18
|
+
return join(dir, LOG_FILE)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Append one entry to the decision log (JSONL format).
|
|
23
|
+
* Returns the entry count after appending.
|
|
24
|
+
*/
|
|
25
|
+
function appendEntry(projectRoot: string, entry: DecisionLogEntry): { count: number; path: string } {
|
|
26
|
+
const filePath = logPath(projectRoot)
|
|
27
|
+
const line = JSON.stringify(entry) + '\n'
|
|
28
|
+
appendFileSync(filePath, line, 'utf-8')
|
|
29
|
+
// Count entries
|
|
30
|
+
let count = 0
|
|
31
|
+
try {
|
|
32
|
+
const content = readFileSync(filePath, 'utf-8')
|
|
33
|
+
count = content.split('\n').filter((l) => l.trim().length > 0).length
|
|
34
|
+
} catch {
|
|
35
|
+
count = 1
|
|
36
|
+
}
|
|
37
|
+
return { count, path: filePath }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read all entries from the decision log.
|
|
42
|
+
*/
|
|
43
|
+
function readEntries(projectRoot: string): DecisionLogEntry[] {
|
|
44
|
+
const filePath = join(projectRoot, LOG_DIR, LOG_FILE)
|
|
45
|
+
if (!existsSync(filePath)) return []
|
|
46
|
+
try {
|
|
47
|
+
const content = readFileSync(filePath, 'utf-8')
|
|
48
|
+
return content
|
|
49
|
+
.split('\n')
|
|
50
|
+
.filter((l) => l.trim().length > 0)
|
|
51
|
+
.map((l) => JSON.parse(l) as DecisionLogEntry)
|
|
52
|
+
} catch {
|
|
53
|
+
return []
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Register the `iterate_decision_log` tool.
|
|
59
|
+
* Append-only decision log stored in .iterate/decision-log.jsonl.
|
|
60
|
+
* Supports `append` and `read` operations.
|
|
61
|
+
*/
|
|
62
|
+
export function registerDecisionLogTool(ctx: { tools: { register: (def: ReturnType<typeof defineTool>) => void } }): void {
|
|
63
|
+
ctx.tools.register(
|
|
64
|
+
defineTool({
|
|
65
|
+
name: 'iterate_decision_log',
|
|
66
|
+
description:
|
|
67
|
+
'Append-only decision log for the iterate loop. ' +
|
|
68
|
+
'Use `append` to record a round start, review finding, fix, validation result, or decision. ' +
|
|
69
|
+
'Use `read` to retrieve all entries for review. ' +
|
|
70
|
+
'The log is stored in .iterate/decision-log.jsonl and persists across sessions.',
|
|
71
|
+
|
|
72
|
+
parameters: {
|
|
73
|
+
operation: {
|
|
74
|
+
type: 'string',
|
|
75
|
+
required: true,
|
|
76
|
+
description: '"append" to add an entry, "read" to retrieve all entries.',
|
|
77
|
+
enum: ['append', 'read'],
|
|
78
|
+
},
|
|
79
|
+
type: {
|
|
80
|
+
type: 'string',
|
|
81
|
+
description:
|
|
82
|
+
'Entry type (required for append): round_start, review_result, atomic_fix, ' +
|
|
83
|
+
'architectural_fix, revert, validation, decision, report.',
|
|
84
|
+
},
|
|
85
|
+
round: {
|
|
86
|
+
type: 'integer',
|
|
87
|
+
description: 'Current iteration round number (required for append).',
|
|
88
|
+
},
|
|
89
|
+
data: {
|
|
90
|
+
type: 'json',
|
|
91
|
+
description: 'Entry payload as JSON object (required for append).',
|
|
92
|
+
},
|
|
93
|
+
path: {
|
|
94
|
+
type: 'string',
|
|
95
|
+
description: 'Project root directory (default: current working directory).',
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
output: {
|
|
100
|
+
schema: {
|
|
101
|
+
type: 'object',
|
|
102
|
+
additionalProperties: false,
|
|
103
|
+
properties: {
|
|
104
|
+
operation: { type: 'string', required: true },
|
|
105
|
+
entryCount: { type: 'integer' },
|
|
106
|
+
logPath: { type: 'string' },
|
|
107
|
+
entries: { type: 'json' },
|
|
108
|
+
success: { type: 'boolean' },
|
|
109
|
+
entry: { type: 'json' },
|
|
110
|
+
error: { type: 'string' },
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
render: (_args, value) => [
|
|
114
|
+
{ type: 'text', text: JSON.stringify(value, null, 2) },
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
async execute(args) {
|
|
119
|
+
const projectRoot = args.path ?? process.cwd()
|
|
120
|
+
|
|
121
|
+
if (args.operation === 'read') {
|
|
122
|
+
const entries = readEntries(projectRoot)
|
|
123
|
+
return {
|
|
124
|
+
operation: 'read',
|
|
125
|
+
entryCount: entries.length,
|
|
126
|
+
logPath: join(projectRoot, LOG_DIR, LOG_FILE),
|
|
127
|
+
entries: entries as unknown as JsonValue,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (args.operation === 'append') {
|
|
132
|
+
if (!args.type || !args.round) {
|
|
133
|
+
return {
|
|
134
|
+
operation: 'append',
|
|
135
|
+
error: 'type and round are required for append operation.',
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const entry: DecisionLogEntry = {
|
|
140
|
+
timestamp: new Date().toISOString(),
|
|
141
|
+
round: args.round,
|
|
142
|
+
type: args.type as DecisionLogEntry['type'],
|
|
143
|
+
data: (args.data as Record<string, unknown>) ?? {},
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const result = appendEntry(projectRoot, entry)
|
|
147
|
+
return {
|
|
148
|
+
operation: 'append',
|
|
149
|
+
success: true,
|
|
150
|
+
entryCount: result.count,
|
|
151
|
+
logPath: result.path,
|
|
152
|
+
entry: entry as unknown as JsonValue,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
operation: args.operation,
|
|
158
|
+
error: `Unknown operation "${args.operation}". Use "append" or "read".`,
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
}),
|
|
162
|
+
)
|
|
163
|
+
}
|