iterate-plugin 3.5.7 → 3.7.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 (79) hide show
  1. package/README.md +97 -22
  2. package/README.zh-CN.md +79 -19
  3. package/dist/approval-gate.js +143 -23
  4. package/dist/atomic-fs.js +148 -17
  5. package/dist/config-loader.js +516 -35
  6. package/dist/config-write.js +83 -22
  7. package/dist/evidence.js +141 -87
  8. package/dist/file-lock.js +193 -0
  9. package/dist/index.js +61 -28
  10. package/dist/jobs.js +56 -11
  11. package/dist/live.js +54 -14
  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 +61 -14
  17. package/dist/skill-prompt.js +396 -66
  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 +106 -28
  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 +150 -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/dist/working-directory.js +114 -0
  38. package/lib/client.js +1912 -512
  39. package/lib/parse.js +1001 -103
  40. package/package.json +16 -9
  41. package/src/approval-gate.ts +160 -23
  42. package/src/atomic-fs.ts +152 -17
  43. package/src/client/i18n.ts +517 -0
  44. package/src/client/index.ts +1031 -369
  45. package/src/client/session-cache.ts +54 -0
  46. package/src/config-loader.ts +549 -36
  47. package/src/config-write.ts +108 -26
  48. package/src/evidence.ts +194 -92
  49. package/src/file-lock.ts +211 -0
  50. package/src/index.ts +60 -29
  51. package/src/jobs.ts +63 -12
  52. package/src/live.ts +50 -12
  53. package/src/meta-review.ts +151 -34
  54. package/src/method-scope.ts +31 -8
  55. package/src/review-scope.ts +33 -10
  56. package/src/review.ts +96 -17
  57. package/src/session-hooks.ts +60 -13
  58. package/src/skill-prompt.ts +396 -66
  59. package/src/tools/checkpoint.ts +81 -19
  60. package/src/tools/config.ts +44 -2
  61. package/src/tools/context.ts +25 -5
  62. package/src/tools/decision-log.ts +114 -23
  63. package/src/tools/defense-events.ts +87 -31
  64. package/src/tools/defense-store.ts +30 -3
  65. package/src/tools/experience-bank.ts +112 -44
  66. package/src/tools/experience-store.ts +54 -5
  67. package/src/tools/fix.ts +258 -20
  68. package/src/tools/history.ts +74 -15
  69. package/src/tools/present.ts +89 -0
  70. package/src/tools/prune.ts +154 -51
  71. package/src/tools/quality-gate.ts +57 -2
  72. package/src/tools/quality-store.ts +18 -1
  73. package/src/tools/review.ts +155 -6
  74. package/src/tools/transcript.ts +178 -36
  75. package/src/tools/triage.ts +70 -13
  76. package/src/tools/validate.ts +138 -32
  77. package/src/transcript.ts +279 -4
  78. package/src/types.ts +29 -1
  79. package/src/working-directory.ts +126 -0
@@ -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
- /** Clamp a caller-supplied limit to a sane range. */
23
- function clampLimit(limit) {
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
- if (args.line !== undefined && (typeof args.line !== 'number' || !Number.isInteger(args.line) || args.line < 0)) {
60
- errors.push('line must be a non-negative integer when present');
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
- `Defense Events (${value.count} total):`,
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
- const result = clearDefenseEvents(projectRoot);
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
- const stream = readDefenseEvents(projectRoot);
251
- const next = addDefenseEvent(stream, {
252
- round: args.round,
253
- type: args.type,
254
- description: args.description,
255
- defense: args.defense,
256
- outcome: args.outcome,
257
- severity: args.severity,
258
- ...(typeof args.file === 'string' && args.file.length > 0 ? { file: args.file } : {}),
259
- ...(typeof args.line === 'number' ? { line: args.line } : {}),
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 : new Date().toISOString(),
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
- ...(typeof e.line === 'number' && Number.isFinite(e.line) && e.line >= 0 ? { line: e.line } : {}),
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
- ...event,
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 { readExperienceBank, writeExperienceBank, searchExperienceEntries, upsertExperience, removeExperience } from "./experience-store.js";
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
- /** Clamp a caller-supplied limit to a sane range. */
14
- function clampLimit(limit) {
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 operation, or to update a specific entry via add).',
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
- const bank = readExperienceBank(projectRoot);
198
- const { bank: next, added, entryId } = upsertExperience(bank, normalizeExperienceInput(raw));
199
- const write = writeExperienceBank(projectRoot, next);
200
- if (!write.ok) {
201
- return { ok: false, kind: 'experience', operation: 'add', error: write.error };
202
- }
203
- const entry = next.entries.find((e) => e.id === entryId);
204
- return {
205
- ok: true,
206
- kind: 'experience',
207
- operation: 'add',
208
- added,
209
- count: next.entries.length,
210
- entry: entry,
211
- totalHits: next.totalHits,
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
- const bank = readExperienceBank(projectRoot);
225
- const { bank: next, removed } = removeExperience(bank, id);
226
- if (!removed) {
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: false,
280
+ ok: true,
229
281
  kind: 'experience',
230
282
  operation: 'remove',
231
- error: `Experience not found: ${id}`,
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
- const bank = readExperienceBank(projectRoot);
247
- if (operation === 'get' && typeof args.id === 'string') {
248
- const entry = bank.entries.find((e) => e.id === args.id);
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: ${args.id}` };
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' && typeof args.query === 'string') {
262
- const entries = searchExperienceEntries(bank.entries, args.query, {
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 = typeof e.hitCount === 'number' && Number.isFinite(e.hitCount) ? e.hitCount : 0;
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
- totalHits: typeof parsed.totalHits === 'number' && Number.isFinite(parsed.totalHits) ? parsed.totalHits : 0,
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
- // `id`/`timestamp`/`hitCount`/`lastHitAt` always win — a hostile or
188
- // malformed input can never forge its own identity or hit count.
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,