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,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
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/tools/experience-store.ts — experience bank storage layer.
|
|
3
|
+
*
|
|
4
|
+
* Provides read/write access to the experience bank stored in
|
|
5
|
+
* .iterate/experience.json. Experiences are accumulated across sessions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'node:fs'
|
|
9
|
+
import * as path from 'node:path'
|
|
10
|
+
import type { ExperienceBank, ExperienceEntry } from '../types.ts'
|
|
11
|
+
|
|
12
|
+
const EXPERIENCE_FILE = 'experience.json'
|
|
13
|
+
|
|
14
|
+
/** Default empty experience bank. */
|
|
15
|
+
function emptyBank(): ExperienceBank {
|
|
16
|
+
return {
|
|
17
|
+
entries: [],
|
|
18
|
+
lastUpdated: new Date().toISOString(),
|
|
19
|
+
totalHits: 0,
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Read the experience bank from disk. Returns empty bank if not found. */
|
|
24
|
+
export function readExperienceBank(projectRoot: string): ExperienceBank {
|
|
25
|
+
const filePath = path.join(projectRoot, '.iterate', EXPERIENCE_FILE)
|
|
26
|
+
try {
|
|
27
|
+
const content = fs.readFileSync(filePath, 'utf-8')
|
|
28
|
+
const parsed = JSON.parse(content) as ExperienceBank
|
|
29
|
+
if (parsed && Array.isArray(parsed.entries)) {
|
|
30
|
+
return parsed
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
// File not found or invalid JSON
|
|
34
|
+
}
|
|
35
|
+
return emptyBank()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Write the experience bank to disk. */
|
|
39
|
+
export function writeExperienceBank(projectRoot: string, bank: ExperienceBank): void {
|
|
40
|
+
const dirPath = path.join(projectRoot, '.iterate')
|
|
41
|
+
const filePath = path.join(dirPath, EXPERIENCE_FILE)
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
if (!fs.existsSync(dirPath)) {
|
|
45
|
+
fs.mkdirSync(dirPath, { recursive: true })
|
|
46
|
+
}
|
|
47
|
+
fs.writeFileSync(filePath, JSON.stringify(bank, null, 2), 'utf-8')
|
|
48
|
+
} catch {
|
|
49
|
+
// Silently fail - experience bank is not critical
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Search experience entries by query string. */
|
|
54
|
+
export function searchExperienceEntries(
|
|
55
|
+
entries: ExperienceEntry[],
|
|
56
|
+
query: string,
|
|
57
|
+
opts: { dimension?: string; tags?: string[] } = {},
|
|
58
|
+
): ExperienceEntry[] {
|
|
59
|
+
const lowerQuery = query.toLowerCase()
|
|
60
|
+
|
|
61
|
+
return entries.filter((entry) => {
|
|
62
|
+
// Dimension filter
|
|
63
|
+
if (opts.dimension && entry.dimension !== opts.dimension) {
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Tags filter (AND logic)
|
|
68
|
+
if (opts.tags && opts.tags.length > 0) {
|
|
69
|
+
if (!opts.tags.every((t) => entry.tags.includes(t))) {
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Text search across multiple fields
|
|
75
|
+
if (query) {
|
|
76
|
+
const searchableText = [
|
|
77
|
+
entry.pattern,
|
|
78
|
+
entry.description,
|
|
79
|
+
entry.verifiedFix,
|
|
80
|
+
entry.findingSummary,
|
|
81
|
+
entry.dimension,
|
|
82
|
+
...entry.files,
|
|
83
|
+
...entry.tags,
|
|
84
|
+
].join(' ').toLowerCase()
|
|
85
|
+
|
|
86
|
+
if (!searchableText.includes(lowerQuery)) {
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return true
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Fields the caller may supply when adding/updating an experience entry. */
|
|
96
|
+
export type ExperienceEntryInput = Omit<
|
|
97
|
+
ExperienceEntry,
|
|
98
|
+
'id' | 'timestamp' | 'hitCount' | 'lastHitAt'
|
|
99
|
+
> & { id?: string }
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Add or update an experience entry.
|
|
103
|
+
*
|
|
104
|
+
* An entry with an `id` that already exists, OR a new entry whose
|
|
105
|
+
* `pattern`+`dimension` pair matches an existing entry, is treated as a HIT:
|
|
106
|
+
* the matching entry's hitCount is incremented (lastHitAt refreshed) so
|
|
107
|
+
* repeated encounters of the same pattern do not create duplicates. Otherwise
|
|
108
|
+
* a fresh entry is appended with hitCount 1. Never mutates the input bank.
|
|
109
|
+
*
|
|
110
|
+
* Returns the resulting bank plus whether a NEW entry was created and the id
|
|
111
|
+
* of the affected entry.
|
|
112
|
+
*/
|
|
113
|
+
export function upsertExperience(
|
|
114
|
+
bank: ExperienceBank,
|
|
115
|
+
entry: ExperienceEntryInput
|
|
116
|
+
): { bank: ExperienceBank; added: boolean; entryId: string } {
|
|
117
|
+
const lastUpdated = new Date().toISOString()
|
|
118
|
+
const existing = entry.id
|
|
119
|
+
? bank.entries.find((e) => e.id === entry.id)
|
|
120
|
+
: bank.entries.find((e) => e.pattern === entry.pattern && e.dimension === entry.dimension)
|
|
121
|
+
|
|
122
|
+
if (existing) {
|
|
123
|
+
const updated: ExperienceEntry = {
|
|
124
|
+
...existing,
|
|
125
|
+
hitCount: (existing.hitCount ?? 0) + 1,
|
|
126
|
+
lastHitAt: lastUpdated,
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
bank: {
|
|
130
|
+
...bank,
|
|
131
|
+
entries: bank.entries.map((e) => (e.id === existing.id ? updated : e)),
|
|
132
|
+
lastUpdated,
|
|
133
|
+
totalHits: (bank.totalHits ?? 0) + 1,
|
|
134
|
+
},
|
|
135
|
+
added: false,
|
|
136
|
+
entryId: existing.id,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Add new entry
|
|
141
|
+
const id = entry.id || `exp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
142
|
+
const newEntry: ExperienceEntry = {
|
|
143
|
+
id,
|
|
144
|
+
timestamp: lastUpdated,
|
|
145
|
+
hitCount: 1,
|
|
146
|
+
lastHitAt: lastUpdated,
|
|
147
|
+
...entry,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
bank: {
|
|
152
|
+
...bank,
|
|
153
|
+
entries: [...bank.entries, newEntry],
|
|
154
|
+
lastUpdated,
|
|
155
|
+
totalHits: (bank.totalHits ?? 0) + 1,
|
|
156
|
+
},
|
|
157
|
+
added: true,
|
|
158
|
+
entryId: id,
|
|
159
|
+
}
|
|
160
|
+
}
|
package/src/tools/fix.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
|
|
21
21
|
import { join, sep } from 'node:path'
|
|
22
22
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
23
|
-
import type { JsonValue } from '@deepseek-ai/dsh-
|
|
23
|
+
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
24
24
|
import { loadEffectiveConfig, resolveProjectRootForExec } from '../config-loader.ts'
|
|
25
25
|
import { runWithJob } from '../jobs.ts'
|
|
26
26
|
import { countTouchedMethods } from '../method-scope.ts'
|
package/src/tools/history.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
12
|
-
import type { JsonValue } from '@deepseek-ai/dsh-
|
|
12
|
+
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
13
13
|
import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
14
14
|
import { readDecisionEntries } from './decision-log.ts'
|
|
15
15
|
import { readRegistry } from './fix.ts'
|
package/src/tools/prune.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import { existsSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
22
22
|
import { join } from 'node:path'
|
|
23
23
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
24
|
-
import type { JsonValue } from '@deepseek-ai/dsh-
|
|
24
|
+
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
25
25
|
import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
26
26
|
import { readDecisionEntries, appendDecisionEntry } from './decision-log.ts'
|
|
27
27
|
import { readRegistry, removeRecord, recomputeRoundCounts } from './fix.ts'
|