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.
Files changed (75) hide show
  1. package/README.md +64 -20
  2. package/README.zh-CN.md +53 -17
  3. package/dist/approval-gate.js +143 -23
  4. package/dist/atomic-fs.js +148 -17
  5. package/dist/config-loader.js +509 -34
  6. package/dist/config-write.js +83 -22
  7. package/dist/evidence.js +141 -87
  8. package/dist/file-lock.js +165 -0
  9. package/dist/index.js +54 -28
  10. package/dist/jobs.js +56 -11
  11. package/dist/live.js +52 -13
  12. package/dist/meta-review.js +132 -28
  13. package/dist/method-scope.js +31 -8
  14. package/dist/review-scope.js +32 -9
  15. package/dist/review.js +96 -17
  16. package/dist/session-hooks.js +55 -13
  17. package/dist/skill-prompt.js +417 -67
  18. package/dist/tools/checkpoint.js +85 -19
  19. package/dist/tools/config.js +44 -2
  20. package/dist/tools/context.js +22 -5
  21. package/dist/tools/decision-log.js +122 -27
  22. package/dist/tools/defense-events.js +90 -31
  23. package/dist/tools/defense-store.js +28 -3
  24. package/dist/tools/experience-bank.js +108 -43
  25. package/dist/tools/experience-store.js +54 -4
  26. package/dist/tools/fix.js +347 -119
  27. package/dist/tools/history.js +68 -14
  28. package/dist/tools/present.js +80 -0
  29. package/dist/tools/prune.js +156 -53
  30. package/dist/tools/quality-gate.js +52 -2
  31. package/dist/tools/quality-store.js +17 -1
  32. package/dist/tools/review.js +158 -7
  33. package/dist/tools/transcript.js +173 -36
  34. package/dist/tools/triage.js +67 -12
  35. package/dist/tools/validate.js +115 -24
  36. package/dist/transcript.js +267 -4
  37. package/lib/client.js +945 -181
  38. package/lib/parse.js +760 -57
  39. package/package.json +15 -12
  40. package/src/approval-gate.ts +160 -23
  41. package/src/atomic-fs.ts +152 -17
  42. package/src/client/index.ts +600 -129
  43. package/src/config-loader.ts +542 -35
  44. package/src/config-write.ts +108 -26
  45. package/src/evidence.ts +194 -92
  46. package/src/file-lock.ts +184 -0
  47. package/src/index.ts +53 -29
  48. package/src/jobs.ts +63 -12
  49. package/src/live.ts +48 -11
  50. package/src/meta-review.ts +151 -34
  51. package/src/method-scope.ts +31 -8
  52. package/src/review-scope.ts +33 -10
  53. package/src/review.ts +96 -17
  54. package/src/session-hooks.ts +54 -12
  55. package/src/skill-prompt.ts +417 -67
  56. package/src/tools/checkpoint.ts +81 -19
  57. package/src/tools/config.ts +44 -2
  58. package/src/tools/context.ts +25 -5
  59. package/src/tools/decision-log.ts +127 -22
  60. package/src/tools/defense-events.ts +87 -31
  61. package/src/tools/defense-store.ts +30 -3
  62. package/src/tools/experience-bank.ts +112 -44
  63. package/src/tools/experience-store.ts +54 -5
  64. package/src/tools/fix.ts +258 -20
  65. package/src/tools/history.ts +74 -15
  66. package/src/tools/present.ts +89 -0
  67. package/src/tools/prune.ts +160 -51
  68. package/src/tools/quality-gate.ts +57 -2
  69. package/src/tools/quality-store.ts +18 -1
  70. package/src/tools/review.ts +155 -6
  71. package/src/tools/transcript.ts +178 -36
  72. package/src/tools/triage.ts +70 -13
  73. package/src/tools/validate.ts +138 -32
  74. package/src/transcript.ts +279 -4
  75. 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
- /** Clamp a caller-supplied limit to a sane range. */
29
- function clampLimit(limit: number | undefined): number {
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
- if (args.line !== undefined && (typeof args.line !== 'number' || !Number.isInteger(args.line) || args.line < 0)) {
77
- errors.push('line must be a non-negative integer when present')
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
- `Defense Events (${value.count} total):`,
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
- const result = clearDefenseEvents(projectRoot)
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
- const stream = readDefenseEvents(projectRoot)
282
- const next = addDefenseEvent(stream, {
283
- round: args.round as number,
284
- type: args.type as DefenseEventType,
285
- description: args.description as string,
286
- defense: args.defense as string,
287
- outcome: args.outcome as string,
288
- severity: args.severity as DefenseEvent['severity'],
289
- ...(typeof args.file === 'string' && args.file.length > 0 ? { file: args.file } : {}),
290
- ...(typeof args.line === 'number' ? { line: args.line } : {}),
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 : new Date().toISOString(),
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
- ...(typeof e.line === 'number' && Number.isFinite(e.line) && e.line >= 0 ? { line: e.line } : {}),
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
- ...event,
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 { readExperienceBank, writeExperienceBank, searchExperienceEntries, upsertExperience, removeExperience } from './experience-store.ts'
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
- /** Clamp a caller-supplied limit to a sane range. */
20
- function clampLimit(limit: number | undefined): number {
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: 'Experience ID (for get operation, or to update a specific entry via add).',
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
- const bank = readExperienceBank(projectRoot)
207
- const { bank: next, added, entryId } = upsertExperience(bank, normalizeExperienceInput(raw as Record<string, unknown>))
208
- const write = writeExperienceBank(projectRoot, next)
209
- if (!write.ok) {
210
- return { ok: false, kind: 'experience', operation: 'add', error: write.error }
211
- }
212
- const entry = next.entries.find((e) => e.id === entryId)
213
- return {
214
- ok: true,
215
- kind: 'experience',
216
- operation: 'add',
217
- added,
218
- count: next.entries.length,
219
- entry: entry as unknown as JsonValue,
220
- totalHits: next.totalHits,
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
- const bank = readExperienceBank(projectRoot)
235
- const { bank: next, removed } = removeExperience(bank, id)
236
- if (!removed) {
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: false,
294
+ ok: true,
239
295
  kind: 'experience',
240
296
  operation: 'remove',
241
- error: `Experience not found: ${id}`,
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
- const bank = readExperienceBank(projectRoot)
258
-
259
- if (operation === 'get' && typeof args.id === 'string') {
260
- const entry = bank.entries.find((e) => e.id === args.id)
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: ${args.id}` }
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' && typeof args.query === 'string') {
275
- const entries = searchExperienceEntries(bank.entries, args.query, {
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 = typeof e.hitCount === 'number' && Number.isFinite(e.hitCount) ? e.hitCount : 0
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
- typeof parsed.totalHits === 'number' && Number.isFinite(parsed.totalHits) ? parsed.totalHits : 0,
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
- // `id`/`timestamp`/`hitCount`/`lastHitAt` always win — a hostile or
218
- // malformed input can never forge its own identity or hit count.
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,