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.
- package/README.md +28 -13
- package/dist/approval-gate.js +16 -2
- package/dist/config-loader.js +5 -0
- 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 +318 -15
- package/package.json +6 -6
- package/src/approval-gate.ts +14 -2
- package/src/client/index.ts +288 -16
- package/src/config-loader.ts +5 -0
- package/src/index.ts +16 -6
- package/src/session-hooks.ts +33 -11
- package/src/skill-prompt.ts +3 -0
- package/src/tools/decision-log.ts +10 -1
- 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/quality-gate.ts +193 -0
- package/src/tools/quality-store.ts +199 -0
- package/src/types.ts +118 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
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
|
+
import * as fs from 'node:fs';
|
|
8
|
+
import * as path from 'node:path';
|
|
9
|
+
const EXPERIENCE_FILE = 'experience.json';
|
|
10
|
+
/** Default empty experience bank. */
|
|
11
|
+
function emptyBank() {
|
|
12
|
+
return {
|
|
13
|
+
entries: [],
|
|
14
|
+
lastUpdated: new Date().toISOString(),
|
|
15
|
+
totalHits: 0,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/** Read the experience bank from disk. Returns empty bank if not found. */
|
|
19
|
+
export function readExperienceBank(projectRoot) {
|
|
20
|
+
const filePath = path.join(projectRoot, '.iterate', EXPERIENCE_FILE);
|
|
21
|
+
try {
|
|
22
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
23
|
+
const parsed = JSON.parse(content);
|
|
24
|
+
if (parsed && Array.isArray(parsed.entries)) {
|
|
25
|
+
return parsed;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// File not found or invalid JSON
|
|
30
|
+
}
|
|
31
|
+
return emptyBank();
|
|
32
|
+
}
|
|
33
|
+
/** Write the experience bank to disk. */
|
|
34
|
+
export function writeExperienceBank(projectRoot, bank) {
|
|
35
|
+
const dirPath = path.join(projectRoot, '.iterate');
|
|
36
|
+
const filePath = path.join(dirPath, EXPERIENCE_FILE);
|
|
37
|
+
try {
|
|
38
|
+
if (!fs.existsSync(dirPath)) {
|
|
39
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
40
|
+
}
|
|
41
|
+
fs.writeFileSync(filePath, JSON.stringify(bank, null, 2), 'utf-8');
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Silently fail - experience bank is not critical
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Search experience entries by query string. */
|
|
48
|
+
export function searchExperienceEntries(entries, query, opts = {}) {
|
|
49
|
+
const lowerQuery = query.toLowerCase();
|
|
50
|
+
return entries.filter((entry) => {
|
|
51
|
+
// Dimension filter
|
|
52
|
+
if (opts.dimension && entry.dimension !== opts.dimension) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
// Tags filter (AND logic)
|
|
56
|
+
if (opts.tags && opts.tags.length > 0) {
|
|
57
|
+
if (!opts.tags.every((t) => entry.tags.includes(t))) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Text search across multiple fields
|
|
62
|
+
if (query) {
|
|
63
|
+
const searchableText = [
|
|
64
|
+
entry.pattern,
|
|
65
|
+
entry.description,
|
|
66
|
+
entry.verifiedFix,
|
|
67
|
+
entry.findingSummary,
|
|
68
|
+
entry.dimension,
|
|
69
|
+
...entry.files,
|
|
70
|
+
...entry.tags,
|
|
71
|
+
].join(' ').toLowerCase();
|
|
72
|
+
if (!searchableText.includes(lowerQuery)) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Add or update an experience entry.
|
|
81
|
+
*
|
|
82
|
+
* An entry with an `id` that already exists, OR a new entry whose
|
|
83
|
+
* `pattern`+`dimension` pair matches an existing entry, is treated as a HIT:
|
|
84
|
+
* the matching entry's hitCount is incremented (lastHitAt refreshed) so
|
|
85
|
+
* repeated encounters of the same pattern do not create duplicates. Otherwise
|
|
86
|
+
* a fresh entry is appended with hitCount 1. Never mutates the input bank.
|
|
87
|
+
*
|
|
88
|
+
* Returns the resulting bank plus whether a NEW entry was created and the id
|
|
89
|
+
* of the affected entry.
|
|
90
|
+
*/
|
|
91
|
+
export function upsertExperience(bank, entry) {
|
|
92
|
+
const lastUpdated = new Date().toISOString();
|
|
93
|
+
const existing = entry.id
|
|
94
|
+
? bank.entries.find((e) => e.id === entry.id)
|
|
95
|
+
: bank.entries.find((e) => e.pattern === entry.pattern && e.dimension === entry.dimension);
|
|
96
|
+
if (existing) {
|
|
97
|
+
const updated = {
|
|
98
|
+
...existing,
|
|
99
|
+
hitCount: (existing.hitCount ?? 0) + 1,
|
|
100
|
+
lastHitAt: lastUpdated,
|
|
101
|
+
};
|
|
102
|
+
return {
|
|
103
|
+
bank: {
|
|
104
|
+
...bank,
|
|
105
|
+
entries: bank.entries.map((e) => (e.id === existing.id ? updated : e)),
|
|
106
|
+
lastUpdated,
|
|
107
|
+
totalHits: (bank.totalHits ?? 0) + 1,
|
|
108
|
+
},
|
|
109
|
+
added: false,
|
|
110
|
+
entryId: existing.id,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
// Add new entry
|
|
114
|
+
const id = entry.id || `exp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
115
|
+
const newEntry = {
|
|
116
|
+
id,
|
|
117
|
+
timestamp: lastUpdated,
|
|
118
|
+
hitCount: 1,
|
|
119
|
+
lastHitAt: lastUpdated,
|
|
120
|
+
...entry,
|
|
121
|
+
};
|
|
122
|
+
return {
|
|
123
|
+
bank: {
|
|
124
|
+
...bank,
|
|
125
|
+
entries: [...bank.entries, newEntry],
|
|
126
|
+
lastUpdated,
|
|
127
|
+
totalHits: (bank.totalHits ?? 0) + 1,
|
|
128
|
+
},
|
|
129
|
+
added: true,
|
|
130
|
+
entryId: id,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/tools/quality-gate.ts — quality gate query & write tool.
|
|
3
|
+
*
|
|
4
|
+
* iterate_quality_gate — query the persisted quality certificate, or compute
|
|
5
|
+
* and persist a new one from review/validation data.
|
|
6
|
+
*
|
|
7
|
+
* Provides a machine-readable quality certificate for the current iteration.
|
|
8
|
+
*/
|
|
9
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
10
|
+
import { resolveProjectRootForExec } from "../config-loader.js";
|
|
11
|
+
import { readQualityGate, writeQualityGate, computeQualityGate } from "./quality-store.js";
|
|
12
|
+
/** Validate a single finding object; returns true when well-formed. */
|
|
13
|
+
function isValidFinding(raw) {
|
|
14
|
+
if (!raw || typeof raw !== 'object')
|
|
15
|
+
return false;
|
|
16
|
+
const f = raw;
|
|
17
|
+
return (typeof f.dimension === 'string' &&
|
|
18
|
+
typeof f.severity === 'string' &&
|
|
19
|
+
typeof f.file === 'string' &&
|
|
20
|
+
(f.line === undefined || typeof f.line === 'number'));
|
|
21
|
+
}
|
|
22
|
+
/** Validate a single validation result; returns true when well-formed. */
|
|
23
|
+
function isValidValidationResult(raw) {
|
|
24
|
+
if (!raw || typeof raw !== 'object')
|
|
25
|
+
return false;
|
|
26
|
+
const r = raw;
|
|
27
|
+
return typeof r.command === 'string' && typeof r.exitCode === 'number' && Number.isFinite(r.exitCode);
|
|
28
|
+
}
|
|
29
|
+
/** Sanitize a caller-supplied per-dimension number map. */
|
|
30
|
+
function sanitizeNumberMap(raw) {
|
|
31
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
32
|
+
return undefined;
|
|
33
|
+
const out = {};
|
|
34
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
35
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= 0)
|
|
36
|
+
out[key] = value;
|
|
37
|
+
}
|
|
38
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
39
|
+
}
|
|
40
|
+
/** Sanitize a caller-supplied per-dimension round series map. */
|
|
41
|
+
function sanitizeRoundSeries(raw) {
|
|
42
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
43
|
+
return undefined;
|
|
44
|
+
const out = {};
|
|
45
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
46
|
+
if (Array.isArray(value)) {
|
|
47
|
+
const series = value
|
|
48
|
+
.filter((n) => typeof n === 'number' && Number.isFinite(n) && n >= 0);
|
|
49
|
+
if (series.length > 0)
|
|
50
|
+
out[key] = series;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Register the `iterate_quality_gate` tool.
|
|
57
|
+
* Reads the persisted quality certificate, or computes + persists a new one.
|
|
58
|
+
*/
|
|
59
|
+
export function registerQualityGateTool(ctx) {
|
|
60
|
+
ctx.tools.register(defineTool({
|
|
61
|
+
name: 'iterate_quality_gate',
|
|
62
|
+
description: 'Query or write the quality gate status: dimension convergence rates, verification pass rates, ' +
|
|
63
|
+
'and overall PASS/FAIL status. ' +
|
|
64
|
+
'Operation "read" (default) returns the persisted machine-readable quality certificate. ' +
|
|
65
|
+
'Operation "compute" computes a fresh snapshot from this round\'s findings/validation results, ' +
|
|
66
|
+
'persists it to .iterate/quality-gate.json, and returns it.',
|
|
67
|
+
parameters: {
|
|
68
|
+
operation: {
|
|
69
|
+
type: 'string',
|
|
70
|
+
description: 'Operation: read (load persisted certificate) or compute (recompute + persist). Default: read.',
|
|
71
|
+
enum: ['read', 'compute'],
|
|
72
|
+
},
|
|
73
|
+
dimensions: {
|
|
74
|
+
type: 'array',
|
|
75
|
+
items: { type: 'string' },
|
|
76
|
+
description: 'Dimensions to gate (required for compute).',
|
|
77
|
+
},
|
|
78
|
+
findings: {
|
|
79
|
+
type: 'json',
|
|
80
|
+
description: 'Findings array (required for compute). Each item: { dimension, severity (critical|high|medium|low), file, line? }.',
|
|
81
|
+
},
|
|
82
|
+
validationResults: {
|
|
83
|
+
type: 'json',
|
|
84
|
+
description: 'Validation results array (optional for compute). Each item: { command, exitCode }.',
|
|
85
|
+
},
|
|
86
|
+
findingsByRound: {
|
|
87
|
+
type: 'json',
|
|
88
|
+
description: 'Optional per-dimension NEW-finding counts across rounds (latest last) — used to compute real convergence rates. ' +
|
|
89
|
+
'Example: { "correctness": [5, 2, 0] }.',
|
|
90
|
+
},
|
|
91
|
+
fixedByDimension: {
|
|
92
|
+
type: 'json',
|
|
93
|
+
description: 'Optional per-dimension count of fixed findings, e.g. { "correctness": 3 }.',
|
|
94
|
+
},
|
|
95
|
+
path: {
|
|
96
|
+
type: 'string',
|
|
97
|
+
description: 'Project root directory (default: current working directory).',
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
output: {
|
|
101
|
+
schema: {
|
|
102
|
+
type: 'object',
|
|
103
|
+
additionalProperties: false,
|
|
104
|
+
properties: {
|
|
105
|
+
ok: { type: 'boolean', required: true },
|
|
106
|
+
kind: { type: 'string' },
|
|
107
|
+
operation: { type: 'string' },
|
|
108
|
+
snapshot: { type: 'json' },
|
|
109
|
+
error: { type: 'string' },
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
render: (_args, value) => {
|
|
113
|
+
if (!value.ok)
|
|
114
|
+
return [{ type: 'text', text: `quality gate query failed: ${value.error}` }];
|
|
115
|
+
const operation = typeof value.operation === 'string' ? value.operation : 'read';
|
|
116
|
+
const snapshot = value.snapshot;
|
|
117
|
+
if (!snapshot)
|
|
118
|
+
return [{ type: 'text', text: 'No quality gate data available.' }];
|
|
119
|
+
const statusEmoji = snapshot.overallStatus === 'pass' ? '✓' : snapshot.overallStatus === 'fail' ? '✗' : '○';
|
|
120
|
+
const lines = [
|
|
121
|
+
`${statusEmoji} Quality Gate: ${snapshot.overallStatus.toUpperCase()} (score: ${snapshot.overallScore})`,
|
|
122
|
+
`Verification: ${snapshot.passedChecks}/${snapshot.totalChecks} passed (${snapshot.verificationPassRate}%)`,
|
|
123
|
+
`Findings: ${snapshot.totalFindings} total (${snapshot.criticalCount} critical, ${snapshot.highCount} high, ${snapshot.mediumCount} medium, ${snapshot.lowCount} low)`,
|
|
124
|
+
'',
|
|
125
|
+
'Dimension Breakdown:',
|
|
126
|
+
...snapshot.dimensions.map((d) => {
|
|
127
|
+
const dimStatus = d.status === 'pass' ? '✓' : d.status === 'warn' ? '!' : '✗';
|
|
128
|
+
return ` ${dimStatus} ${d.dimension}: score=${d.score}, convergence=${d.convergenceRate}%, findings=${d.findingsCount}, fixed=${d.fixedCount}`;
|
|
129
|
+
}),
|
|
130
|
+
];
|
|
131
|
+
if (snapshot.failReason) {
|
|
132
|
+
lines.push('', `Fail Reason: ${snapshot.failReason}`);
|
|
133
|
+
}
|
|
134
|
+
if (operation === 'compute') {
|
|
135
|
+
lines.push('', 'Quality gate snapshot computed and persisted.');
|
|
136
|
+
}
|
|
137
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
async execute(args, exec) {
|
|
141
|
+
const resolved = resolveProjectRootForExec(exec, args.path);
|
|
142
|
+
if (!resolved.ok)
|
|
143
|
+
return { ok: false, kind: 'quality_gate', error: resolved.reason };
|
|
144
|
+
const projectRoot = resolved.root;
|
|
145
|
+
const operation = typeof args.operation === 'string' ? args.operation : 'read';
|
|
146
|
+
if (operation === 'compute') {
|
|
147
|
+
const dimensions = Array.isArray(args.dimensions)
|
|
148
|
+
? args.dimensions.filter((d) => typeof d === 'string' && d.length > 0)
|
|
149
|
+
: [];
|
|
150
|
+
const findings = Array.isArray(args.findings) ? args.findings.filter(isValidFinding) : [];
|
|
151
|
+
const validationResults = Array.isArray(args.validationResults)
|
|
152
|
+
? args.validationResults.filter(isValidValidationResult)
|
|
153
|
+
: undefined;
|
|
154
|
+
const findingsByRound = sanitizeRoundSeries(args.findingsByRound);
|
|
155
|
+
const fixedByDimension = sanitizeNumberMap(args.fixedByDimension);
|
|
156
|
+
const snapshot = computeQualityGate({
|
|
157
|
+
dimensions,
|
|
158
|
+
findings,
|
|
159
|
+
validationResults,
|
|
160
|
+
findingsByRound,
|
|
161
|
+
fixedByDimension,
|
|
162
|
+
});
|
|
163
|
+
writeQualityGate(projectRoot, snapshot);
|
|
164
|
+
return {
|
|
165
|
+
ok: true,
|
|
166
|
+
kind: 'quality_gate',
|
|
167
|
+
operation: 'compute',
|
|
168
|
+
snapshot: snapshot,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
const snapshot = readQualityGate(projectRoot);
|
|
172
|
+
return {
|
|
173
|
+
ok: true,
|
|
174
|
+
kind: 'quality_gate',
|
|
175
|
+
operation: 'read',
|
|
176
|
+
snapshot: snapshot,
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
}));
|
|
180
|
+
}
|