iterate-plugin 2.6.0 → 2.7.1

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.
@@ -0,0 +1,260 @@
1
+ /**
2
+ * src/tools/checkpoint.ts — iteration checkpoint + status tools.
3
+ *
4
+ * iterate_checkpoint — save / load / clear a resume checkpoint so a long
5
+ * iteration can continue where it left off.
6
+ * iterate_status — summarize the current iteration state from the
7
+ * decision log, fix registry, and checkpoint.
8
+ *
9
+ * Checkpoint layout: `.iterate/checkpoint.json`.
10
+ */
11
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { defineTool } from '@deepseek-ai/dsh-tools';
13
+ import { resolveProjectRoot } from "../config-loader.js";
14
+ import { checkpointPath, iterateDir } from "../paths.js";
15
+ import { readRegistry } from "./fix.js";
16
+ import { readDecisionEntries } from "./decision-log.js";
17
+ // ─── Pure helpers (exported for unit tests) ─────────────────────────────────
18
+ /** Read a checkpoint from disk (missing/corrupt → null). */
19
+ export function readCheckpoint(projectRoot) {
20
+ const file = checkpointPath(projectRoot);
21
+ if (!existsSync(file))
22
+ return null;
23
+ try {
24
+ const parsed = JSON.parse(readFileSync(file, 'utf-8'));
25
+ if (!parsed || typeof parsed !== 'object')
26
+ return null;
27
+ if (parsed.mode !== 'dry-run' && parsed.mode !== 'normal')
28
+ return null;
29
+ if (typeof parsed.round !== 'number')
30
+ return null;
31
+ return parsed;
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ /** Validate a checkpoint payload (returns error string or null). */
38
+ export function validateCheckpoint(input) {
39
+ if (input.mode !== 'dry-run' && input.mode !== 'normal') {
40
+ return 'mode must be "dry-run" or "normal"';
41
+ }
42
+ if (typeof input.round !== 'number' || !Number.isInteger(input.round) || input.round < 0) {
43
+ return 'round must be a non-negative integer';
44
+ }
45
+ if (typeof input.maxRounds !== 'number' || !Number.isInteger(input.maxRounds) || input.maxRounds < 1) {
46
+ return 'maxRounds must be a positive integer';
47
+ }
48
+ if (typeof input.fixedCount !== 'number' || !Number.isInteger(input.fixedCount) || input.fixedCount < 0) {
49
+ return 'fixedCount must be a non-negative integer';
50
+ }
51
+ if (typeof input.architecturalCount !== 'number' || !Number.isInteger(input.architecturalCount) || input.architecturalCount < 0) {
52
+ return 'architecturalCount must be a non-negative integer';
53
+ }
54
+ return null;
55
+ }
56
+ /**
57
+ * Compute a status summary from the runtime artifacts.
58
+ * Pure (no I/O) — all reads are injected, so it is unit-testable.
59
+ */
60
+ export function computeStatus(input) {
61
+ const checkpoint = input.checkpoint;
62
+ const entries = input.decisionEntries;
63
+ const registry = input.fixRegistry;
64
+ const lastEntry = entries.length > 0 ? entries[entries.length - 1] : null;
65
+ const lastUpdated = lastEntry?.timestamp ?? checkpoint?.updatedAt ?? null;
66
+ // Round = checkpoint.round (explicit) or max round seen in the decision log.
67
+ let currentRound = checkpoint?.round ?? 0;
68
+ if (!checkpoint) {
69
+ for (const e of entries) {
70
+ if (typeof e.round === 'number' && e.round > currentRound)
71
+ currentRound = e.round;
72
+ }
73
+ }
74
+ const totalRounds = checkpoint?.maxRounds ?? currentRound;
75
+ const registryFixed = registry.rounds.reduce((sum, r) => sum + r.fixedCount, 0);
76
+ const failedCount = registry.rounds.reduce((sum, r) => sum + r.failedCount, 0);
77
+ // When a checkpoint exists, its snapshot fields are authoritative for resume
78
+ // (fixedCount / architecturalCount / findings); otherwise derive from the
79
+ // live fix registry and decision log.
80
+ const fixedCount = checkpoint ? checkpoint.fixedCount : registryFixed;
81
+ const architecturalCount = checkpoint?.architecturalCount ?? 0;
82
+ return {
83
+ mode: checkpoint?.mode ?? null,
84
+ currentRound,
85
+ totalRounds,
86
+ fixedCount,
87
+ architecturalCount,
88
+ findingsCount: checkpoint?.findings.length ?? 0,
89
+ totalDecisionLogEntries: entries.length,
90
+ hasCheckpoint: checkpoint !== null,
91
+ checkpoint,
92
+ lastUpdated,
93
+ };
94
+ }
95
+ // ─── iterate_checkpoint ──────────────────────────────────────────────────────
96
+ /**
97
+ * Register the `iterate_checkpoint` tool.
98
+ * Saves progress so the orchestrator can resume a long iteration.
99
+ */
100
+ export function registerCheckpointTool(ctx) {
101
+ ctx.tools.register(defineTool({
102
+ name: 'iterate_checkpoint',
103
+ description: 'Save / load / clear the iteration checkpoint. The workflow saves a checkpoint at the start of ' +
104
+ 'each round (so a long run can resume) and clears it when the iteration completes.',
105
+ parameters: {
106
+ operation: {
107
+ type: 'string',
108
+ required: true,
109
+ description: '"save" to persist the current progress, "load" to read it back, "clear" to remove it.',
110
+ enum: ['save', 'load', 'clear'],
111
+ },
112
+ mode: { type: 'string', description: 'Required for save: "dry-run" or "normal".' },
113
+ round: { type: 'integer', description: 'Required for save: current round number (0 = none started).' },
114
+ maxRounds: { type: 'integer', description: 'Required for save: total round cap.' },
115
+ fixedCount: { type: 'integer', description: 'Required for save: number of fixes applied so far.' },
116
+ architecturalCount: { type: 'integer', description: 'Required for save: architectural findings left unfixed.' },
117
+ findings: { type: 'json', description: 'Optional for save: the current deduped findings to resume from.' },
118
+ path: { type: 'string', description: 'Project root directory (default: current working directory).' },
119
+ },
120
+ output: {
121
+ schema: {
122
+ type: 'object',
123
+ additionalProperties: false,
124
+ properties: {
125
+ operation: { type: 'string', required: true },
126
+ ok: { type: 'boolean', required: true },
127
+ checkpoint: { type: 'json' },
128
+ existed: { type: 'boolean' },
129
+ error: { type: 'string' },
130
+ },
131
+ },
132
+ render: (_args, value) => [
133
+ { type: 'text', text: JSON.stringify(value, null, 2) },
134
+ ],
135
+ },
136
+ async execute(args) {
137
+ const resolved = resolveProjectRoot(args.path);
138
+ if (!resolved.ok)
139
+ return { operation: args.operation, ok: false, error: resolved.reason };
140
+ const projectRoot = resolved.root;
141
+ if (args.operation === 'load') {
142
+ const checkpoint = readCheckpoint(projectRoot);
143
+ return { operation: 'load', ok: true, checkpoint: checkpoint ?? undefined };
144
+ }
145
+ if (args.operation === 'clear') {
146
+ const existed = existsSync(checkpointPath(projectRoot));
147
+ if (existed) {
148
+ try {
149
+ rmSync(checkpointPath(projectRoot), { force: true });
150
+ }
151
+ catch (err) {
152
+ return { operation: 'clear', ok: false, existed, error: `failed to clear checkpoint: ${String(err)}` };
153
+ }
154
+ }
155
+ return { operation: 'clear', ok: true, existed };
156
+ }
157
+ if (args.operation === 'save') {
158
+ const invalid = validateCheckpoint({
159
+ mode: args.mode,
160
+ round: args.round,
161
+ maxRounds: args.maxRounds,
162
+ fixedCount: args.fixedCount,
163
+ architecturalCount: args.architecturalCount,
164
+ });
165
+ if (invalid)
166
+ return { operation: 'save', ok: false, error: invalid };
167
+ const checkpoint = {
168
+ mode: args.mode,
169
+ round: args.round,
170
+ maxRounds: args.maxRounds,
171
+ fixedCount: args.fixedCount,
172
+ architecturalCount: args.architecturalCount,
173
+ findings: (Array.isArray(args.findings) ? args.findings : []),
174
+ startedAt: readCheckpoint(projectRoot)?.startedAt ?? new Date().toISOString(),
175
+ updatedAt: new Date().toISOString(),
176
+ };
177
+ try {
178
+ mkdirSync(iterateDir(projectRoot), { recursive: true });
179
+ writeFileSync(checkpointPath(projectRoot), JSON.stringify(checkpoint, null, 2), 'utf-8');
180
+ }
181
+ catch (err) {
182
+ return { operation: 'save', ok: false, error: `failed to write checkpoint: ${String(err)}` };
183
+ }
184
+ return { operation: 'save', ok: true, checkpoint: checkpoint };
185
+ }
186
+ return { operation: args.operation, ok: false, error: 'unknown operation. Use "save", "load", or "clear".' };
187
+ },
188
+ }));
189
+ }
190
+ // ─── iterate_status ──────────────────────────────────────────────────────────
191
+ /**
192
+ * Register the `iterate_status` tool.
193
+ * Summarizes the current iteration state (mode, round, fixed count, findings).
194
+ */
195
+ export function registerStatusTool(ctx) {
196
+ ctx.tools.register(defineTool({
197
+ name: 'iterate_status',
198
+ description: 'Summarize the current iterate run: mode, current round vs total, fixes applied, architectural ' +
199
+ 'findings remaining, decision-log size, and whether a resume checkpoint exists.',
200
+ parameters: {
201
+ path: { type: 'string', description: 'Project root directory (default: current working directory).' },
202
+ },
203
+ output: {
204
+ schema: {
205
+ type: 'object',
206
+ additionalProperties: false,
207
+ properties: {
208
+ ok: { type: 'boolean', required: true },
209
+ mode: { type: 'string' },
210
+ currentRound: { type: 'integer' },
211
+ totalRounds: { type: 'integer' },
212
+ fixedCount: { type: 'integer' },
213
+ architecturalCount: { type: 'integer' },
214
+ findingsCount: { type: 'integer' },
215
+ totalDecisionLogEntries: { type: 'integer' },
216
+ hasCheckpoint: { type: 'boolean' },
217
+ lastUpdated: { type: 'string' },
218
+ error: { type: 'string' },
219
+ },
220
+ },
221
+ render: (_args, value) => {
222
+ if (!value.ok)
223
+ return [{ type: 'text', text: `status failed: ${value.error}` }];
224
+ const lines = [
225
+ `Mode: ${value.mode ?? 'none'}`,
226
+ `Round: ${value.currentRound} / ${value.totalRounds}`,
227
+ `Fixed: ${value.fixedCount} · Architectural remaining: ${value.architecturalCount}`,
228
+ `Findings in checkpoint: ${value.findingsCount}`,
229
+ `Decision-log entries: ${value.totalDecisionLogEntries}`,
230
+ `Checkpoint: ${value.hasCheckpoint ? 'yes' : 'no'}`,
231
+ value.lastUpdated ? `Last updated: ${value.lastUpdated}` : '',
232
+ ];
233
+ return [{ type: 'text', text: lines.filter(Boolean).join('\n') }];
234
+ },
235
+ },
236
+ async execute(args) {
237
+ const resolved = resolveProjectRoot(args.path);
238
+ if (!resolved.ok)
239
+ return { ok: false, error: resolved.reason };
240
+ const projectRoot = resolved.root;
241
+ const status = computeStatus({
242
+ checkpoint: readCheckpoint(projectRoot),
243
+ decisionEntries: readDecisionEntries(projectRoot),
244
+ fixRegistry: readRegistry(projectRoot),
245
+ });
246
+ return {
247
+ ok: true,
248
+ mode: status.mode ?? undefined,
249
+ currentRound: status.currentRound,
250
+ totalRounds: status.totalRounds,
251
+ fixedCount: status.fixedCount,
252
+ architecturalCount: status.architecturalCount,
253
+ findingsCount: status.findingsCount,
254
+ totalDecisionLogEntries: status.totalDecisionLogEntries,
255
+ hasCheckpoint: status.hasCheckpoint,
256
+ lastUpdated: status.lastUpdated ?? undefined,
257
+ };
258
+ },
259
+ }));
260
+ }
@@ -0,0 +1,134 @@
1
+ import { join } from 'node:path';
2
+ import { defineTool } from '@deepseek-ai/dsh-tools';
3
+ import { loadEffectiveConfig, validateConfig, resolveProjectRoot } from "../config-loader.js";
4
+ import { applyConfigUpdates, readRawConfig, validateConfigUpdates, writeConfigFile, } from "../config-write.js";
5
+ /**
6
+ * Register the `iterate_config` tool.
7
+ * Reads and returns the iterate.config.yaml configuration, or writes a
8
+ * validated partial update back to it (with backup + rollback).
9
+ * Model-facing: returns JSON with the full config, a specific section, or validation errors.
10
+ */
11
+ export function registerConfigTool(ctx) {
12
+ ctx.tools.register(defineTool({
13
+ name: 'iterate_config',
14
+ description: 'Read or update the iterate.config.yaml configuration from the project root. ' +
15
+ 'Returns the full parsed config, a specific section, or validation errors. ' +
16
+ 'Use this to discover available dimensions, validation commands, git settings, and personalization rules, ' +
17
+ 'or to write back validated changes (goal, dimensions, max_rounds, review, atomic, validation, git, etc.).',
18
+ parameters: {
19
+ operation: {
20
+ type: 'string',
21
+ description: 'Default "read". "write" validates and applies a partial config update (backed up first).',
22
+ enum: ['read', 'write'],
23
+ },
24
+ path: {
25
+ type: 'string',
26
+ description: 'Project root directory (default: current working directory).',
27
+ },
28
+ section: {
29
+ type: 'string',
30
+ description: 'Optional config section to return: dimensions, validation, git, atomic, review, personalization, onboarding, or goal.',
31
+ },
32
+ validate: {
33
+ type: 'boolean',
34
+ description: 'If true, validate the config schema and return any missing fields.',
35
+ },
36
+ updates: {
37
+ type: 'json',
38
+ description: 'For operation "write": a partial config object to merge in, e.g. ' +
39
+ '{"goal":"...","dimensions":["correctness","security"],"max_rounds":5}. ' +
40
+ 'Supported keys: goal, language, dimensions, max_rounds, review, atomic, git, validation, personalization, onboarding.',
41
+ },
42
+ },
43
+ output: {
44
+ schema: {
45
+ type: 'object',
46
+ additionalProperties: false,
47
+ properties: {
48
+ found: { type: 'boolean', required: true },
49
+ valid: { type: 'boolean' },
50
+ errors: { type: 'array', items: { type: 'string' } },
51
+ section: { type: 'string' },
52
+ data: { type: 'json' },
53
+ config: { type: 'json' },
54
+ availableSections: { type: 'array', items: { type: 'string' } },
55
+ operation: { type: 'string' },
56
+ ok: { type: 'boolean' },
57
+ backupPath: { type: 'string' },
58
+ error: { type: 'string' },
59
+ },
60
+ },
61
+ render: (_args, value) => [
62
+ { type: 'text', text: JSON.stringify(value, null, 2) },
63
+ ],
64
+ },
65
+ async execute(args) {
66
+ const resolved = resolveProjectRoot(args.path);
67
+ if (!resolved.ok) {
68
+ return { found: false, error: resolved.reason };
69
+ }
70
+ const projectRoot = resolved.root;
71
+ // ── Write operation ────────────────────────────────────────────────
72
+ if (args.operation === 'write') {
73
+ const updates = args.updates;
74
+ const updateErrors = validateConfigUpdates(updates ?? {});
75
+ if (updateErrors.length > 0) {
76
+ return { operation: 'write', ok: false, found: false, errors: updateErrors };
77
+ }
78
+ let base;
79
+ try {
80
+ base = readRawConfig(join(projectRoot, 'iterate.config.yaml'));
81
+ }
82
+ catch (err) {
83
+ return { operation: 'write', ok: false, found: false, error: `failed to read config: ${String(err)}` };
84
+ }
85
+ const merged = applyConfigUpdates(base, updates ?? {});
86
+ const schemaErrors = validateConfig(merged);
87
+ if (schemaErrors.length > 0) {
88
+ return {
89
+ operation: 'write',
90
+ ok: false,
91
+ found: false,
92
+ errors: schemaErrors.map((e) => `missing/required field: ${e}`),
93
+ };
94
+ }
95
+ const result = writeConfigFile(projectRoot, merged);
96
+ if (!result.ok)
97
+ return { operation: 'write', ok: false, found: false, error: result.error };
98
+ const { config } = loadEffectiveConfig(projectRoot);
99
+ return {
100
+ operation: 'write',
101
+ ok: true,
102
+ found: true,
103
+ backupPath: result.backupPath ?? undefined,
104
+ config: config,
105
+ };
106
+ }
107
+ // ── Read operations (original behavior) ────────────────────────────
108
+ const { config, source } = loadEffectiveConfig(projectRoot);
109
+ const hasOverride = source === 'override';
110
+ if (args.validate) {
111
+ const errors = validateConfig(config);
112
+ return {
113
+ found: hasOverride,
114
+ valid: errors.length === 0,
115
+ errors: errors.length > 0 ? errors : undefined,
116
+ section: 'validation_report',
117
+ };
118
+ }
119
+ if (args.section) {
120
+ const configRecord = config;
121
+ const section = configRecord[args.section];
122
+ if (section === undefined) {
123
+ return {
124
+ found: hasOverride,
125
+ error: `Section "${args.section}" not found in config.`,
126
+ availableSections: Object.keys(configRecord),
127
+ };
128
+ }
129
+ return { found: hasOverride, section: args.section, data: section };
130
+ }
131
+ return { found: hasOverride, config: config };
132
+ },
133
+ }));
134
+ }
@@ -0,0 +1,160 @@
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
+ import { resolveProjectRoot } from "../config-loader.js";
6
+ /** How many ancestor directories we walk up looking for a SKILL.md. */
7
+ const MAX_SKILL_DIR_LOOKUP_DEPTH = 12;
8
+ /**
9
+ * The directory this source file lives in (…/src/tools). The plugin's own
10
+ * package root is one level up (…/src), and the skill root is typically a few
11
+ * levels above that. We use it as the anchor for auto-detecting where the
12
+ * original SKILL.md lives.
13
+ */
14
+ const PLUGIN_SRC_DIR = dirname(fileURLToPath(import.meta.url));
15
+ /**
16
+ * Walk up from the plugin's own location until a directory containing SKILL.md
17
+ * is found. This is how the plugin locates the ORIGINAL skill (skill 目录)
18
+ * without any hardcoded absolute path — it works whether the plugin is mounted
19
+ * from the source tree or bundled next to the skill.
20
+ *
21
+ * Returns the absolute directory containing SKILL.md, or null if none found
22
+ * within `MAX_SKILL_DIR_LOOKUP_DEPTH` ancestors.
23
+ */
24
+ function findSkillRoot(startDir) {
25
+ let dir = resolve(startDir);
26
+ for (let depth = 0; depth < MAX_SKILL_DIR_LOOKUP_DEPTH; depth++) {
27
+ if (existsSync(join(dir, 'SKILL.md')))
28
+ return dir;
29
+ const parent = dirname(dir);
30
+ if (parent === dir)
31
+ break; // reached the filesystem root
32
+ dir = parent;
33
+ }
34
+ return null;
35
+ }
36
+ /** Exported for unit tests. See the private `findSkillRoot` above. */
37
+ export { findSkillRoot };
38
+ /**
39
+ * Read a file from a candidate directory, returning its content or null.
40
+ */
41
+ function readProjectFile(projectRoot, filename) {
42
+ const filePath = join(projectRoot, filename);
43
+ if (!existsSync(filePath))
44
+ return null;
45
+ try {
46
+ return readFileSync(filePath, 'utf-8');
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ /**
53
+ * Locate the first existing SKILL.md across the candidate directories, in
54
+ * priority order:
55
+ * 1. explicit skillDir (custom path / 自定义路径)
56
+ * 2. auto-detected skill root walking up from the plugin (skill 目录)
57
+ * 3. project root (项目根)
58
+ * Returns the file content plus the directory it was found in, or null.
59
+ */
60
+ function findSkillMd(candidates) {
61
+ for (const dir of candidates) {
62
+ if (!dir)
63
+ continue;
64
+ const content = readProjectFile(dir, 'SKILL.md');
65
+ if (content !== null)
66
+ return { content, sourceDir: dir };
67
+ }
68
+ return null;
69
+ }
70
+ /** Exported for unit tests. See the private `findSkillMd` above. */
71
+ export { findSkillMd };
72
+ /**
73
+ * Register the `iterate_context` tool.
74
+ * Reads SKILL.md (original skill instructions) from the skill directory,
75
+ * project root, or a custom path, and ITERATE.md from the project root.
76
+ * Provides the model with the original skill instructions and project knowledge base.
77
+ */
78
+ export function registerContextTool(ctx) {
79
+ ctx.tools.register(defineTool({
80
+ name: 'iterate_context',
81
+ description: 'Read project context files (SKILL.md and/or ITERATE.md). ' +
82
+ 'SKILL.md contains the original iterate skill instructions; it is searched in ' +
83
+ 'the skill directory (auto-detected), the project root, or an explicit `skillDir`. ' +
84
+ 'ITERATE.md contains the project-specific knowledge base and onboarding information. ' +
85
+ 'Use this to understand the skill workflow and project context.',
86
+ parameters: {
87
+ files: {
88
+ type: 'string',
89
+ required: true,
90
+ description: 'Comma-separated list of files to read: "skill", "project", or "skill,project" for both.',
91
+ },
92
+ path: {
93
+ type: 'string',
94
+ description: 'Project root directory (default: current working directory).',
95
+ },
96
+ skillDir: {
97
+ type: 'string',
98
+ description: 'Custom directory to search for SKILL.md (highest priority). ' +
99
+ 'When omitted, SKILL.md is auto-detected from the skill directory, then the project root.',
100
+ },
101
+ },
102
+ output: {
103
+ schema: {
104
+ type: 'object',
105
+ additionalProperties: false,
106
+ properties: {
107
+ found: { type: 'boolean', required: true },
108
+ skill: { oneOf: [{ type: 'string' }, { type: 'null' }] },
109
+ project: { oneOf: [{ type: 'string' }, { type: 'null' }] },
110
+ skillSource: { oneOf: [{ type: 'string' }, { type: 'null' }] },
111
+ error: { type: 'string' },
112
+ searched: { type: 'array', items: { type: 'string' } },
113
+ },
114
+ },
115
+ render: (_args, value) => {
116
+ const parts = [];
117
+ if (value.skill)
118
+ parts.push(`--- SKILL.md (${value.skillSource ?? '?source?'}) ---\n${value.skill}`);
119
+ if (value.project)
120
+ parts.push(`--- ITERATE.md ---\n${value.project}`);
121
+ if (!value.skill && !value.project) {
122
+ parts.push('No files found. Searched: ' + (value.searched?.join(', ') ?? 'none'));
123
+ }
124
+ return [{ type: 'text', text: parts.join('\n\n') }];
125
+ },
126
+ },
127
+ async execute(args) {
128
+ const resolved = resolveProjectRoot(args.path);
129
+ if (!resolved.ok) {
130
+ return { found: false, error: resolved.reason, searched: [] };
131
+ }
132
+ const projectRoot = resolved.root;
133
+ const requested = (args.files ?? '')
134
+ .split(',')
135
+ .map((s) => s.trim().toLowerCase())
136
+ .filter(Boolean);
137
+ const result = { found: true, searched: [] };
138
+ if (requested.includes('skill') || requested.includes('skill.md')) {
139
+ // Candidate dirs in priority order: custom path → auto-detected skill
140
+ // root → project root. This is how "skill 目录、项目根、自定义路径"
141
+ // are all supported.
142
+ const skillRoot = findSkillRoot(PLUGIN_SRC_DIR);
143
+ const candidates = [];
144
+ if (args.skillDir)
145
+ candidates.push(args.skillDir);
146
+ if (skillRoot)
147
+ candidates.push(skillRoot);
148
+ candidates.push(projectRoot);
149
+ result.searched = candidates;
150
+ const found = findSkillMd(candidates);
151
+ result.skill = found ? found.content : null;
152
+ result.skillSource = found ? found.sourceDir : null;
153
+ }
154
+ if (requested.includes('project') || requested.includes('iterate.md')) {
155
+ result.project = readProjectFile(projectRoot, 'ITERATE.md');
156
+ }
157
+ return result;
158
+ },
159
+ }));
160
+ }