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