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