iterate-plugin 3.5.7 → 3.6.0
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 +64 -20
- package/README.zh-CN.md +53 -17
- package/dist/approval-gate.js +143 -23
- package/dist/atomic-fs.js +148 -17
- package/dist/config-loader.js +509 -34
- package/dist/config-write.js +83 -22
- package/dist/evidence.js +141 -87
- package/dist/file-lock.js +165 -0
- package/dist/index.js +54 -28
- package/dist/jobs.js +56 -11
- package/dist/live.js +52 -13
- package/dist/meta-review.js +132 -28
- package/dist/method-scope.js +31 -8
- package/dist/review-scope.js +32 -9
- package/dist/review.js +96 -17
- package/dist/session-hooks.js +55 -13
- package/dist/skill-prompt.js +396 -66
- package/dist/tools/checkpoint.js +85 -19
- package/dist/tools/config.js +44 -2
- package/dist/tools/context.js +22 -5
- package/dist/tools/decision-log.js +111 -27
- package/dist/tools/defense-events.js +90 -31
- package/dist/tools/defense-store.js +28 -3
- package/dist/tools/experience-bank.js +108 -43
- package/dist/tools/experience-store.js +54 -4
- package/dist/tools/fix.js +347 -119
- package/dist/tools/history.js +68 -14
- package/dist/tools/present.js +80 -0
- package/dist/tools/prune.js +150 -53
- package/dist/tools/quality-gate.js +52 -2
- package/dist/tools/quality-store.js +17 -1
- package/dist/tools/review.js +158 -7
- package/dist/tools/transcript.js +173 -36
- package/dist/tools/triage.js +67 -12
- package/dist/tools/validate.js +115 -24
- package/dist/transcript.js +267 -4
- package/lib/client.js +945 -181
- package/lib/parse.js +760 -57
- package/package.json +14 -12
- package/src/approval-gate.ts +160 -23
- package/src/atomic-fs.ts +152 -17
- package/src/client/index.ts +600 -129
- package/src/config-loader.ts +542 -35
- package/src/config-write.ts +108 -26
- package/src/evidence.ts +194 -92
- package/src/file-lock.ts +184 -0
- package/src/index.ts +53 -29
- package/src/jobs.ts +63 -12
- package/src/live.ts +48 -11
- package/src/meta-review.ts +151 -34
- package/src/method-scope.ts +31 -8
- package/src/review-scope.ts +33 -10
- package/src/review.ts +96 -17
- package/src/session-hooks.ts +54 -12
- package/src/skill-prompt.ts +396 -66
- package/src/tools/checkpoint.ts +81 -19
- package/src/tools/config.ts +44 -2
- package/src/tools/context.ts +25 -5
- package/src/tools/decision-log.ts +115 -22
- package/src/tools/defense-events.ts +87 -31
- package/src/tools/defense-store.ts +30 -3
- package/src/tools/experience-bank.ts +112 -44
- package/src/tools/experience-store.ts +54 -5
- package/src/tools/fix.ts +258 -20
- package/src/tools/history.ts +74 -15
- package/src/tools/present.ts +89 -0
- package/src/tools/prune.ts +154 -51
- package/src/tools/quality-gate.ts +57 -2
- package/src/tools/quality-store.ts +18 -1
- package/src/tools/review.ts +155 -6
- package/src/tools/transcript.ts +178 -36
- package/src/tools/triage.ts +70 -13
- package/src/tools/validate.ts +138 -32
- package/src/transcript.ts +279 -4
- package/src/types.ts +29 -1
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
12
12
|
import { resolveProjectRootForExec, loadEffectiveConfig } from "../config-loader.js";
|
|
13
|
+
import { withProjectLock } from "../file-lock.js";
|
|
13
14
|
import { readDefenseEvents, writeDefenseEvents, addDefenseEvent, clearDefenseEvents } from "./defense-store.js";
|
|
15
|
+
import { renderedText } from "./present.js";
|
|
14
16
|
const DEFAULT_LIMIT = 50;
|
|
15
17
|
const MAX_LIMIT = 100;
|
|
16
18
|
const EVENT_TYPES = [
|
|
@@ -19,8 +21,13 @@ const EVENT_TYPES = [
|
|
|
19
21
|
'invariant_violated',
|
|
20
22
|
'assumption_falsified',
|
|
21
23
|
];
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Clamp a caller-supplied limit to a sane range.
|
|
26
|
+
* Anything that is not a positive integer falls back to the default — the
|
|
27
|
+
* parameter schema already rejects non-integers, but hand-constructed args
|
|
28
|
+
* (and future internal callers) still land here. Pure — exported for unit tests.
|
|
29
|
+
*/
|
|
30
|
+
export function clampLimit(limit) {
|
|
24
31
|
if (typeof limit !== 'number' || !Number.isInteger(limit) || limit <= 0) {
|
|
25
32
|
return DEFAULT_LIMIT;
|
|
26
33
|
}
|
|
@@ -56,8 +63,14 @@ function validateRecordInput(args) {
|
|
|
56
63
|
if (typeof args.outcome !== 'string' || !args.outcome.trim()) {
|
|
57
64
|
errors.push('outcome is required');
|
|
58
65
|
}
|
|
59
|
-
|
|
60
|
-
|
|
66
|
+
// Positions are 1-BASED. `line: 0` is not usable here even though the
|
|
67
|
+
// evidence/review layer treats 0 as "whole file": the renderer prints the
|
|
68
|
+
// position only for a truthy line (`file + ':' + line`), so a stored 0 would
|
|
69
|
+
// come back as a bare file path with no indication of what it meant — and
|
|
70
|
+
// the client-side normalizer already degrades 0 to null ("not a usable
|
|
71
|
+
// position"). Reject it at the door instead of persisting a silent 0.
|
|
72
|
+
if (args.line !== undefined && (typeof args.line !== 'number' || !Number.isInteger(args.line) || args.line < 1)) {
|
|
73
|
+
errors.push('line must be a positive integer (>= 1) when present');
|
|
61
74
|
}
|
|
62
75
|
const severity = args.severity;
|
|
63
76
|
if (severity !== 'critical' && severity !== 'high' && severity !== 'medium' && severity !== 'low') {
|
|
@@ -72,6 +85,34 @@ function validateRecordInput(args) {
|
|
|
72
85
|
export function registerDefenseEventsTool(ctx) {
|
|
73
86
|
ctx.tools.register(defineTool({
|
|
74
87
|
name: 'iterate_defense_events',
|
|
88
|
+
// Result card (#12): the `counts` render is a column of per-type lines —
|
|
89
|
+
// fold it into one headline ("Defense events: 3 recorded (rollback: 2)").
|
|
90
|
+
// Only `counts` gets a card; other operations keep the default view.
|
|
91
|
+
// Pure: parsed from the rendered text; an unreadable shape declines.
|
|
92
|
+
presentResult: (args, result) => {
|
|
93
|
+
const a = args;
|
|
94
|
+
if (a.operation !== 'counts')
|
|
95
|
+
return undefined;
|
|
96
|
+
const text = renderedText(result);
|
|
97
|
+
if (!text)
|
|
98
|
+
return undefined;
|
|
99
|
+
const lines = text.split('\n');
|
|
100
|
+
const totalLine = lines.find((l) => l.trim().startsWith('Total:'));
|
|
101
|
+
const totalMatch = totalLine ? /Total:\s*(\d+)/.exec(totalLine) : null;
|
|
102
|
+
const total = totalMatch ? Number(totalMatch[1]) : NaN;
|
|
103
|
+
if (!Number.isFinite(total))
|
|
104
|
+
return undefined;
|
|
105
|
+
// Per-type rows (" rollback: 2"), localized labels included; the zero
|
|
106
|
+
// rows are dropped so the headline only names types that actually fired.
|
|
107
|
+
const detail = lines
|
|
108
|
+
.filter((l) => /^\s{2,}\S.*:\s*\d+\s*$/.test(l))
|
|
109
|
+
.map((l) => l.trim())
|
|
110
|
+
.filter((l) => !l.startsWith('Total:') && !/:\s*0\s*$/.test(l));
|
|
111
|
+
return {
|
|
112
|
+
card: 'generic',
|
|
113
|
+
title: `Defense events: ${total} recorded` + (detail.length > 0 ? ` (${detail.join(', ')})` : ''),
|
|
114
|
+
};
|
|
115
|
+
},
|
|
75
116
|
// List/counts never write; `record` persists an event and `clear` removes
|
|
76
117
|
// the persisted stream, so only the read shapes may join a parallel
|
|
77
118
|
// dispatch group.
|
|
@@ -122,7 +163,7 @@ export function registerDefenseEventsTool(ctx) {
|
|
|
122
163
|
},
|
|
123
164
|
line: {
|
|
124
165
|
type: 'integer',
|
|
125
|
-
description: 'Optional line number context (record).',
|
|
166
|
+
description: 'Optional line number context (record): a 1-based line number (>= 1).',
|
|
126
167
|
},
|
|
127
168
|
language: {
|
|
128
169
|
type: 'string',
|
|
@@ -146,7 +187,8 @@ export function registerDefenseEventsTool(ctx) {
|
|
|
146
187
|
ok: { type: 'boolean', required: true },
|
|
147
188
|
kind: { type: 'string' },
|
|
148
189
|
operation: { type: 'string' },
|
|
149
|
-
count: { type: 'integer' },
|
|
190
|
+
count: { type: 'integer', description: 'list: number of events actually returned (after limit truncation).' },
|
|
191
|
+
total: { type: 'integer', description: 'list: total number of matching events before the limit was applied.' },
|
|
150
192
|
counted: { type: 'boolean', description: 'clear only: true when a persisted stream was removed.' },
|
|
151
193
|
events: { type: 'json' },
|
|
152
194
|
counts: { type: 'json' },
|
|
@@ -195,7 +237,10 @@ export function registerDefenseEventsTool(ctx) {
|
|
|
195
237
|
return [{ type: 'text', text: 'No defense events recorded.' }];
|
|
196
238
|
}
|
|
197
239
|
const lines = [
|
|
198
|
-
`
|
|
240
|
+
// `total` is the UNTRUNCATED match count; `count` is what survived
|
|
241
|
+
// the limit. Printing only one number (the old `count` as "total")
|
|
242
|
+
// hid the real stream size from the reader.
|
|
243
|
+
`Defense Events (showing ${value.count} of ${value.total}):`,
|
|
199
244
|
'',
|
|
200
245
|
...events.map((e) => {
|
|
201
246
|
const typeLabel = labelFor(e.type, language);
|
|
@@ -221,7 +266,10 @@ export function registerDefenseEventsTool(ctx) {
|
|
|
221
266
|
if (operation === 'clear') {
|
|
222
267
|
// Reset the persisted event stream so a fresh iteration does not carry
|
|
223
268
|
// stale defensive data (mirrors iterate_quality_gate clear / clearQualityGate).
|
|
224
|
-
|
|
269
|
+
// Same lock as `record`: clear is a read-modify-write against the same
|
|
270
|
+
// file, so an unlocked clear racing a locked record could resurrect
|
|
271
|
+
// the just-cleared events (the record's write lands after the rm).
|
|
272
|
+
const result = withProjectLock(projectRoot, 'defense-events', () => clearDefenseEvents(projectRoot));
|
|
225
273
|
if (!result.ok) {
|
|
226
274
|
return { ok: false, kind: 'defense_events', operation: 'clear', error: result.error };
|
|
227
275
|
}
|
|
@@ -247,30 +295,38 @@ export function registerDefenseEventsTool(ctx) {
|
|
|
247
295
|
error: `Invalid defense event: ${errors.join('; ')}`,
|
|
248
296
|
};
|
|
249
297
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
298
|
+
// The read→modify→write below is the exact cross-process race the
|
|
299
|
+
// decision log documents: two plugin processes reading the same
|
|
300
|
+
// stream concurrently each append their own event and the second
|
|
301
|
+
// write silently drops the first. Serialize the whole cycle with the
|
|
302
|
+
// shared advisory lock (fail-open on timeout is built into the
|
|
303
|
+
// helper — worst case is the pre-existing unlocked behavior).
|
|
304
|
+
return withProjectLock(projectRoot, 'defense-events', () => {
|
|
305
|
+
const stream = readDefenseEvents(projectRoot);
|
|
306
|
+
const next = addDefenseEvent(stream, {
|
|
307
|
+
round: args.round,
|
|
308
|
+
type: args.type,
|
|
309
|
+
description: args.description,
|
|
310
|
+
defense: args.defense,
|
|
311
|
+
outcome: args.outcome,
|
|
312
|
+
severity: args.severity,
|
|
313
|
+
...(typeof args.file === 'string' && args.file.length > 0 ? { file: args.file } : {}),
|
|
314
|
+
...(typeof args.line === 'number' ? { line: args.line } : {}),
|
|
315
|
+
});
|
|
316
|
+
const write = writeDefenseEvents(projectRoot, next);
|
|
317
|
+
if (!write.ok) {
|
|
318
|
+
return { ok: false, kind: 'defense_events', operation: 'record', error: write.error };
|
|
319
|
+
}
|
|
320
|
+
const event = next.events[next.events.length - 1];
|
|
321
|
+
return {
|
|
322
|
+
ok: true,
|
|
323
|
+
kind: 'defense_events',
|
|
324
|
+
operation: 'record',
|
|
325
|
+
language,
|
|
326
|
+
event: event,
|
|
327
|
+
counts: next.counts,
|
|
328
|
+
};
|
|
260
329
|
});
|
|
261
|
-
const write = writeDefenseEvents(projectRoot, next);
|
|
262
|
-
if (!write.ok) {
|
|
263
|
-
return { ok: false, kind: 'defense_events', operation: 'record', error: write.error };
|
|
264
|
-
}
|
|
265
|
-
const event = next.events[next.events.length - 1];
|
|
266
|
-
return {
|
|
267
|
-
ok: true,
|
|
268
|
-
kind: 'defense_events',
|
|
269
|
-
operation: 'record',
|
|
270
|
-
language,
|
|
271
|
-
event: event,
|
|
272
|
-
counts: next.counts,
|
|
273
|
-
};
|
|
274
330
|
}
|
|
275
331
|
const stream = readDefenseEvents(projectRoot);
|
|
276
332
|
if (operation === 'counts') {
|
|
@@ -300,7 +356,10 @@ export function registerDefenseEventsTool(ctx) {
|
|
|
300
356
|
kind: 'defense_events',
|
|
301
357
|
operation: 'list',
|
|
302
358
|
language,
|
|
359
|
+
// `count` = events actually returned (limit-truncated);
|
|
360
|
+
// `total` = every event matching the filters, before truncation.
|
|
303
361
|
count: Math.min(events.length, limit),
|
|
362
|
+
total: events.length,
|
|
304
363
|
events: events.slice(0, limit),
|
|
305
364
|
};
|
|
306
365
|
},
|
|
@@ -42,6 +42,15 @@ function emptyStream() {
|
|
|
42
42
|
}
|
|
43
43
|
/** Valid severity values (kept in sync with DefenseEvent). */
|
|
44
44
|
const VALID_SEVERITIES = new Set(['critical', 'high', 'medium', 'low']);
|
|
45
|
+
/**
|
|
46
|
+
* Sentinel timestamp for events persisted WITHOUT one. Reads must be
|
|
47
|
+
* deterministic: stamping `new Date()` here meant two reads of the same
|
|
48
|
+
* hand-edited file produced different JSON, and the next write persisted that
|
|
49
|
+
* read-time churn as real history. The epoch sentinel is stable across reads
|
|
50
|
+
* AND sorts as the oldest event (list output is newest-first), so a timestamp-less
|
|
51
|
+
* event degrades to "the beginning of time" instead of "just now".
|
|
52
|
+
*/
|
|
53
|
+
const MISSING_TIMESTAMP_SENTINEL = '1970-01-01T00:00:00.000Z';
|
|
45
54
|
/**
|
|
46
55
|
* Normalize one persisted event. Hand-edited files can carry events missing
|
|
47
56
|
* `timestamp`/`round`/`description`/`defense`/`outcome`/`severity` — readers
|
|
@@ -61,14 +70,18 @@ function normalizeEvent(raw, index) {
|
|
|
61
70
|
const derivedId = `def-${hashString(`${index}|${String(e.timestamp ?? '')}|${e.type}|${String(e.description ?? '')}|${String(e.defense ?? '')}`)}`;
|
|
62
71
|
return {
|
|
63
72
|
id: typeof e.id === 'string' && e.id ? e.id : derivedId,
|
|
64
|
-
timestamp: typeof e.timestamp === 'string' && e.timestamp ? e.timestamp :
|
|
73
|
+
timestamp: typeof e.timestamp === 'string' && e.timestamp ? e.timestamp : MISSING_TIMESTAMP_SENTINEL,
|
|
65
74
|
round: typeof e.round === 'number' && Number.isFinite(e.round) ? Math.floor(e.round) : 0,
|
|
66
75
|
type: e.type,
|
|
67
76
|
description: typeof e.description === 'string' ? e.description : '',
|
|
68
77
|
defense: typeof e.defense === 'string' ? e.defense : '',
|
|
69
78
|
outcome: typeof e.outcome === 'string' ? e.outcome : '',
|
|
70
79
|
...(typeof e.file === 'string' && e.file.length > 0 ? { file: e.file } : {}),
|
|
71
|
-
|
|
80
|
+
// Only 1-based lines are a usable position (see the record-side check in
|
|
81
|
+
// src/tools/defense-events.ts): a hand-edited `line: 0`/negative/non-integer
|
|
82
|
+
// is dropped rather than echoed back to a renderer that would print the
|
|
83
|
+
// file WITHOUT a position anyway (`line ? ':' + line : ''`).
|
|
84
|
+
...(typeof e.line === 'number' && Number.isInteger(e.line) && e.line >= 1 ? { line: e.line } : {}),
|
|
72
85
|
severity: VALID_SEVERITIES.has(String(e.severity)) ? e.severity : 'low',
|
|
73
86
|
};
|
|
74
87
|
}
|
|
@@ -92,6 +105,11 @@ export function readDefenseEvents(projectRoot) {
|
|
|
92
105
|
const counts = computeCounts(events);
|
|
93
106
|
return {
|
|
94
107
|
events,
|
|
108
|
+
// Stream-level fallback timestamp: display-only metadata — retention
|
|
109
|
+
// and sweeps rank per-EVENT `event.timestamp`, never `lastUpdated`, so
|
|
110
|
+
// a missing/corrupt value degrading to "now" cannot reorder or
|
|
111
|
+
// resurrect events. (Reaffirmed from the earlier review: comment only,
|
|
112
|
+
// behavior unchanged.)
|
|
95
113
|
lastUpdated: typeof parsed.lastUpdated === 'string' ? parsed.lastUpdated : emptyStream().lastUpdated,
|
|
96
114
|
counts,
|
|
97
115
|
};
|
|
@@ -145,11 +163,18 @@ export function clearDefenseEvents(projectRoot) {
|
|
|
145
163
|
}
|
|
146
164
|
/** Add a defense event to the stream. */
|
|
147
165
|
export function addDefenseEvent(stream, event) {
|
|
166
|
+
// Storage-side line check, mirroring the tool's record validation: a
|
|
167
|
+
// non-integer / < 1 line is not a usable position (the renderer prints the
|
|
168
|
+
// file WITHOUT a position for a falsy line), so it is dropped instead of
|
|
169
|
+
// being persisted as a silent 0.
|
|
170
|
+
const { line, ...rest } = event;
|
|
171
|
+
const usableLine = typeof line === 'number' && Number.isInteger(line) && line >= 1 ? line : undefined;
|
|
148
172
|
// Spread the caller-shaped event FIRST so the store-generated `id` and
|
|
149
173
|
// `timestamp` always win — a caller-supplied id/timestamp must never
|
|
150
174
|
// override the stream's own identity fields.
|
|
151
175
|
const newEvent = {
|
|
152
|
-
...
|
|
176
|
+
...rest,
|
|
177
|
+
...(usableLine !== undefined ? { line: usableLine } : {}),
|
|
153
178
|
id: `def-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
154
179
|
timestamp: new Date().toISOString(),
|
|
155
180
|
};
|
|
@@ -7,11 +7,18 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
9
9
|
import { resolveProjectRootForExec } from "../config-loader.js";
|
|
10
|
-
import {
|
|
10
|
+
import { withProjectLock } from "../file-lock.js";
|
|
11
|
+
import { readExperienceBank, writeExperienceBank, searchExperienceEntries, upsertExperience, removeExperience, isValidExperienceId, MAX_EXPERIENCE_ID_LENGTH } from "./experience-store.js";
|
|
12
|
+
import { resultHeadline } from "./present.js";
|
|
11
13
|
const DEFAULT_LIMIT = 50;
|
|
12
14
|
const MAX_LIMIT = 100;
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Clamp a caller-supplied limit to a sane range.
|
|
17
|
+
* Anything that is not a positive integer falls back to the default — the
|
|
18
|
+
* parameter schema already rejects non-integers, but hand-constructed args
|
|
19
|
+
* (and future internal callers) still land here. Pure — exported for unit tests.
|
|
20
|
+
*/
|
|
21
|
+
export function clampLimit(limit) {
|
|
15
22
|
if (typeof limit !== 'number' || !Number.isInteger(limit) || limit <= 0) {
|
|
16
23
|
return DEFAULT_LIMIT;
|
|
17
24
|
}
|
|
@@ -67,6 +74,13 @@ function normalizeExperienceInput(raw) {
|
|
|
67
74
|
export function registerExperienceBankTool(ctx) {
|
|
68
75
|
ctx.tools.register(defineTool({
|
|
69
76
|
name: 'iterate_experience',
|
|
77
|
+
// Result card (#12): each operation renders a self-describing first line
|
|
78
|
+
// ("Found 3 experience(s)…", "Recorded new experience: exp-…") — reuse it
|
|
79
|
+
// as the card title. Pure: rendered result only; failures decline.
|
|
80
|
+
presentResult: (_args, result) => {
|
|
81
|
+
const title = resultHeadline(result);
|
|
82
|
+
return title ? { card: 'generic', title } : undefined;
|
|
83
|
+
},
|
|
70
84
|
// List/search/get never write; `add` upserts the bank and `remove`
|
|
71
85
|
// rewrites it → only the read shapes may join a parallel dispatch group.
|
|
72
86
|
isConcurrencySafe: (args) => {
|
|
@@ -101,7 +115,8 @@ export function registerExperienceBankTool(ctx) {
|
|
|
101
115
|
},
|
|
102
116
|
id: {
|
|
103
117
|
type: 'string',
|
|
104
|
-
description: 'Experience ID (for get
|
|
118
|
+
description: 'Experience ID (required for get and remove). For `add`, name the entry via `entry.id` ' +
|
|
119
|
+
'— a top-level `id` is not read on add.',
|
|
105
120
|
},
|
|
106
121
|
entry: {
|
|
107
122
|
type: 'json',
|
|
@@ -194,22 +209,44 @@ export function registerExperienceBankTool(ctx) {
|
|
|
194
209
|
error: `Invalid experience entry: ${errors.join('; ')}`,
|
|
195
210
|
};
|
|
196
211
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
212
|
+
// Serialize the read→modify→write cycle: two processes adding to the
|
|
213
|
+
// same bank concurrently would otherwise lose one of the entries
|
|
214
|
+
// (exactly the race the shared advisory lock exists for; fail-open
|
|
215
|
+
// on lock timeout is built into the helper).
|
|
216
|
+
return withProjectLock(projectRoot, 'experience-bank', () => {
|
|
217
|
+
const bank = readExperienceBank(projectRoot);
|
|
218
|
+
const input = normalizeExperienceInput(raw);
|
|
219
|
+
// A caller-supplied id may only NAME a new entry — reject ids that
|
|
220
|
+
// would not survive contact with disk/output (oversized or
|
|
221
|
+
// control-byte). An id that matches an EXISTING entry is an update
|
|
222
|
+
// and keeps working unchanged.
|
|
223
|
+
if (input.id !== undefined &&
|
|
224
|
+
!bank.entries.some((e) => e.id === input.id) &&
|
|
225
|
+
!isValidExperienceId(input.id)) {
|
|
226
|
+
return {
|
|
227
|
+
ok: false,
|
|
228
|
+
kind: 'experience',
|
|
229
|
+
operation: 'add',
|
|
230
|
+
error: `entry.id must be a printable string of at most ${MAX_EXPERIENCE_ID_LENGTH} characters ` +
|
|
231
|
+
'(non-empty, no control characters) when it does not match an existing entry',
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const { bank: next, added, entryId } = upsertExperience(bank, input);
|
|
235
|
+
const write = writeExperienceBank(projectRoot, next);
|
|
236
|
+
if (!write.ok) {
|
|
237
|
+
return { ok: false, kind: 'experience', operation: 'add', error: write.error };
|
|
238
|
+
}
|
|
239
|
+
const entry = next.entries.find((e) => e.id === entryId);
|
|
240
|
+
return {
|
|
241
|
+
ok: true,
|
|
242
|
+
kind: 'experience',
|
|
243
|
+
operation: 'add',
|
|
244
|
+
added,
|
|
245
|
+
count: next.entries.length,
|
|
246
|
+
entry: entry,
|
|
247
|
+
totalHits: next.totalHits,
|
|
248
|
+
};
|
|
249
|
+
});
|
|
213
250
|
}
|
|
214
251
|
if (operation === 'remove') {
|
|
215
252
|
const id = typeof args.id === 'string' && args.id ? args.id : '';
|
|
@@ -221,33 +258,50 @@ export function registerExperienceBankTool(ctx) {
|
|
|
221
258
|
error: 'id is required for remove',
|
|
222
259
|
};
|
|
223
260
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
261
|
+
// Same read→modify→write hazard as `add` — hold the shared lock for
|
|
262
|
+
// the whole cycle so a concurrent remove cannot resurrect a deleted
|
|
263
|
+
// entry.
|
|
264
|
+
return withProjectLock(projectRoot, 'experience-bank', () => {
|
|
265
|
+
const bank = readExperienceBank(projectRoot);
|
|
266
|
+
const { bank: next, removed } = removeExperience(bank, id);
|
|
267
|
+
if (!removed) {
|
|
268
|
+
return {
|
|
269
|
+
ok: false,
|
|
270
|
+
kind: 'experience',
|
|
271
|
+
operation: 'remove',
|
|
272
|
+
error: `Experience not found: ${id}`,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
const write = writeExperienceBank(projectRoot, next);
|
|
276
|
+
if (!write.ok) {
|
|
277
|
+
return { ok: false, kind: 'experience', operation: 'remove', error: write.error };
|
|
278
|
+
}
|
|
227
279
|
return {
|
|
228
|
-
ok:
|
|
280
|
+
ok: true,
|
|
229
281
|
kind: 'experience',
|
|
230
282
|
operation: 'remove',
|
|
231
|
-
|
|
283
|
+
count: next.entries.length,
|
|
284
|
+
totalHits: next.totalHits,
|
|
232
285
|
};
|
|
233
|
-
}
|
|
234
|
-
const write = writeExperienceBank(projectRoot, next);
|
|
235
|
-
if (!write.ok) {
|
|
236
|
-
return { ok: false, kind: 'experience', operation: 'remove', error: write.error };
|
|
237
|
-
}
|
|
238
|
-
return {
|
|
239
|
-
ok: true,
|
|
240
|
-
kind: 'experience',
|
|
241
|
-
operation: 'remove',
|
|
242
|
-
count: next.entries.length,
|
|
243
|
-
totalHits: next.totalHits,
|
|
244
|
-
};
|
|
286
|
+
});
|
|
245
287
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
288
|
+
// `get`/`search` require their argument: silently falling through to
|
|
289
|
+
// `list` would answer a get with a DIFFERENT operation's payload
|
|
290
|
+
// (ok:true + entries) — mirror the `remove` contract and error instead.
|
|
291
|
+
if (operation === 'get') {
|
|
292
|
+
const id = typeof args.id === 'string' && args.id ? args.id : '';
|
|
293
|
+
if (!id) {
|
|
294
|
+
return {
|
|
295
|
+
ok: false,
|
|
296
|
+
kind: 'experience',
|
|
297
|
+
operation: 'get',
|
|
298
|
+
error: 'id is required for get',
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
const bank = readExperienceBank(projectRoot);
|
|
302
|
+
const entry = bank.entries.find((e) => e.id === id);
|
|
249
303
|
if (!entry) {
|
|
250
|
-
return { ok: false, kind: 'experience', error: `Experience not found: ${
|
|
304
|
+
return { ok: false, kind: 'experience', operation: 'get', error: `Experience not found: ${id}` };
|
|
251
305
|
}
|
|
252
306
|
return {
|
|
253
307
|
ok: true,
|
|
@@ -258,8 +312,18 @@ export function registerExperienceBankTool(ctx) {
|
|
|
258
312
|
totalHits: bank.totalHits ?? 0,
|
|
259
313
|
};
|
|
260
314
|
}
|
|
261
|
-
if (operation === 'search'
|
|
262
|
-
const
|
|
315
|
+
if (operation === 'search') {
|
|
316
|
+
const query = typeof args.query === 'string' && args.query ? args.query : '';
|
|
317
|
+
if (!query) {
|
|
318
|
+
return {
|
|
319
|
+
ok: false,
|
|
320
|
+
kind: 'experience',
|
|
321
|
+
operation: 'search',
|
|
322
|
+
error: 'query is required for search',
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
const bank = readExperienceBank(projectRoot);
|
|
326
|
+
const entries = searchExperienceEntries(bank.entries, query, {
|
|
263
327
|
dimension: typeof args.dimension === 'string' ? args.dimension : undefined,
|
|
264
328
|
tags: Array.isArray(args.tags) ? args.tags : undefined,
|
|
265
329
|
}).slice(0, limit);
|
|
@@ -273,6 +337,7 @@ export function registerExperienceBankTool(ctx) {
|
|
|
273
337
|
};
|
|
274
338
|
}
|
|
275
339
|
// Default: list with optional filters
|
|
340
|
+
const bank = readExperienceBank(projectRoot);
|
|
276
341
|
let entries = bank.entries;
|
|
277
342
|
if (typeof args.dimension === 'string' && args.dimension) {
|
|
278
343
|
entries = entries.filter((e) => e.dimension === args.dimension);
|
|
@@ -26,6 +26,41 @@ function stringArray(v) {
|
|
|
26
26
|
return [];
|
|
27
27
|
return v.filter((x) => typeof x === 'string');
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Coerce a persisted hit counter to a safe non-negative integer. Hand-edited
|
|
31
|
+
* banks can carry fractional (4.5), negative (-1), non-finite (Infinity) or
|
|
32
|
+
* non-numeric ('x') values — the tool output schema declares these fields as
|
|
33
|
+
* `integer`, so any of them reaching the result would throw ToolOutputError
|
|
34
|
+
* at runtime. Floors first, then clamps at 0; anything non-finite → 0.
|
|
35
|
+
*/
|
|
36
|
+
function toHitCount(v) {
|
|
37
|
+
if (typeof v !== 'number' || !Number.isFinite(v))
|
|
38
|
+
return 0;
|
|
39
|
+
return Math.max(0, Math.floor(v));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Longest caller-supplied experience id we will persist, and the characters
|
|
43
|
+
* it may contain. Ids surface verbatim in tool output and on disk, so a
|
|
44
|
+
* hostile "add" must not be able to store a 100 KB blob or control bytes
|
|
45
|
+
* (NUL/newline) under the id field. Printable characters only (no C0/C1
|
|
46
|
+
* controls); unicode text is fine.
|
|
47
|
+
*/
|
|
48
|
+
export const MAX_EXPERIENCE_ID_LENGTH = 200;
|
|
49
|
+
/**
|
|
50
|
+
* Whether a caller-supplied id may identify a NEW experience entry.
|
|
51
|
+
* Validated at the tool layer BEFORE the entry reaches `upsertExperience`
|
|
52
|
+
* (see src/tools/experience-bank.ts): non-empty, ≤ 200 chars, no control
|
|
53
|
+
* characters. Pure — exported for unit tests.
|
|
54
|
+
*/
|
|
55
|
+
export function isValidExperienceId(id) {
|
|
56
|
+
if (typeof id !== 'string')
|
|
57
|
+
return false;
|
|
58
|
+
if (id.length === 0 || id.length > MAX_EXPERIENCE_ID_LENGTH)
|
|
59
|
+
return false;
|
|
60
|
+
// Control characters (NUL, newline, …) would corrupt the id wherever it is
|
|
61
|
+
// echoed (JSON output, file names in renderers) — reject them outright.
|
|
62
|
+
return !/[\u0000-\u001f\u007f]/.test(id);
|
|
63
|
+
}
|
|
29
64
|
/**
|
|
30
65
|
* Normalize one persisted experience entry. A hand-edited bank entry can be
|
|
31
66
|
* missing `files`/`tags` arrays (or `hitCount`) — consumers rendering/searching
|
|
@@ -45,7 +80,7 @@ function normalizeEntry(raw, index) {
|
|
|
45
80
|
const id = typeof e.id === 'string' && e.id ? e.id : derivedId;
|
|
46
81
|
if (!pattern && !dimension)
|
|
47
82
|
return null;
|
|
48
|
-
const hitCount =
|
|
83
|
+
const hitCount = toHitCount(e.hitCount);
|
|
49
84
|
return {
|
|
50
85
|
id,
|
|
51
86
|
timestamp: typeof e.timestamp === 'string' ? e.timestamp : new Date().toISOString(),
|
|
@@ -73,8 +108,16 @@ export function readExperienceBank(projectRoot) {
|
|
|
73
108
|
.filter((e) => e !== null);
|
|
74
109
|
return {
|
|
75
110
|
entries,
|
|
111
|
+
// Stream-level fallback timestamp: display-only metadata — the entry
|
|
112
|
+
// sweep ranks per-ENTRY `entry.timestamp`, never `lastUpdated`, so a
|
|
113
|
+
// missing/corrupt value degrading to "now" cannot reorder or rescue
|
|
114
|
+
// entries. (Reaffirmed from the earlier review: comment only, behavior
|
|
115
|
+
// unchanged.)
|
|
76
116
|
lastUpdated: typeof parsed.lastUpdated === 'string' ? parsed.lastUpdated : emptyBank().lastUpdated,
|
|
77
|
-
|
|
117
|
+
// Same contract as per-entry hitCount: a hand-edited totalHits must be
|
|
118
|
+
// a finite non-negative integer before it reaches the `integer` output
|
|
119
|
+
// schema (4.5 → 4, -1 → 0, Infinity/NaN/'x' → 0).
|
|
120
|
+
totalHits: toHitCount(parsed.totalHits),
|
|
78
121
|
};
|
|
79
122
|
}
|
|
80
123
|
}
|
|
@@ -184,8 +227,15 @@ export function upsertExperience(bank, entry) {
|
|
|
184
227
|
};
|
|
185
228
|
}
|
|
186
229
|
// Add new entry. Spread the caller input FIRST so the store-generated
|
|
187
|
-
// `
|
|
188
|
-
//
|
|
230
|
+
// `timestamp`/`hitCount`/`lastHitAt` always win — a hostile or malformed
|
|
231
|
+
// input can never forge its hit metadata. A caller-supplied `id` IS honored
|
|
232
|
+
// here (it is the documented "update a specific entry via add"/custom-id
|
|
233
|
+
// contract), but only after the tool layer validated it with
|
|
234
|
+
// `isValidExperienceId` (non-empty, ≤ 200 chars, printable) — oversized or
|
|
235
|
+
// control-byte ids are rejected with a structured error before they reach
|
|
236
|
+
// this store (see src/tools/experience-bank.ts). Because an EXISTING id
|
|
237
|
+
// takes the update path above, a new entry can never collide with — or
|
|
238
|
+
// impersonate — an entry that is already in the bank.
|
|
189
239
|
const id = entry.id || `exp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
190
240
|
const newEntry = {
|
|
191
241
|
...entry,
|