iterate-plugin 2.12.2 → 3.2.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.
- package/README.md +28 -12
- package/README.zh-CN.md +2 -1
- package/dist/approval-gate.js +16 -2
- package/dist/config-loader.js +5 -0
- package/dist/git-scope.js +61 -7
- package/dist/index.js +16 -6
- package/dist/session-hooks.js +36 -11
- package/dist/skill-prompt.js +3 -0
- package/dist/tools/decision-log.js +10 -1
- package/dist/tools/defense-events.js +260 -0
- package/dist/tools/defense-store.js +97 -0
- package/dist/tools/experience-bank.js +248 -0
- package/dist/tools/experience-store.js +132 -0
- package/dist/tools/quality-gate.js +180 -0
- package/dist/tools/quality-store.js +174 -0
- package/lib/client.js +662 -103
- package/lib/parse.js +93 -0
- package/package.json +7 -6
- package/src/approval-gate.ts +14 -2
- package/src/client/index.ts +542 -49
- package/src/config-loader.ts +5 -0
- package/src/git-scope.ts +48 -7
- package/src/index.ts +16 -6
- package/src/session-hooks.ts +33 -11
- package/src/skill-prompt.ts +3 -0
- package/src/tools/checkpoint.ts +1 -1
- package/src/tools/config.ts +1 -1
- package/src/tools/decision-log.ts +11 -2
- package/src/tools/defense-events.ts +295 -0
- package/src/tools/defense-store.ts +113 -0
- package/src/tools/experience-bank.ts +264 -0
- package/src/tools/experience-store.ts +160 -0
- package/src/tools/fix.ts +1 -1
- package/src/tools/history.ts +1 -1
- package/src/tools/prune.ts +1 -1
- package/src/tools/quality-gate.ts +193 -0
- package/src/tools/quality-store.ts +199 -0
- package/src/tools/review.ts +1 -1
- package/src/tools/transcript.ts +1 -1
- package/src/tools/triage.ts +1 -1
- package/src/types.ts +118 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/tools/defense-events.ts — defense event stream query & record tool.
|
|
3
|
+
*
|
|
4
|
+
* iterate_defense_events — browse/search defense events from the current
|
|
5
|
+
* iteration, or record a new one.
|
|
6
|
+
*
|
|
7
|
+
* Defense events include: precondition failures, rollbacks, invariant violations,
|
|
8
|
+
* and assumption falsifications. Read operations give visibility into defensive
|
|
9
|
+
* actions; "record" persists a new event to .iterate/defense-events.json.
|
|
10
|
+
*/
|
|
11
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
12
|
+
import { resolveProjectRootForExec, loadEffectiveConfig } from "../config-loader.js";
|
|
13
|
+
import { readDefenseEvents, writeDefenseEvents, addDefenseEvent } from "./defense-store.js";
|
|
14
|
+
const DEFAULT_LIMIT = 50;
|
|
15
|
+
const MAX_LIMIT = 100;
|
|
16
|
+
const EVENT_TYPES = [
|
|
17
|
+
'precondition_failed',
|
|
18
|
+
'rollback',
|
|
19
|
+
'invariant_violated',
|
|
20
|
+
'assumption_falsified',
|
|
21
|
+
];
|
|
22
|
+
/** Clamp a caller-supplied limit to a sane range. */
|
|
23
|
+
function clampLimit(limit) {
|
|
24
|
+
if (typeof limit !== 'number' || !Number.isInteger(limit) || limit <= 0) {
|
|
25
|
+
return DEFAULT_LIMIT;
|
|
26
|
+
}
|
|
27
|
+
return Math.min(limit, MAX_LIMIT);
|
|
28
|
+
}
|
|
29
|
+
/** Bilingual, config-driven human-readable labels for defense event types. */
|
|
30
|
+
const EVENT_TYPE_LABELS = {
|
|
31
|
+
precondition_failed: { zh: '前置校验失败', en: 'precondition failed' },
|
|
32
|
+
rollback: { zh: '回滚', en: 'rollback' },
|
|
33
|
+
invariant_violated: { zh: '不变量违反', en: 'invariant violated' },
|
|
34
|
+
assumption_falsified: { zh: '假设被证伪', en: 'assumption falsified' },
|
|
35
|
+
};
|
|
36
|
+
/** Label for a defense event type in the requested language (fallback: English). */
|
|
37
|
+
function labelFor(type, language) {
|
|
38
|
+
const labels = EVENT_TYPE_LABELS[type];
|
|
39
|
+
return labels ? labels[language] : type;
|
|
40
|
+
}
|
|
41
|
+
/** Validate arguments for the record operation. */
|
|
42
|
+
function validateRecordInput(args) {
|
|
43
|
+
const errors = [];
|
|
44
|
+
if (typeof args.type !== 'string' || !EVENT_TYPES.includes(args.type)) {
|
|
45
|
+
errors.push(`type must be one of: ${EVENT_TYPES.join(', ')}`);
|
|
46
|
+
}
|
|
47
|
+
if (typeof args.round !== 'number' || !Number.isInteger(args.round) || args.round < 1) {
|
|
48
|
+
errors.push('round must be a positive integer');
|
|
49
|
+
}
|
|
50
|
+
if (typeof args.description !== 'string' || !args.description.trim()) {
|
|
51
|
+
errors.push('description is required');
|
|
52
|
+
}
|
|
53
|
+
if (typeof args.defense !== 'string' || !args.defense.trim()) {
|
|
54
|
+
errors.push('defense is required');
|
|
55
|
+
}
|
|
56
|
+
if (typeof args.outcome !== 'string' || !args.outcome.trim()) {
|
|
57
|
+
errors.push('outcome is required');
|
|
58
|
+
}
|
|
59
|
+
const severity = args.severity;
|
|
60
|
+
if (severity !== 'critical' && severity !== 'high' && severity !== 'medium' && severity !== 'low') {
|
|
61
|
+
errors.push('severity must be one of critical, high, medium, low');
|
|
62
|
+
}
|
|
63
|
+
return errors;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Register the `iterate_defense_events` tool.
|
|
67
|
+
* Queries defense events from the current iteration.
|
|
68
|
+
*/
|
|
69
|
+
export function registerDefenseEventsTool(ctx) {
|
|
70
|
+
ctx.tools.register(defineTool({
|
|
71
|
+
name: 'iterate_defense_events',
|
|
72
|
+
description: 'Query or record defense events: precondition failures, rollbacks, invariant violations, ' +
|
|
73
|
+
'and assumption falsifications. ' +
|
|
74
|
+
'List/counts return events with descriptions, outcomes, and summary counts; ' +
|
|
75
|
+
'"record" persists a new event to .iterate/defense-events.json. ' +
|
|
76
|
+
'Use it to review defensive actions taken, or to log one when a defense fires.',
|
|
77
|
+
parameters: {
|
|
78
|
+
operation: {
|
|
79
|
+
type: 'string',
|
|
80
|
+
description: 'Operation: list (browse all), counts (summary by type), record (log a new event). Default: list.',
|
|
81
|
+
enum: ['list', 'counts', 'record'],
|
|
82
|
+
},
|
|
83
|
+
type: {
|
|
84
|
+
type: 'string',
|
|
85
|
+
description: 'Event type (filter for list; required for record): precondition_failed, rollback, invariant_violated, assumption_falsified.',
|
|
86
|
+
},
|
|
87
|
+
round: {
|
|
88
|
+
type: 'integer',
|
|
89
|
+
description: 'Round number (filter for list; required for record).',
|
|
90
|
+
},
|
|
91
|
+
severity: {
|
|
92
|
+
type: 'string',
|
|
93
|
+
description: 'Severity (filter for list; required for record): critical, high, medium, low.',
|
|
94
|
+
},
|
|
95
|
+
description: {
|
|
96
|
+
type: 'string',
|
|
97
|
+
description: 'What was being checked (required for record).',
|
|
98
|
+
},
|
|
99
|
+
defense: {
|
|
100
|
+
type: 'string',
|
|
101
|
+
description: 'The defense that was triggered (required for record).',
|
|
102
|
+
},
|
|
103
|
+
outcome: {
|
|
104
|
+
type: 'string',
|
|
105
|
+
description: 'Outcome: what was protected against (required for record).',
|
|
106
|
+
},
|
|
107
|
+
file: {
|
|
108
|
+
type: 'string',
|
|
109
|
+
description: 'Optional file/location context (record).',
|
|
110
|
+
},
|
|
111
|
+
line: {
|
|
112
|
+
type: 'integer',
|
|
113
|
+
description: 'Optional line number context (record).',
|
|
114
|
+
},
|
|
115
|
+
language: {
|
|
116
|
+
type: 'string',
|
|
117
|
+
description: 'Label language for readable output: en (default) or zh. Falls back to the project config language.',
|
|
118
|
+
enum: ['en', 'zh'],
|
|
119
|
+
},
|
|
120
|
+
limit: {
|
|
121
|
+
type: 'integer',
|
|
122
|
+
description: `Max events to return (default: ${DEFAULT_LIMIT}, cap: ${MAX_LIMIT}).`,
|
|
123
|
+
},
|
|
124
|
+
path: {
|
|
125
|
+
type: 'string',
|
|
126
|
+
description: 'Project root directory (default: current working directory).',
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
output: {
|
|
130
|
+
schema: {
|
|
131
|
+
type: 'object',
|
|
132
|
+
additionalProperties: false,
|
|
133
|
+
properties: {
|
|
134
|
+
ok: { type: 'boolean', required: true },
|
|
135
|
+
kind: { type: 'string' },
|
|
136
|
+
operation: { type: 'string' },
|
|
137
|
+
count: { type: 'integer' },
|
|
138
|
+
events: { type: 'json' },
|
|
139
|
+
counts: { type: 'json' },
|
|
140
|
+
event: { type: 'json' },
|
|
141
|
+
language: { type: 'string' },
|
|
142
|
+
errors: { type: 'json' },
|
|
143
|
+
error: { type: 'string' },
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
render: (_args, value) => {
|
|
147
|
+
if (!value.ok)
|
|
148
|
+
return [{ type: 'text', text: `defense events query failed: ${value.error}` }];
|
|
149
|
+
const language = value.language === 'zh' ? 'zh' : 'en';
|
|
150
|
+
if (value.operation === 'counts' && value.counts) {
|
|
151
|
+
const counts = value.counts;
|
|
152
|
+
const lines = [
|
|
153
|
+
'Defense Event Summary:',
|
|
154
|
+
...EVENT_TYPES.map((type) => ` ${labelFor(type, language)}: ${counts[type] ?? 0}`),
|
|
155
|
+
` Total: ${EVENT_TYPES.reduce((sum, type) => sum + (counts[type] ?? 0), 0)}`,
|
|
156
|
+
];
|
|
157
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
158
|
+
}
|
|
159
|
+
if (value.operation === 'record' && value.event) {
|
|
160
|
+
const e = value.event;
|
|
161
|
+
return [{ type: 'text', text: [
|
|
162
|
+
`Recorded defense event: ${e.id}`,
|
|
163
|
+
` Round ${e.round} - ${labelFor(e.type, language)} (${e.severity})`,
|
|
164
|
+
` Check: ${e.description}`,
|
|
165
|
+
` Defense: ${e.defense}`,
|
|
166
|
+
` Outcome: ${e.outcome}`,
|
|
167
|
+
e.file ? ` File: ${e.file}${e.line ? `:${e.line}` : ''}` : '',
|
|
168
|
+
].filter(Boolean).join('\n') }];
|
|
169
|
+
}
|
|
170
|
+
const events = value.events ?? [];
|
|
171
|
+
if (events.length === 0) {
|
|
172
|
+
return [{ type: 'text', text: 'No defense events recorded.' }];
|
|
173
|
+
}
|
|
174
|
+
const lines = [
|
|
175
|
+
`Defense Events (${value.count} total):`,
|
|
176
|
+
'',
|
|
177
|
+
...events.map((e) => {
|
|
178
|
+
const typeLabel = labelFor(e.type, language);
|
|
179
|
+
return `[${e.id}] Round ${e.round} - ${typeLabel}\n ${e.description}\n Outcome: ${e.outcome}`;
|
|
180
|
+
}),
|
|
181
|
+
];
|
|
182
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
async execute(args, exec) {
|
|
186
|
+
const resolved = resolveProjectRootForExec(exec, args.path);
|
|
187
|
+
if (!resolved.ok)
|
|
188
|
+
return { ok: false, kind: 'defense_events', error: resolved.reason };
|
|
189
|
+
const projectRoot = resolved.root;
|
|
190
|
+
const configLang = loadEffectiveConfig(projectRoot).config.language;
|
|
191
|
+
const language = args.language === 'zh' || args.language === 'en' ? args.language : configLang;
|
|
192
|
+
const operation = typeof args.operation === 'string' ? args.operation : 'list';
|
|
193
|
+
const limit = clampLimit(args.limit);
|
|
194
|
+
if (operation === 'record') {
|
|
195
|
+
const errors = validateRecordInput(args);
|
|
196
|
+
if (errors.length > 0) {
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
kind: 'defense_events',
|
|
200
|
+
operation: 'record',
|
|
201
|
+
errors: errors,
|
|
202
|
+
error: `Invalid defense event: ${errors.join('; ')}`,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
const stream = readDefenseEvents(projectRoot);
|
|
206
|
+
const next = addDefenseEvent(stream, {
|
|
207
|
+
round: args.round,
|
|
208
|
+
type: args.type,
|
|
209
|
+
description: args.description,
|
|
210
|
+
defense: args.defense,
|
|
211
|
+
outcome: args.outcome,
|
|
212
|
+
severity: args.severity,
|
|
213
|
+
...(typeof args.file === 'string' && args.file.length > 0 ? { file: args.file } : {}),
|
|
214
|
+
...(typeof args.line === 'number' ? { line: args.line } : {}),
|
|
215
|
+
});
|
|
216
|
+
writeDefenseEvents(projectRoot, next);
|
|
217
|
+
const event = next.events[next.events.length - 1];
|
|
218
|
+
return {
|
|
219
|
+
ok: true,
|
|
220
|
+
kind: 'defense_events',
|
|
221
|
+
operation: 'record',
|
|
222
|
+
language,
|
|
223
|
+
event: event,
|
|
224
|
+
counts: next.counts,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
const stream = readDefenseEvents(projectRoot);
|
|
228
|
+
if (operation === 'counts') {
|
|
229
|
+
return {
|
|
230
|
+
ok: true,
|
|
231
|
+
kind: 'defense_events',
|
|
232
|
+
operation: 'counts',
|
|
233
|
+
language,
|
|
234
|
+
counts: stream.counts,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
// Filter events
|
|
238
|
+
let events = stream.events;
|
|
239
|
+
if (typeof args.type === 'string' && args.type) {
|
|
240
|
+
events = events.filter((e) => e.type === args.type);
|
|
241
|
+
}
|
|
242
|
+
if (typeof args.round === 'number') {
|
|
243
|
+
events = events.filter((e) => e.round === args.round);
|
|
244
|
+
}
|
|
245
|
+
if (typeof args.severity === 'string' && args.severity) {
|
|
246
|
+
events = events.filter((e) => e.severity === args.severity);
|
|
247
|
+
}
|
|
248
|
+
// Sort by timestamp descending (newest first)
|
|
249
|
+
events.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
250
|
+
return {
|
|
251
|
+
ok: true,
|
|
252
|
+
kind: 'defense_events',
|
|
253
|
+
operation: 'list',
|
|
254
|
+
language,
|
|
255
|
+
count: Math.min(events.length, limit),
|
|
256
|
+
events: events.slice(0, limit),
|
|
257
|
+
};
|
|
258
|
+
},
|
|
259
|
+
}));
|
|
260
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/tools/defense-store.ts — defense event storage layer.
|
|
3
|
+
*
|
|
4
|
+
* Provides read/write access to defense events stored in
|
|
5
|
+
* .iterate/defense-events.json. Events are accumulated during iteration.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from 'node:fs';
|
|
8
|
+
import * as path from 'node:path';
|
|
9
|
+
const DEFENSE_EVENTS_FILE = 'defense-events.json';
|
|
10
|
+
/** Valid defense event types (must stay in sync with DefenseEventType). */
|
|
11
|
+
const VALID_EVENT_TYPES = new Set([
|
|
12
|
+
'precondition_failed',
|
|
13
|
+
'rollback',
|
|
14
|
+
'invariant_violated',
|
|
15
|
+
'assumption_falsified',
|
|
16
|
+
]);
|
|
17
|
+
/**
|
|
18
|
+
* Bump the count for an event type. Unknown types (malformed JSON on disk,
|
|
19
|
+
* or a caller passing an untyped value) are ignored rather than crashing or
|
|
20
|
+
* creating garbage keys in the counts object.
|
|
21
|
+
*/
|
|
22
|
+
function bumpCount(counts, type) {
|
|
23
|
+
if (typeof type === 'string' && VALID_EVENT_TYPES.has(type)) {
|
|
24
|
+
counts[type]++;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Default empty defense event stream. */
|
|
28
|
+
function emptyStream() {
|
|
29
|
+
return {
|
|
30
|
+
events: [],
|
|
31
|
+
lastUpdated: new Date().toISOString(),
|
|
32
|
+
counts: {
|
|
33
|
+
precondition_failed: 0,
|
|
34
|
+
rollback: 0,
|
|
35
|
+
invariant_violated: 0,
|
|
36
|
+
assumption_falsified: 0,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Read the defense events stream from disk. */
|
|
41
|
+
export function readDefenseEvents(projectRoot) {
|
|
42
|
+
const filePath = path.join(projectRoot, '.iterate', DEFENSE_EVENTS_FILE);
|
|
43
|
+
try {
|
|
44
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
45
|
+
const parsed = JSON.parse(content);
|
|
46
|
+
if (parsed && Array.isArray(parsed.events)) {
|
|
47
|
+
return parsed;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// File not found or invalid JSON
|
|
52
|
+
}
|
|
53
|
+
return emptyStream();
|
|
54
|
+
}
|
|
55
|
+
/** Write the defense events stream to disk. */
|
|
56
|
+
export function writeDefenseEvents(projectRoot, stream) {
|
|
57
|
+
const dirPath = path.join(projectRoot, '.iterate');
|
|
58
|
+
const filePath = path.join(dirPath, DEFENSE_EVENTS_FILE);
|
|
59
|
+
try {
|
|
60
|
+
if (!fs.existsSync(dirPath)) {
|
|
61
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
62
|
+
}
|
|
63
|
+
fs.writeFileSync(filePath, JSON.stringify(stream, null, 2), 'utf-8');
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// Silently fail - defense events are not critical
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Add a defense event to the stream. */
|
|
70
|
+
export function addDefenseEvent(stream, event) {
|
|
71
|
+
const id = `def-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
72
|
+
const newEvent = {
|
|
73
|
+
id,
|
|
74
|
+
timestamp: new Date().toISOString(),
|
|
75
|
+
...event,
|
|
76
|
+
};
|
|
77
|
+
const newCounts = { ...stream.counts };
|
|
78
|
+
bumpCount(newCounts, event.type);
|
|
79
|
+
return {
|
|
80
|
+
events: [...stream.events, newEvent],
|
|
81
|
+
lastUpdated: new Date().toISOString(),
|
|
82
|
+
counts: newCounts,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/** Compute counts from events array (for consistency). */
|
|
86
|
+
export function computeCounts(events) {
|
|
87
|
+
const counts = {
|
|
88
|
+
precondition_failed: 0,
|
|
89
|
+
rollback: 0,
|
|
90
|
+
invariant_violated: 0,
|
|
91
|
+
assumption_falsified: 0,
|
|
92
|
+
};
|
|
93
|
+
for (const event of events) {
|
|
94
|
+
bumpCount(counts, event.type);
|
|
95
|
+
}
|
|
96
|
+
return counts;
|
|
97
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/tools/experience-bank.ts — experience bank query tool.
|
|
3
|
+
*
|
|
4
|
+
* iterate_experience — browse, search, query, and add project experience entries.
|
|
5
|
+
*
|
|
6
|
+
* Experiences are accumulated across sessions and stored in .iterate/experience.json.
|
|
7
|
+
*/
|
|
8
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
9
|
+
import { resolveProjectRootForExec } from "../config-loader.js";
|
|
10
|
+
import { readExperienceBank, writeExperienceBank, searchExperienceEntries, upsertExperience } from "./experience-store.js";
|
|
11
|
+
const DEFAULT_LIMIT = 50;
|
|
12
|
+
const MAX_LIMIT = 100;
|
|
13
|
+
/** Clamp a caller-supplied limit to a sane range. */
|
|
14
|
+
function clampLimit(limit) {
|
|
15
|
+
if (typeof limit !== 'number' || !Number.isInteger(limit) || limit <= 0) {
|
|
16
|
+
return DEFAULT_LIMIT;
|
|
17
|
+
}
|
|
18
|
+
return Math.min(limit, MAX_LIMIT);
|
|
19
|
+
}
|
|
20
|
+
/** Validate a caller-supplied experience entry object. Returns error strings. */
|
|
21
|
+
function validateExperienceInput(raw) {
|
|
22
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
23
|
+
return ['entry must be a JSON object'];
|
|
24
|
+
}
|
|
25
|
+
const e = raw;
|
|
26
|
+
const errors = [];
|
|
27
|
+
if (typeof e.pattern !== 'string' || !e.pattern.trim())
|
|
28
|
+
errors.push('.pattern is required');
|
|
29
|
+
if (typeof e.dimension !== 'string' || !e.dimension.trim())
|
|
30
|
+
errors.push('.dimension is required');
|
|
31
|
+
if (typeof e.description !== 'string' || !e.description.trim())
|
|
32
|
+
errors.push('.description is required');
|
|
33
|
+
if (typeof e.verifiedFix !== 'string' || !e.verifiedFix.trim())
|
|
34
|
+
errors.push('.verifiedFix is required');
|
|
35
|
+
if (typeof e.findingSummary !== 'string' || !e.findingSummary.trim())
|
|
36
|
+
errors.push('.findingSummary is required');
|
|
37
|
+
const severity = e.severity;
|
|
38
|
+
if (severity !== 'critical' && severity !== 'high' && severity !== 'medium' && severity !== 'low') {
|
|
39
|
+
errors.push('.severity must be one of critical, high, medium, low');
|
|
40
|
+
}
|
|
41
|
+
if (!Array.isArray(e.files) || !e.files.every((f) => typeof f === 'string' && f.length > 0)) {
|
|
42
|
+
errors.push('.files must be an array of non-empty strings');
|
|
43
|
+
}
|
|
44
|
+
if (!Array.isArray(e.tags) || !e.tags.every((t) => typeof t === 'string')) {
|
|
45
|
+
errors.push('.tags must be an array of strings');
|
|
46
|
+
}
|
|
47
|
+
return errors;
|
|
48
|
+
}
|
|
49
|
+
/** Normalize a validated raw entry into the store input shape. */
|
|
50
|
+
function normalizeExperienceInput(raw) {
|
|
51
|
+
return {
|
|
52
|
+
...(typeof raw.id === 'string' && raw.id.length > 0 ? { id: raw.id } : {}),
|
|
53
|
+
pattern: raw.pattern,
|
|
54
|
+
description: raw.description,
|
|
55
|
+
verifiedFix: raw.verifiedFix,
|
|
56
|
+
dimension: raw.dimension,
|
|
57
|
+
findingSummary: raw.findingSummary,
|
|
58
|
+
severity: raw.severity,
|
|
59
|
+
files: raw.files,
|
|
60
|
+
tags: raw.tags,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Register the `iterate_experience` tool.
|
|
65
|
+
* Queries the experience bank for historical fixes and patterns.
|
|
66
|
+
*/
|
|
67
|
+
export function registerExperienceBankTool(ctx) {
|
|
68
|
+
ctx.tools.register(defineTool({
|
|
69
|
+
name: 'iterate_experience',
|
|
70
|
+
description: 'Query or extend the experience bank: browse/search historical fixes and patterns, ' +
|
|
71
|
+
'or record a new verified fix (operation:"add"). ' +
|
|
72
|
+
'List/search/get return matching entries with hit counts, verified fixes, and related context. ' +
|
|
73
|
+
'"add" upserts an experience entry into .iterate/experience.json — a repeat of the same ' +
|
|
74
|
+
'pattern+dimension increments its hit count instead of duplicating it. ' +
|
|
75
|
+
'Use it to remember fixes that worked so future rounds apply them first.',
|
|
76
|
+
parameters: {
|
|
77
|
+
operation: {
|
|
78
|
+
type: 'string',
|
|
79
|
+
description: 'Operation: list (browse all), search (by query), get (by id), add (add a new experience). Default: list.',
|
|
80
|
+
enum: ['list', 'search', 'get', 'add'],
|
|
81
|
+
},
|
|
82
|
+
query: {
|
|
83
|
+
type: 'string',
|
|
84
|
+
description: 'Search query (for search operation). Matches against pattern, description, files, tags.',
|
|
85
|
+
},
|
|
86
|
+
dimension: {
|
|
87
|
+
type: 'string',
|
|
88
|
+
description: 'Filter by dimension (e.g., correctness, security, performance).',
|
|
89
|
+
},
|
|
90
|
+
tags: {
|
|
91
|
+
type: 'array',
|
|
92
|
+
items: { type: 'string' },
|
|
93
|
+
description: 'Filter by tags (AND logic).',
|
|
94
|
+
},
|
|
95
|
+
id: {
|
|
96
|
+
type: 'string',
|
|
97
|
+
description: 'Experience ID (for get operation, or to update a specific entry via add).',
|
|
98
|
+
},
|
|
99
|
+
entry: {
|
|
100
|
+
type: 'json',
|
|
101
|
+
description: 'Experience entry object (required for add). Fields: id (optional), pattern, dimension, description, ' +
|
|
102
|
+
'verifiedFix, findingSummary, severity (critical|high|medium|low), files (string[]), tags (string[]).',
|
|
103
|
+
},
|
|
104
|
+
limit: {
|
|
105
|
+
type: 'integer',
|
|
106
|
+
description: `Max entries to return (default: ${DEFAULT_LIMIT}, cap: ${MAX_LIMIT}).`,
|
|
107
|
+
},
|
|
108
|
+
path: {
|
|
109
|
+
type: 'string',
|
|
110
|
+
description: 'Project root directory (default: current working directory).',
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
output: {
|
|
114
|
+
schema: {
|
|
115
|
+
type: 'object',
|
|
116
|
+
additionalProperties: false,
|
|
117
|
+
properties: {
|
|
118
|
+
ok: { type: 'boolean', required: true },
|
|
119
|
+
kind: { type: 'string' },
|
|
120
|
+
operation: { type: 'string' },
|
|
121
|
+
count: { type: 'integer' },
|
|
122
|
+
entries: { type: 'json' },
|
|
123
|
+
entry: { type: 'json' },
|
|
124
|
+
totalHits: { type: 'integer' },
|
|
125
|
+
added: { type: 'boolean' },
|
|
126
|
+
errors: { type: 'json' },
|
|
127
|
+
error: { type: 'string' },
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
render: (_args, value) => {
|
|
131
|
+
if (!value.ok)
|
|
132
|
+
return [{ type: 'text', text: `experience query failed: ${value.error}` }];
|
|
133
|
+
if (value.operation === 'add' && value.entry) {
|
|
134
|
+
const entry = value.entry;
|
|
135
|
+
return [{ type: 'text', text: [
|
|
136
|
+
value.added
|
|
137
|
+
? `Recorded new experience: ${entry.id}`
|
|
138
|
+
: `Experience already known (hit ${entry.hitCount}): ${entry.id}`,
|
|
139
|
+
`Pattern: ${entry.pattern}`,
|
|
140
|
+
`Dimension: ${entry.dimension}`,
|
|
141
|
+
`Description: ${entry.description}`,
|
|
142
|
+
`Fix: ${entry.verifiedFix}`,
|
|
143
|
+
`Files: ${entry.files.join(', ')}`,
|
|
144
|
+
`Tags: ${entry.tags.join(', ')}`,
|
|
145
|
+
].join('\n') }];
|
|
146
|
+
}
|
|
147
|
+
if (value.operation === 'get' && value.entry) {
|
|
148
|
+
const entry = value.entry;
|
|
149
|
+
return [{ type: 'text', text: [
|
|
150
|
+
`Experience: ${entry.id}`,
|
|
151
|
+
`Pattern: ${entry.pattern}`,
|
|
152
|
+
`Description: ${entry.description}`,
|
|
153
|
+
`Fix: ${entry.verifiedFix}`,
|
|
154
|
+
`Files: ${entry.files.join(', ')}`,
|
|
155
|
+
`Hits: ${entry.hitCount}`,
|
|
156
|
+
`Tags: ${entry.tags.join(', ')}`,
|
|
157
|
+
].join('\n') }];
|
|
158
|
+
}
|
|
159
|
+
const entries = value.entries ?? [];
|
|
160
|
+
const lines = [
|
|
161
|
+
`Found ${value.count} experience(s) (total hits: ${value.totalHits})`,
|
|
162
|
+
'',
|
|
163
|
+
...entries.map((e) => `[${e.id}] ${e.pattern} (hits: ${e.hitCount}) - ${e.description}`),
|
|
164
|
+
];
|
|
165
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
async execute(args, exec) {
|
|
169
|
+
const resolved = resolveProjectRootForExec(exec, args.path);
|
|
170
|
+
if (!resolved.ok)
|
|
171
|
+
return { ok: false, kind: 'experience', error: resolved.reason };
|
|
172
|
+
const projectRoot = resolved.root;
|
|
173
|
+
const operation = typeof args.operation === 'string' ? args.operation : 'list';
|
|
174
|
+
const limit = clampLimit(args.limit);
|
|
175
|
+
if (operation === 'add') {
|
|
176
|
+
const raw = args.entry;
|
|
177
|
+
const errors = validateExperienceInput(raw);
|
|
178
|
+
if (errors.length > 0) {
|
|
179
|
+
return {
|
|
180
|
+
ok: false,
|
|
181
|
+
kind: 'experience',
|
|
182
|
+
operation: 'add',
|
|
183
|
+
errors: errors,
|
|
184
|
+
error: `Invalid experience entry: ${errors.join('; ')}`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
const bank = readExperienceBank(projectRoot);
|
|
188
|
+
const { bank: next, added, entryId } = upsertExperience(bank, normalizeExperienceInput(raw));
|
|
189
|
+
writeExperienceBank(projectRoot, next);
|
|
190
|
+
const entry = next.entries.find((e) => e.id === entryId);
|
|
191
|
+
return {
|
|
192
|
+
ok: true,
|
|
193
|
+
kind: 'experience',
|
|
194
|
+
operation: 'add',
|
|
195
|
+
added,
|
|
196
|
+
count: next.entries.length,
|
|
197
|
+
entry: entry,
|
|
198
|
+
totalHits: next.totalHits,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
const bank = readExperienceBank(projectRoot);
|
|
202
|
+
if (operation === 'get' && typeof args.id === 'string') {
|
|
203
|
+
const entry = bank.entries.find((e) => e.id === args.id);
|
|
204
|
+
if (!entry) {
|
|
205
|
+
return { ok: false, kind: 'experience', error: `Experience not found: ${args.id}` };
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
ok: true,
|
|
209
|
+
kind: 'experience',
|
|
210
|
+
operation: 'get',
|
|
211
|
+
count: 1,
|
|
212
|
+
entry: entry,
|
|
213
|
+
totalHits: bank.totalHits,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
if (operation === 'search' && typeof args.query === 'string') {
|
|
217
|
+
const entries = searchExperienceEntries(bank.entries, args.query, {
|
|
218
|
+
dimension: typeof args.dimension === 'string' ? args.dimension : undefined,
|
|
219
|
+
tags: Array.isArray(args.tags) ? args.tags : undefined,
|
|
220
|
+
}).slice(0, limit);
|
|
221
|
+
return {
|
|
222
|
+
ok: true,
|
|
223
|
+
kind: 'experience',
|
|
224
|
+
operation: 'search',
|
|
225
|
+
count: entries.length,
|
|
226
|
+
entries: entries,
|
|
227
|
+
totalHits: bank.totalHits,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
// Default: list with optional filters
|
|
231
|
+
let entries = bank.entries;
|
|
232
|
+
if (typeof args.dimension === 'string' && args.dimension) {
|
|
233
|
+
entries = entries.filter((e) => e.dimension === args.dimension);
|
|
234
|
+
}
|
|
235
|
+
if (Array.isArray(args.tags) && args.tags.length > 0) {
|
|
236
|
+
entries = entries.filter((e) => args.tags.every((t) => e.tags.includes(t)));
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
ok: true,
|
|
240
|
+
kind: 'experience',
|
|
241
|
+
operation: 'list',
|
|
242
|
+
count: Math.min(entries.length, limit),
|
|
243
|
+
entries: entries.slice(0, limit),
|
|
244
|
+
totalHits: bank.totalHits,
|
|
245
|
+
};
|
|
246
|
+
},
|
|
247
|
+
}));
|
|
248
|
+
}
|