iterate-plugin 3.5.3 → 3.5.4

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.
@@ -7,12 +7,22 @@
7
7
  *
8
8
  * - `allow` policy → the call runs.
9
9
  * - `deny` policy → the call is refused (fail-closed), surfaced as an
10
- * error to the model.
10
+ * error to the model. Since dsh 0.1.6-alpha.1 the
11
+ * denial carries a structured `info` (name
12
+ * `iterate-approval-gate`, code `APPROVAL_DENIED`,
13
+ * plus the human-readable reason) so durable
14
+ * projections can route it distinctly from a normal
15
+ * tool failure.
11
16
  * - `ask` policy → return `{ kind: 'ask', reason }`; dsh's own
12
17
  * scheduler routes it through the `approval` service
13
18
  * (see `@deepseek-ai/dsh-user-approval`), which
14
19
  * prompts the human and audits an approve/deny pair
15
20
  * on the session.
21
+ * - canceled → if the caller aborted the invocation before
22
+ * dispatch, return `{ kind: 'cancel' }` (the
23
+ * canonical 0.1.6-alpha.1 cancellation result) so a
24
+ * destructive call never runs (or asks) for a dead
25
+ * request.
16
26
  *
17
27
  * We deliberately do NOT also add `approved` flags inside the tool bodies:
18
28
  * the pre-execute waterfall consumes the human decision before the tool runs,
@@ -27,6 +37,13 @@
27
37
  */
28
38
  import { loadEffectiveConfig, resolveProjectRoot } from "./config-loader.js";
29
39
  import { decideApproval, isDestructiveIterateTool } from "./approval-gate.js";
40
+ /** Structured error identity attached to policy denials (dsh 0.1.6-alpha.1:
41
+ * `deny.info` lets durable projections / retry / replay distinguish an
42
+ * approval-policy denial from a tool-thrown failure). */
43
+ const DENY_INFO = {
44
+ name: 'iterate-approval-gate',
45
+ code: 'APPROVAL_DENIED',
46
+ };
30
47
  /**
31
48
  * Build the per-call approval decision for a tool execution.
32
49
  * Returns a dsh `PreToolDecision` so the caller can short-circuit the caller.
@@ -46,6 +63,20 @@ export function gateDecision(exec) {
46
63
  }
47
64
  if (!isDestructiveIterateTool(name))
48
65
  return { kind: 'allow' };
66
+ // dsh 0.1.6-alpha.1: if the caller already aborted this invocation before
67
+ // dispatch, there is nothing to gate — select the canonical cancellation
68
+ // result (`cancel`) rather than prompting a human for a dead call or running
69
+ // `next()` (which would allow) on an aborted one.
70
+ let signal;
71
+ try {
72
+ signal = exec.signal;
73
+ }
74
+ catch {
75
+ signal = undefined;
76
+ }
77
+ if (signal && typeof signal.aborted === 'boolean' && signal.aborted) {
78
+ return { kind: 'cancel' };
79
+ }
49
80
  // Resolve the project root (use the call's own `path` arg, else the agent's
50
81
  // session cwd) to read the effective observatory policy.
51
82
  let argPath;
@@ -74,8 +105,18 @@ export function gateDecision(exec) {
74
105
  // anything else (including a corrupt/missing `ask`) → 'ask'
75
106
  }
76
107
  const decision = decideApproval(exec, policy);
77
- if (decision.kind === 'deny')
78
- return { kind: 'deny', reason: decision.reason };
108
+ if (decision.kind === 'deny') {
109
+ return {
110
+ kind: 'deny',
111
+ reason: decision.reason,
112
+ info: {
113
+ ...DENY_INFO,
114
+ // ToolErrorInfo.reason (0.1.6-alpha.1): raw user-facing detail kept in
115
+ // durable state; the model-facing text still only sees `reason` above.
116
+ reason: decision.reason,
117
+ },
118
+ };
119
+ }
79
120
  if (decision.kind === 'ask')
80
121
  return { kind: 'ask', reason: decision.reason };
81
122
  return { kind: 'allow' };
@@ -106,7 +147,10 @@ export function registerSessionHooks(ctx) {
106
147
  // to allow, which would bypass consent — so return our ask decision.
107
148
  return Promise.resolve(decision);
108
149
  }
109
- if (decision.kind === 'deny') {
150
+ if (decision.kind === 'deny' || decision.kind === 'cancel') {
151
+ // deny: refuse the call. cancel: the caller already aborted this
152
+ // invocation — selecting the canonical cancellation result instead of
153
+ // letting `next()` allow it or prompting consent for a dead request.
110
154
  return Promise.resolve(decision);
111
155
  }
112
156
  return next();
@@ -94,10 +94,11 @@ for (let r = 1; r <= maxRounds; r++) {
94
94
  log('round ' + r + ' of ' + maxRounds + ' — finding NEW issues only')
95
95
  let agg = null
96
96
  let schemaInvalid = false
97
- let retries = 0
97
+ let attempts = 0
98
98
  do {
99
+ attempts += 1
99
100
  // Schema validation retry: on the 2nd+ pass, nudge reviewers toward strict JSON.
100
- const nudge = retries > 0
101
+ const nudge = attempts > 1
101
102
  ? '\\nSTRICT JSON REQUIRED: your previous output failed schema validation. Return ONLY a JSON object {"findings":[...]} where EVERY finding has dimension, file, line (non-negative integer; 0 = whole-file), severity (critical|high|medium|low), summary, failure_scenario, suggested_fix, is_atomic (boolean).'
102
103
  : ''
103
104
  const raw = await parallel(dims.map(dim => () => {
@@ -134,11 +135,15 @@ for (let r = 1; r <= maxRounds; r++) {
134
135
  schemaInvalid = agg && agg.schemaValidation && agg.schemaValidation.length > 0
135
136
  ? agg.schemaValidation[agg.schemaValidation.length - 1].valid === false
136
137
  : false
137
- if (schemaInvalid && retries < 2) {
138
- retries += 1
139
- log('retry ' + retries + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
138
+ if (schemaInvalid && attempts < 3) {
139
+ log('retry ' + attempts + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
140
140
  }
141
- } while (schemaInvalid && retries <= 2)
141
+ } while (schemaInvalid && attempts < 3)
142
+ if (schemaInvalid) {
143
+ // Bounded exit: never loop forever. Surface the failure so the run's
144
+ // final summary can explain WHY the round was inconclusive.
145
+ log('round ' + r + ' output STILL schema-invalid after 3 attempts — marking round inconclusive (NOT converged)')
146
+ }
142
147
  // Feed the DEDUPED + already-filtered set back (not raw findings) so the known
143
148
  // list stays bounded and reviewers never see the same issue twice.
144
149
  if (agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
@@ -269,6 +274,7 @@ let fixedCount = (checkpoint && typeof checkpoint.fixedCount === 'number') ? che
269
274
  let converged = false
270
275
  let abortedByValidation = false
271
276
  let failedCommands = []
277
+ let configErrors = [] // validation commands NOT in validation.commands (config gap, no rollback)
272
278
  const fixRecords = [] // observatory fix records collected round by round
273
279
 
274
280
  // Read any steering nudge intended for this run's reviewers.
@@ -288,10 +294,11 @@ for (let r = startRound; r <= maxRounds; r++) {
288
294
  )
289
295
  let agg = null
290
296
  let schemaInvalid = false
291
- let retries = 0
297
+ let attempts = 0
292
298
  do {
299
+ attempts += 1
293
300
  // Schema validation retry: on the 2nd+ pass, nudge reviewers toward strict JSON.
294
- const nudge = retries > 0
301
+ const nudge = attempts > 1
295
302
  ? '\\nSTRICT JSON REQUIRED: your previous output failed schema validation. Return ONLY a JSON object {"findings":[...]} where EVERY finding has dimension, file, line (non-negative integer; 0 = whole-file), severity (critical|high|medium|low), summary, failure_scenario, suggested_fix, is_atomic (boolean).'
296
303
  : ''
297
304
  const raw = await parallel(dims.map(dim => () => {
@@ -327,11 +334,15 @@ for (let r = startRound; r <= maxRounds; r++) {
327
334
  schemaInvalid = agg && agg.schemaValidation && agg.schemaValidation.length > 0
328
335
  ? agg.schemaValidation[agg.schemaValidation.length - 1].valid === false
329
336
  : false
330
- if (schemaInvalid && retries < 2) {
331
- retries += 1
332
- log('retry ' + retries + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
337
+ if (schemaInvalid && attempts < 3) {
338
+ log('retry ' + attempts + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
333
339
  }
334
- } while (schemaInvalid && retries <= 2)
340
+ } while (schemaInvalid && attempts < 3)
341
+ if (schemaInvalid) {
342
+ // Bounded exit: never loop forever. Surface the failure so the run's
343
+ // final summary can explain WHY the round was inconclusive.
344
+ log('round ' + r + ' output STILL schema-invalid after 3 attempts — marking round inconclusive (NOT converged)')
345
+ }
335
346
  const findings = (agg && agg.report && agg.report.findings) ? agg.report.findings : thisRound.findings
336
347
  const atomic = findings.filter(f => f.is_atomic === true)
337
348
  const remaining = findings.filter(f => f.is_atomic !== true)
@@ -386,18 +397,33 @@ for (let r = startRound; r <= maxRounds; r++) {
386
397
  if (seenKeys.indexOf(key) < 0) { architectural.push(f); seenKeys.push(key) }
387
398
  }
388
399
 
389
- // Validate every configured command; on ANY failure roll back this round's fixes.
400
+ // Validate every configured command; on ANY *executed* failure roll back this
401
+ // round's fixes. iterate_validate returns \`allowed:false\` + \`rejectReason\`
402
+ // when a command is NOT in validation.commands (a config gap, NOT a code
403
+ // failure) — those abort the run WITHOUT rolling back (the code changes are
404
+ // fine; the trust list just needs fixing).
390
405
  const valRes = await agent(
391
406
  'Read iterate.config.yaml validation.commands, then call iterate_validate({ command: <cmd> }) for EACH configured command ' +
392
- '(one tool call per command). Return all results as {command, exitCode} entries.',
407
+ '(one tool call per command). Return all results as {command, exitCode, allowed, rejectReason} entries — include \`allowed\` and \`rejectReason\` exactly as returned by iterate_validate (allowed:false means the command is not in validation.commands).',
393
408
  Object.assign({ label: 'validate:r' + r, phase: 'validate', schema: {
394
409
  type: 'object', additionalProperties: false,
395
410
  properties: {
396
- results: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { command: { type: 'string' }, exitCode: { type: 'integer' } }, required: ['command', 'exitCode'] } }
411
+ results: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { command: { type: 'string' }, exitCode: { type: 'integer' }, allowed: { type: 'boolean' }, rejectReason: { type: 'string' } }, required: ['command', 'exitCode'] } }
397
412
  },
398
413
  required: ['results'] } }, backend)
399
414
  )
400
- failedCommands = (valRes && Array.isArray(valRes.results)) ? valRes.results.filter(v => v.exitCode !== 0).map(v => v.command) : []
415
+ const valResults = (valRes && Array.isArray(valRes.results)) ? valRes.results : []
416
+ configErrors = valResults.filter(v => v.allowed === false).map(v => v.rejectReason || (v.command + ' was rejected'))
417
+ failedCommands = valResults.filter(v => !(v.allowed === false) && v.exitCode !== 0).map(v => v.command)
418
+ if (configErrors.length > 0) {
419
+ log('round ' + r + ' validation CONFIG error (not in validation.commands): ' + configErrors.join(' | ') + ' — aborting WITHOUT rollback (code changes are kept; the command trust list needs updating)')
420
+ abortedByValidation = true
421
+ await agent(
422
+ 'Call iterate_decision_log({operation:"append", type:"round_failed", round:' + r + ', data:{configErrors:' + JSON.stringify(configErrors) + ', rolledBack:0, reason:"command not in validation.commands"}})',
423
+ Object.assign({ label: 'log:configError:r' + r }, backend)
424
+ )
425
+ break
426
+ }
401
427
  if (failedCommands.length > 0) {
402
428
  log('round ' + r + ' validation FAILED on: ' + failedCommands.join(', ') + ' — rolling back this round')
403
429
  abortedByValidation = true
@@ -458,17 +484,23 @@ if (!abortedByValidation) {
458
484
  }
459
485
  // Persist the run's observatory transcript (threads, trend, fixes, checkpoint)
460
486
  // so the client observatory panel reflects the run. Writes ONLY .iterate/transcript.json.
461
- const obsCheckpoint = abortedByValidation ? null : {
462
- mode: 'normal',
463
- round: rounds.length,
464
- maxRounds: maxRounds,
465
- fixedCount: fixedCount,
466
- resumeCount: effectiveResumeCount,
467
- }
468
- await agent(
469
- 'Call iterate_transcript({operation:"capture", mode:"normal", goal:' + JSON.stringify(plan.goal) + ', maxRounds:' + maxRounds + ', roundsExecuted:' + rounds.length + ', findingsByRound:' + JSON.stringify(rounds.map(rr => (rr.findings && rr.findings.length) ? rr.findings.length : 0)) + ', fixes:' + JSON.stringify(fixRecords) + ', checkpoint:' + JSON.stringify(obsCheckpoint) + ', rounds:' + JSON.stringify(rounds.map(rr => ({ round: rr.round, findings: rr.findings, readFiles: rr.readFiles }))) + '}). Return {operation:"ok"}.',
470
- { label: 'transcript:capture' }
471
- )
487
+ // The checkpoint survives on disk for aborted runs (that is how resumption
488
+ // works), so the transcript must mirror that reality — a null checkpoint here
489
+ // would hide the very thing F5 is meant to resume. Reflect what is on disk.
490
+ const obsCheckpoint = {
491
+ mode: 'normal',
492
+ round: rounds.length,
493
+ maxRounds: maxRounds,
494
+ fixedCount: fixedCount,
495
+ resumeCount: effectiveResumeCount,
496
+ }
497
+ const stopReason = abortedByValidation
498
+ ? (configErrors.length > 0 ? 'aborted_by_config' : 'aborted_by_validation')
499
+ : (converged ? 'converged' : 'max_rounds_reached')
500
+ await agent(
501
+ 'Call iterate_transcript({operation:"capture", mode:"normal", goal:' + JSON.stringify(plan.goal) + ', maxRounds:' + maxRounds + ', roundsExecuted:' + rounds.length + ', findingsByRound:' + JSON.stringify(rounds.map(rr => (rr.findings && rr.findings.length) ? rr.findings.length : 0)) + ', stoppedReason:"' + stopReason + '", fixes:' + JSON.stringify(fixRecords) + ', checkpoint:' + JSON.stringify(obsCheckpoint) + ', rounds:' + JSON.stringify(rounds.map(rr => ({ round: rr.round, findings: rr.findings, readFiles: rr.readFiles }))) + '}). Return {operation:"ok"}.',
502
+ { label: 'transcript:capture' }
503
+ )
472
504
  return {
473
505
  mode: 'normal',
474
506
  goal: plan.goal,
@@ -477,6 +509,8 @@ return {
477
509
  converged: converged,
478
510
  abortedByValidation: abortedByValidation,
479
511
  failedCommands: failedCommands,
512
+ configErrors: configErrors,
513
+ stoppedReason: stopReason,
480
514
  findingsFixed: fixedCount,
481
515
  remainingArchitecturalCount: architectural.length,
482
516
  remainingArchitectural: architectural,
@@ -494,10 +528,10 @@ return {
494
528
  Key rules for normal mode:
495
529
  - Fixers are the ONLY agents allowed to write files, and they must go through \`iterate_fix\` — never edit files directly. That is what gives every change a backup, a diff, and a rollback path. Reviewers read only. Architectural findings are reported, never auto-fixed.
496
530
  - Aggregate the current round deterministically (\`report.findings\`) before fixing, so fixes act on deduped/filtered/sorted findings.
497
- - **Schema validation & retry**: when \`reviewer.output_schema_validation\` is on (default), retry the round's reviewers up to 2 times when \`aggregate\` reports \`schemaValidation\` valid=false for it, then re-aggregate. Never forward schema-invalid findings into \`iterate_fix\`.
531
+ - **Schema validation & retry**: when \`reviewer.output_schema_validation\` is on (default), retry the round's reviewers up to 2 times (3 attempts total) when \`aggregate\` reports \`schemaValidation\` valid=false for it, then re-aggregate. Never forward schema-invalid findings into \`iterate_fix\`. If the output is still schema-invalid after the 3rd attempt, mark the round inconclusive (never report it as a clean convergence).
498
532
  - Apply atomic fixes **per file**: one fixer agent handles all findings for a given file serially (so the same file is never edited concurrently); different files are fixed in parallel.
499
533
  - **Resume**: load the checkpoint first; if a previous run left one, continue from \`checkpoint.round + 1\` (its \`fixedCount\` and deduped \`findings\` are carried forward).
500
- - **Validate after every round** of fixes; on ANY validation failure, roll back the round's fixes via \`iterate_rollback\` and stop (the checkpoint is left in place so the run can be resumed).
534
+ - **Validate after every round** of fixes; on ANY validation failure, roll back the round's fixes via \`iterate_rollback\` and stop (the checkpoint is left in place so the run can be resumed). EXCEPTION: \`iterate_validate\` returning \`allowed:false\` means the command is simply not in \`validation.commands\` — a config gap, not broken code. In that case abort WITHOUT rolling back the round's fixes and surface the missing command + file so the user can fix the trust list.
501
535
  - **Checkpoint after every round**; clear it only when the iteration completes cleanly.
502
536
  - Stop when a round produces nothing to fix (converged) or maxReviewRounds is reached.
503
537
  - Every round, every rollback, and the final report go to the append-only decision log.
@@ -306,7 +306,11 @@ export function registerContextTool(ctx) {
306
306
  // skillDir point at arbitrary filesystem locations (e.g. /etc)
307
307
  // whose contents would be injected wholesale into the context.
308
308
  if (isAllowedSkillDir(args.skillDir, [projectRoot, PLUGIN_SRC_DIR])) {
309
- candidates.push(resolve(args.skillDir));
309
+ // Read from the REAL (symlink-resolved) path that was actually
310
+ // validated — never the raw path. Validating one path and then
311
+ // reading through a different (swappable) symlink would leave a
312
+ // check-vs-read TOCTOU gap; using the realpath here closes it.
313
+ candidates.push(realpathSync(resolve(args.skillDir)));
310
314
  }
311
315
  }
312
316
  catch {
@@ -72,9 +72,13 @@ function validateRecordInput(args) {
72
72
  export function registerDefenseEventsTool(ctx) {
73
73
  ctx.tools.register(defineTool({
74
74
  name: 'iterate_defense_events',
75
- // List/counts never write; only `record` persists an event → read shapes
76
- // join a parallel dispatch group.
77
- isConcurrencySafe: (args) => args.operation !== 'record',
75
+ // List/counts never write; `record` persists an event and `clear` removes
76
+ // the persisted stream, so only the read shapes may join a parallel
77
+ // dispatch group.
78
+ isConcurrencySafe: (args) => {
79
+ const op = args.operation;
80
+ return op !== 'record' && op !== 'clear';
81
+ },
78
82
  description: 'Query, record, or clear defense events: precondition failures, rollbacks, invariant violations, ' +
79
83
  'and assumption falsifications. ' +
80
84
  'List/counts return events with descriptions, outcomes, and summary counts; ' +
@@ -8,6 +8,7 @@ import * as fs from 'node:fs';
8
8
  import * as path from 'node:path';
9
9
  import { iterateDir } from "../paths.js";
10
10
  import { writeJsonAtomic } from "../atomic-fs.js";
11
+ import { hashString } from "./fix.js";
11
12
  const DEFENSE_EVENTS_FILE = 'defense-events.json';
12
13
  /** Valid defense event types (must stay in sync with DefenseEventType). */
13
14
  const VALID_EVENT_TYPES = new Set([
@@ -46,16 +47,20 @@ const VALID_SEVERITIES = new Set(['critical', 'high', 'medium', 'low']);
46
47
  * `timestamp`/`round`/`description`/`defense`/`outcome`/`severity` — readers
47
48
  * (list sort by timestamp, render label selection) must never crash or emit
48
49
  * NaN for those. Returns null when the entry is not an object or has no usable
49
- * `type`; otherwise fills every required field with a safe default.
50
+ * `type`; otherwise fills every required field with a safe default. An event
51
+ * missing its `id` gets a DETERMINISTIC id derived from its content and
52
+ * position (never a random one) so a read→write cycle cannot silently churn
53
+ * ids on every mutation.
50
54
  */
51
- function normalizeEvent(raw) {
55
+ function normalizeEvent(raw, index) {
52
56
  if (!raw || typeof raw !== 'object' || Array.isArray(raw))
53
57
  return null;
54
58
  const e = raw;
55
59
  if (typeof e.type !== 'string' || !VALID_EVENT_TYPES.has(e.type))
56
60
  return null;
61
+ const derivedId = `def-${hashString(`${index}|${String(e.timestamp ?? '')}|${e.type}|${String(e.description ?? '')}|${String(e.defense ?? '')}`)}`;
57
62
  return {
58
- id: typeof e.id === 'string' && e.id ? e.id : `def-${Math.random().toString(36).slice(2, 8)}`,
63
+ id: typeof e.id === 'string' && e.id ? e.id : derivedId,
59
64
  timestamp: typeof e.timestamp === 'string' && e.timestamp ? e.timestamp : new Date().toISOString(),
60
65
  round: typeof e.round === 'number' && Number.isFinite(e.round) ? Math.floor(e.round) : 0,
61
66
  type: e.type,
@@ -82,7 +87,7 @@ export function readDefenseEvents(projectRoot) {
82
87
  const parsed = JSON.parse(content);
83
88
  if (parsed && Array.isArray(parsed.events)) {
84
89
  const events = parsed.events
85
- .map(normalizeEvent)
90
+ .map((raw, i) => normalizeEvent(raw, i))
86
91
  .filter((e) => e !== null);
87
92
  const counts = computeCounts(events);
88
93
  return {
@@ -140,11 +145,13 @@ export function clearDefenseEvents(projectRoot) {
140
145
  }
141
146
  /** Add a defense event to the stream. */
142
147
  export function addDefenseEvent(stream, event) {
143
- const id = `def-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
148
+ // Spread the caller-shaped event FIRST so the store-generated `id` and
149
+ // `timestamp` always win — a caller-supplied id/timestamp must never
150
+ // override the stream's own identity fields.
144
151
  const newEvent = {
145
- id,
146
- timestamp: new Date().toISOString(),
147
152
  ...event,
153
+ id: `def-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
154
+ timestamp: new Date().toISOString(),
148
155
  };
149
156
  // Always recompute from the events array instead of mutating a possibly
150
157
  // stale/malformed persisted `counts` object — guarantees the stream counts
@@ -67,9 +67,12 @@ function normalizeExperienceInput(raw) {
67
67
  export function registerExperienceBankTool(ctx) {
68
68
  ctx.tools.register(defineTool({
69
69
  name: 'iterate_experience',
70
- // List/search/get never write; only `add` upserts the bank → read shapes
71
- // join a parallel dispatch group.
72
- isConcurrencySafe: (args) => args.operation !== 'add',
70
+ // List/search/get never write; `add` upserts the bank and `remove`
71
+ // rewrites it → only the read shapes may join a parallel dispatch group.
72
+ isConcurrencySafe: (args) => {
73
+ const op = args.operation;
74
+ return op !== 'add' && op !== 'remove';
75
+ },
73
76
  description: 'Query or extend the experience bank: browse/search historical fixes and patterns, ' +
74
77
  'or record a new verified fix (operation:"add"). ' +
75
78
  'List/search/get return matching entries with hit counts, verified fixes, and related context. ' +
@@ -8,6 +8,7 @@ import * as fs from 'node:fs';
8
8
  import * as path from 'node:path';
9
9
  import { iterateDir } from "../paths.js";
10
10
  import { writeJsonAtomic } from "../atomic-fs.js";
11
+ import { hashString } from "./fix.js";
11
12
  const EXPERIENCE_FILE = 'experience.json';
12
13
  /** Default empty experience bank. */
13
14
  function emptyBank() {
@@ -32,13 +33,16 @@ function stringArray(v) {
32
33
  * throw or emit NaN. Returns null for non-object entries; every required field
33
34
  * gets a safe default.
34
35
  */
35
- function normalizeEntry(raw) {
36
+ function normalizeEntry(raw, index) {
36
37
  if (!raw || typeof raw !== 'object' || Array.isArray(raw))
37
38
  return null;
38
39
  const e = raw;
39
40
  const pattern = typeof e.pattern === 'string' ? e.pattern : '';
40
41
  const dimension = typeof e.dimension === 'string' ? e.dimension : '';
41
- const id = typeof e.id === 'string' && e.id ? e.id : `exp-${Math.random().toString(36).slice(2, 8)}`;
42
+ // Deterministic id (content + position based) so a read→write cycle never
43
+ // churns random ids on entries that were hand-edited without one.
44
+ const derivedId = `exp-${hashString(`${index}|${pattern}|${dimension}`)}`;
45
+ const id = typeof e.id === 'string' && e.id ? e.id : derivedId;
42
46
  if (!pattern && !dimension)
43
47
  return null;
44
48
  const hitCount = typeof e.hitCount === 'number' && Number.isFinite(e.hitCount) ? e.hitCount : 0;
@@ -65,7 +69,7 @@ export function readExperienceBank(projectRoot) {
65
69
  const parsed = JSON.parse(content);
66
70
  if (parsed && Array.isArray(parsed.entries)) {
67
71
  const entries = parsed.entries
68
- .map(normalizeEntry)
72
+ .map((raw, i) => normalizeEntry(raw, i))
69
73
  .filter((e) => e !== null);
70
74
  return {
71
75
  entries,
@@ -142,8 +146,11 @@ export function searchExperienceEntries(entries, query, opts = {}) {
142
146
  /**
143
147
  * Add or update an experience entry.
144
148
  *
145
- * An entry with an `id` that already exists, OR a new entry whose
146
- * `pattern`+`dimension` pair matches an existing entry, is treated as a HIT:
149
+ * An entry with an `id` that already exists is treated as an UPDATE + HIT:
150
+ * the caller-supplied fields replace the stored ones and the hitCount is
151
+ * incremented (lastHitAt refreshed) — this fulfills the documented
152
+ * "update a specific entry via add" contract. A NEW entry whose
153
+ * `pattern`+`dimension` pair matches an existing entry is treated as a HIT:
147
154
  * the matching entry's hitCount is incremented (lastHitAt refreshed) so
148
155
  * repeated encounters of the same pattern do not create duplicates. Otherwise
149
156
  * a fresh entry is appended with hitCount 1. Never mutates the input bank.
@@ -157,11 +164,14 @@ export function upsertExperience(bank, entry) {
157
164
  ? bank.entries.find((e) => e.id === entry.id)
158
165
  : bank.entries.find((e) => e.pattern === entry.pattern && e.dimension === entry.dimension);
159
166
  if (existing) {
160
- const updated = {
161
- ...existing,
162
- hitCount: (existing.hitCount ?? 0) + 1,
163
- lastHitAt: lastUpdated,
164
- };
167
+ // Explicit-id updates REPLACE the editable fields (fulfilling the
168
+ // documented "update a specific entry via add" contract); a pattern+
169
+ // dimension HIT only bumps the hit metadata so a repeat encounter never
170
+ // overwrites the curated entry.
171
+ const byId = typeof entry.id === 'string' && entry.id.length > 0;
172
+ const updated = byId
173
+ ? { ...existing, ...entry, id: existing.id, hitCount: (existing.hitCount ?? 0) + 1, lastHitAt: lastUpdated }
174
+ : { ...existing, hitCount: (existing.hitCount ?? 0) + 1, lastHitAt: lastUpdated };
165
175
  return {
166
176
  bank: {
167
177
  ...bank,
@@ -173,14 +183,16 @@ export function upsertExperience(bank, entry) {
173
183
  entryId: existing.id,
174
184
  };
175
185
  }
176
- // Add new entry
186
+ // 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.
177
189
  const id = entry.id || `exp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
178
190
  const newEntry = {
191
+ ...entry,
179
192
  id,
180
193
  timestamp: lastUpdated,
181
194
  hitCount: 1,
182
195
  lastHitAt: lastUpdated,
183
- ...entry,
184
196
  };
185
197
  return {
186
198
  bank: {