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.
@@ -244,6 +244,8 @@ interface ObsManifest {
244
244
  timeline?: ObsEntry[]
245
245
  nudge?: ObsNudge | null
246
246
  approval?: ObsApproval
247
+ /** Why the run ended: converged / max_rounds_reached / aborted_by_validation. */
248
+ stoppedReason?: string | null
247
249
  }
248
250
 
249
251
  // ─── Quality command center data types (v3.1+; mirrors src/types.ts) ────────
@@ -735,6 +737,24 @@ function severityLabel(severity: string | undefined): string {
735
737
  return SEVERITY_LABEL[coerceSeverity(severity)]
736
738
  }
737
739
 
740
+ /** Human label for WHY a finished run stopped (transcript manifest). */
741
+ function stoppedReasonLabel(reason: string | null | undefined): string {
742
+ switch (reason) {
743
+ case 'converged': return '已结束 · 已收敛(无新发现)'
744
+ case 'max_rounds_reached': return '已结束 · 达到轮数上限'
745
+ case 'aborted_by_validation': return '已结束 · 验证失败后回滚停止'
746
+ case 'aborted_by_config': return '已结束 · 验证命令不在白名单(配置需修复)'
747
+ default: return reason ? `已结束 · ${reason}` : '已结束'
748
+ }
749
+ }
750
+
751
+ /** Badge text for the run's live/finished state, annotated with the stop reason. */
752
+ function runStatusText(live: boolean, manifest: ObsManifest | null | undefined): string {
753
+ if (live) return '运行中'
754
+ const reason = manifest && typeof manifest.stoppedReason === 'string' ? manifest.stoppedReason : ''
755
+ return reason ? stoppedReasonLabel(reason) : '已结束'
756
+ }
757
+
738
758
  /** Resolve the slots service: prefer the typed `ctx.slots`, fall back to `ctx.get`. */
739
759
  function readSlots(ctx: ClientContext): SlotsService | undefined {
740
760
  if (ctx && ctx.slots && typeof ctx.slots.inject === 'function' && typeof ctx.slots.register === 'function') {
@@ -940,7 +960,7 @@ function ConvergenceDashboard(props: SlotProps) {
940
960
  title: transcript.active === true
941
961
  ? `当前处于「${phase}」阶段(运行中)`
942
962
  : `最近一次执行停留在「${phase}」阶段(已结束)`,
943
- }, `${phase} · ${transcript.active === true ? '运行中' : '已结束'}`)
963
+ }, `${phase} · ${runStatusText(transcript.active === true, transcript)}`)
944
964
  : null
945
965
 
946
966
  // v3.0: task_mode indicator (code/iterate)
@@ -2224,7 +2244,7 @@ function ObservatoryPanel(props: SlotProps) {
2224
2244
  return React.createElement('div', { className: 'iterate-obs-block' },
2225
2245
  React.createElement('div', { className: 'iterate-obs-block-head' },
2226
2246
  React.createElement('span', {}, '运行控制台'),
2227
- React.createElement('span', { className: 'iterate-obs-badge', 'data-live': live ? '' : undefined }, live ? '运行中' : '已结束'),
2247
+ React.createElement('span', { className: 'iterate-obs-badge', 'data-live': live ? '' : undefined }, runStatusText(live, manifest)),
2228
2248
  React.createElement('span', { className: 'iterate-obs-head-meta' }, `批准策略:${policyLabel}`),
2229
2249
  ),
2230
2250
  React.createElement('div', { className: 'iterate-obs-block-body' },
@@ -2731,7 +2751,7 @@ function ObservatoryPanel(props: SlotProps) {
2731
2751
  return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'obs', className: 'iterate-obs' },
2732
2752
  React.createElement('div', { className: 'iterate-obs-head', 'data-closed': open ? undefined : '', onClick: () => setOpen((v) => !v) },
2733
2753
  React.createElement('span', { className: 'iterate-obs-title' }, 'iterate 观测台'),
2734
- React.createElement('span', { className: 'iterate-obs-badge', 'data-live': live ? '' : undefined }, live ? '运行中' : '已结束'),
2754
+ React.createElement('span', { className: 'iterate-obs-badge', 'data-live': live ? '' : undefined }, runStatusText(live, manifest)),
2735
2755
  React.createElement('span', { className: 'iterate-obs-head-meta' }, headMeta || 'runtime'),
2736
2756
  React.createElement('button', {
2737
2757
  className: 'iterate-btn', 'data-ghost': '',
@@ -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,
@@ -29,7 +39,15 @@
29
39
  import { loadEffectiveConfig, resolveProjectRoot } from './config-loader.ts'
30
40
  import { decideApproval, isDestructiveIterateTool } from './approval-gate.ts'
31
41
  import type { Context } from '@deepseek-ai/cordis'
32
- import type { ToolExecution, PreToolDecision } from '@deepseek-ai/dsh-tools'
42
+ import type { ToolErrorInfo, ToolExecution, PreToolDecision } from '@deepseek-ai/dsh-tools'
43
+
44
+ /** Structured error identity attached to policy denials (dsh 0.1.6-alpha.1:
45
+ * `deny.info` lets durable projections / retry / replay distinguish an
46
+ * approval-policy denial from a tool-thrown failure). */
47
+ const DENY_INFO: ToolErrorInfo = {
48
+ name: 'iterate-approval-gate',
49
+ code: 'APPROVAL_DENIED',
50
+ }
33
51
 
34
52
  /**
35
53
  * Build the per-call approval decision for a tool execution.
@@ -49,6 +67,20 @@ export function gateDecision(exec: ToolExecution): PreToolDecision {
49
67
  }
50
68
  if (!isDestructiveIterateTool(name)) return { kind: 'allow' }
51
69
 
70
+ // dsh 0.1.6-alpha.1: if the caller already aborted this invocation before
71
+ // dispatch, there is nothing to gate — select the canonical cancellation
72
+ // result (`cancel`) rather than prompting a human for a dead call or running
73
+ // `next()` (which would allow) on an aborted one.
74
+ let signal: Readonly<AbortSignal> | undefined
75
+ try {
76
+ signal = exec.signal
77
+ } catch {
78
+ signal = undefined
79
+ }
80
+ if (signal && typeof signal.aborted === 'boolean' && signal.aborted) {
81
+ return { kind: 'cancel' }
82
+ }
83
+
52
84
  // Resolve the project root (use the call's own `path` arg, else the agent's
53
85
  // session cwd) to read the effective observatory policy.
54
86
  let argPath: string | undefined
@@ -74,7 +106,18 @@ export function gateDecision(exec: ToolExecution): PreToolDecision {
74
106
  }
75
107
 
76
108
  const decision = decideApproval(exec, policy)
77
- if (decision.kind === 'deny') return { kind: 'deny', reason: decision.reason }
109
+ if (decision.kind === 'deny') {
110
+ return {
111
+ kind: 'deny',
112
+ reason: decision.reason,
113
+ info: {
114
+ ...DENY_INFO,
115
+ // ToolErrorInfo.reason (0.1.6-alpha.1): raw user-facing detail kept in
116
+ // durable state; the model-facing text still only sees `reason` above.
117
+ reason: decision.reason,
118
+ },
119
+ }
120
+ }
78
121
  if (decision.kind === 'ask') return { kind: 'ask', reason: decision.reason }
79
122
  return { kind: 'allow' }
80
123
  }
@@ -104,7 +147,10 @@ export function registerSessionHooks(ctx: Context): void {
104
147
  // to allow, which would bypass consent — so return our ask decision.
105
148
  return Promise.resolve(decision)
106
149
  }
107
- 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.
108
154
  return Promise.resolve(decision)
109
155
  }
110
156
  return next()
@@ -95,10 +95,11 @@ for (let r = 1; r <= maxRounds; r++) {
95
95
  log('round ' + r + ' of ' + maxRounds + ' — finding NEW issues only')
96
96
  let agg = null
97
97
  let schemaInvalid = false
98
- let retries = 0
98
+ let attempts = 0
99
99
  do {
100
+ attempts += 1
100
101
  // Schema validation retry: on the 2nd+ pass, nudge reviewers toward strict JSON.
101
- const nudge = retries > 0
102
+ const nudge = attempts > 1
102
103
  ? '\\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).'
103
104
  : ''
104
105
  const raw = await parallel(dims.map(dim => () => {
@@ -135,11 +136,15 @@ for (let r = 1; r <= maxRounds; r++) {
135
136
  schemaInvalid = agg && agg.schemaValidation && agg.schemaValidation.length > 0
136
137
  ? agg.schemaValidation[agg.schemaValidation.length - 1].valid === false
137
138
  : false
138
- if (schemaInvalid && retries < 2) {
139
- retries += 1
140
- log('retry ' + retries + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
139
+ if (schemaInvalid && attempts < 3) {
140
+ log('retry ' + attempts + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
141
141
  }
142
- } while (schemaInvalid && retries <= 2)
142
+ } while (schemaInvalid && attempts < 3)
143
+ if (schemaInvalid) {
144
+ // Bounded exit: never loop forever. Surface the failure so the run's
145
+ // final summary can explain WHY the round was inconclusive.
146
+ log('round ' + r + ' output STILL schema-invalid after 3 attempts — marking round inconclusive (NOT converged)')
147
+ }
143
148
  // Feed the DEDUPED + already-filtered set back (not raw findings) so the known
144
149
  // list stays bounded and reviewers never see the same issue twice.
145
150
  if (agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
@@ -270,6 +275,7 @@ let fixedCount = (checkpoint && typeof checkpoint.fixedCount === 'number') ? che
270
275
  let converged = false
271
276
  let abortedByValidation = false
272
277
  let failedCommands = []
278
+ let configErrors = [] // validation commands NOT in validation.commands (config gap, no rollback)
273
279
  const fixRecords = [] // observatory fix records collected round by round
274
280
 
275
281
  // Read any steering nudge intended for this run's reviewers.
@@ -289,10 +295,11 @@ for (let r = startRound; r <= maxRounds; r++) {
289
295
  )
290
296
  let agg = null
291
297
  let schemaInvalid = false
292
- let retries = 0
298
+ let attempts = 0
293
299
  do {
300
+ attempts += 1
294
301
  // Schema validation retry: on the 2nd+ pass, nudge reviewers toward strict JSON.
295
- const nudge = retries > 0
302
+ const nudge = attempts > 1
296
303
  ? '\\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).'
297
304
  : ''
298
305
  const raw = await parallel(dims.map(dim => () => {
@@ -328,11 +335,15 @@ for (let r = startRound; r <= maxRounds; r++) {
328
335
  schemaInvalid = agg && agg.schemaValidation && agg.schemaValidation.length > 0
329
336
  ? agg.schemaValidation[agg.schemaValidation.length - 1].valid === false
330
337
  : false
331
- if (schemaInvalid && retries < 2) {
332
- retries += 1
333
- log('retry ' + retries + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
338
+ if (schemaInvalid && attempts < 3) {
339
+ log('retry ' + attempts + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
334
340
  }
335
- } while (schemaInvalid && retries <= 2)
341
+ } while (schemaInvalid && attempts < 3)
342
+ if (schemaInvalid) {
343
+ // Bounded exit: never loop forever. Surface the failure so the run's
344
+ // final summary can explain WHY the round was inconclusive.
345
+ log('round ' + r + ' output STILL schema-invalid after 3 attempts — marking round inconclusive (NOT converged)')
346
+ }
336
347
  const findings = (agg && agg.report && agg.report.findings) ? agg.report.findings : thisRound.findings
337
348
  const atomic = findings.filter(f => f.is_atomic === true)
338
349
  const remaining = findings.filter(f => f.is_atomic !== true)
@@ -387,18 +398,33 @@ for (let r = startRound; r <= maxRounds; r++) {
387
398
  if (seenKeys.indexOf(key) < 0) { architectural.push(f); seenKeys.push(key) }
388
399
  }
389
400
 
390
- // Validate every configured command; on ANY failure roll back this round's fixes.
401
+ // Validate every configured command; on ANY *executed* failure roll back this
402
+ // round's fixes. iterate_validate returns \`allowed:false\` + \`rejectReason\`
403
+ // when a command is NOT in validation.commands (a config gap, NOT a code
404
+ // failure) — those abort the run WITHOUT rolling back (the code changes are
405
+ // fine; the trust list just needs fixing).
391
406
  const valRes = await agent(
392
407
  'Read iterate.config.yaml validation.commands, then call iterate_validate({ command: <cmd> }) for EACH configured command ' +
393
- '(one tool call per command). Return all results as {command, exitCode} entries.',
408
+ '(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).',
394
409
  Object.assign({ label: 'validate:r' + r, phase: 'validate', schema: {
395
410
  type: 'object', additionalProperties: false,
396
411
  properties: {
397
- results: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { command: { type: 'string' }, exitCode: { type: 'integer' } }, required: ['command', 'exitCode'] } }
412
+ results: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { command: { type: 'string' }, exitCode: { type: 'integer' }, allowed: { type: 'boolean' }, rejectReason: { type: 'string' } }, required: ['command', 'exitCode'] } }
398
413
  },
399
414
  required: ['results'] } }, backend)
400
415
  )
401
- failedCommands = (valRes && Array.isArray(valRes.results)) ? valRes.results.filter(v => v.exitCode !== 0).map(v => v.command) : []
416
+ const valResults = (valRes && Array.isArray(valRes.results)) ? valRes.results : []
417
+ configErrors = valResults.filter(v => v.allowed === false).map(v => v.rejectReason || (v.command + ' was rejected'))
418
+ failedCommands = valResults.filter(v => !(v.allowed === false) && v.exitCode !== 0).map(v => v.command)
419
+ if (configErrors.length > 0) {
420
+ 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)')
421
+ abortedByValidation = true
422
+ await agent(
423
+ 'Call iterate_decision_log({operation:"append", type:"round_failed", round:' + r + ', data:{configErrors:' + JSON.stringify(configErrors) + ', rolledBack:0, reason:"command not in validation.commands"}})',
424
+ Object.assign({ label: 'log:configError:r' + r }, backend)
425
+ )
426
+ break
427
+ }
402
428
  if (failedCommands.length > 0) {
403
429
  log('round ' + r + ' validation FAILED on: ' + failedCommands.join(', ') + ' — rolling back this round')
404
430
  abortedByValidation = true
@@ -459,17 +485,23 @@ if (!abortedByValidation) {
459
485
  }
460
486
  // Persist the run's observatory transcript (threads, trend, fixes, checkpoint)
461
487
  // so the client observatory panel reflects the run. Writes ONLY .iterate/transcript.json.
462
- const obsCheckpoint = abortedByValidation ? null : {
463
- mode: 'normal',
464
- round: rounds.length,
465
- maxRounds: maxRounds,
466
- fixedCount: fixedCount,
467
- resumeCount: effectiveResumeCount,
468
- }
469
- await agent(
470
- '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"}.',
471
- { label: 'transcript:capture' }
472
- )
488
+ // The checkpoint survives on disk for aborted runs (that is how resumption
489
+ // works), so the transcript must mirror that reality — a null checkpoint here
490
+ // would hide the very thing F5 is meant to resume. Reflect what is on disk.
491
+ const obsCheckpoint = {
492
+ mode: 'normal',
493
+ round: rounds.length,
494
+ maxRounds: maxRounds,
495
+ fixedCount: fixedCount,
496
+ resumeCount: effectiveResumeCount,
497
+ }
498
+ const stopReason = abortedByValidation
499
+ ? (configErrors.length > 0 ? 'aborted_by_config' : 'aborted_by_validation')
500
+ : (converged ? 'converged' : 'max_rounds_reached')
501
+ await agent(
502
+ '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"}.',
503
+ { label: 'transcript:capture' }
504
+ )
473
505
  return {
474
506
  mode: 'normal',
475
507
  goal: plan.goal,
@@ -478,6 +510,8 @@ return {
478
510
  converged: converged,
479
511
  abortedByValidation: abortedByValidation,
480
512
  failedCommands: failedCommands,
513
+ configErrors: configErrors,
514
+ stoppedReason: stopReason,
481
515
  findingsFixed: fixedCount,
482
516
  remainingArchitecturalCount: architectural.length,
483
517
  remainingArchitectural: architectural,
@@ -495,10 +529,10 @@ return {
495
529
  Key rules for normal mode:
496
530
  - 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.
497
531
  - Aggregate the current round deterministically (\`report.findings\`) before fixing, so fixes act on deduped/filtered/sorted findings.
498
- - **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\`.
532
+ - **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).
499
533
  - 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.
500
534
  - **Resume**: load the checkpoint first; if a previous run left one, continue from \`checkpoint.round + 1\` (its \`fixedCount\` and deduped \`findings\` are carried forward).
501
- - **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).
535
+ - **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.
502
536
  - **Checkpoint after every round**; clear it only when the iteration completes cleanly.
503
537
  - Stop when a round produces nothing to fix (converged) or maxReviewRounds is reached.
504
538
  - Every round, every rollback, and the final report go to the append-only decision log.
@@ -336,7 +336,11 @@ export function registerContextTool(ctx: { tools: { register: (def: ReturnType<t
336
336
  // skillDir point at arbitrary filesystem locations (e.g. /etc)
337
337
  // whose contents would be injected wholesale into the context.
338
338
  if (isAllowedSkillDir(args.skillDir, [projectRoot, PLUGIN_SRC_DIR])) {
339
- candidates.push(resolve(args.skillDir))
339
+ // Read from the REAL (symlink-resolved) path that was actually
340
+ // validated — never the raw path. Validating one path and then
341
+ // reading through a different (swappable) symlink would leave a
342
+ // check-vs-read TOCTOU gap; using the realpath here closes it.
343
+ candidates.push(realpathSync(resolve(args.skillDir)))
340
344
  }
341
345
  } catch {
342
346
  // unreadable/invalid skillDir — skip it
@@ -91,9 +91,13 @@ export function registerDefenseEventsTool(ctx: { tools: { register: (def: Return
91
91
  ctx.tools.register(
92
92
  defineTool({
93
93
  name: 'iterate_defense_events',
94
- // List/counts never write; only `record` persists an event → read shapes
95
- // join a parallel dispatch group.
96
- isConcurrencySafe: (args) => (args as { operation?: unknown }).operation !== 'record',
94
+ // List/counts never write; `record` persists an event and `clear` removes
95
+ // the persisted stream, so only the read shapes may join a parallel
96
+ // dispatch group.
97
+ isConcurrencySafe: (args) => {
98
+ const op = (args as { operation?: unknown }).operation
99
+ return op !== 'record' && op !== 'clear'
100
+ },
97
101
  description:
98
102
  'Query, record, or clear defense events: precondition failures, rollbacks, invariant violations, ' +
99
103
  'and assumption falsifications. ' +
@@ -9,6 +9,7 @@ import * as fs from 'node:fs'
9
9
  import * as path from 'node:path'
10
10
  import { iterateDir } from '../paths.ts'
11
11
  import { writeJsonAtomic } from '../atomic-fs.ts'
12
+ import { hashString } from './fix.ts'
12
13
  import type { DefenseEvent, DefenseEventStream, DefenseEventType } from '../types.ts'
13
14
 
14
15
  const DEFENSE_EVENTS_FILE = 'defense-events.json'
@@ -54,14 +55,18 @@ const VALID_SEVERITIES: ReadonlySet<string> = new Set(['critical', 'high', 'medi
54
55
  * `timestamp`/`round`/`description`/`defense`/`outcome`/`severity` — readers
55
56
  * (list sort by timestamp, render label selection) must never crash or emit
56
57
  * NaN for those. Returns null when the entry is not an object or has no usable
57
- * `type`; otherwise fills every required field with a safe default.
58
+ * `type`; otherwise fills every required field with a safe default. An event
59
+ * missing its `id` gets a DETERMINISTIC id derived from its content and
60
+ * position (never a random one) so a read→write cycle cannot silently churn
61
+ * ids on every mutation.
58
62
  */
59
- function normalizeEvent(raw: unknown): DefenseEvent | null {
63
+ function normalizeEvent(raw: unknown, index: number): DefenseEvent | null {
60
64
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
61
65
  const e = raw as Record<string, unknown>
62
66
  if (typeof e.type !== 'string' || !VALID_EVENT_TYPES.has(e.type as DefenseEventType)) return null
67
+ const derivedId = `def-${hashString(`${index}|${String(e.timestamp ?? '')}|${e.type}|${String(e.description ?? '')}|${String(e.defense ?? '')}`)}`
63
68
  return {
64
- id: typeof e.id === 'string' && e.id ? e.id : `def-${Math.random().toString(36).slice(2, 8)}`,
69
+ id: typeof e.id === 'string' && e.id ? e.id : derivedId,
65
70
  timestamp: typeof e.timestamp === 'string' && e.timestamp ? e.timestamp : new Date().toISOString(),
66
71
  round: typeof e.round === 'number' && Number.isFinite(e.round) ? Math.floor(e.round) : 0,
67
72
  type: e.type as DefenseEventType,
@@ -89,7 +94,7 @@ export function readDefenseEvents(projectRoot: string): DefenseEventStream {
89
94
  const parsed = JSON.parse(content) as Partial<DefenseEventStream>
90
95
  if (parsed && Array.isArray(parsed.events)) {
91
96
  const events = parsed.events
92
- .map(normalizeEvent)
97
+ .map((raw, i) => normalizeEvent(raw, i))
93
98
  .filter((e): e is DefenseEvent => e !== null)
94
99
  const counts = computeCounts(events)
95
100
  return {
@@ -155,11 +160,13 @@ export function addDefenseEvent(
155
160
  stream: DefenseEventStream,
156
161
  event: Omit<DefenseEvent, 'id' | 'timestamp'>
157
162
  ): DefenseEventStream {
158
- const id = `def-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
163
+ // Spread the caller-shaped event FIRST so the store-generated `id` and
164
+ // `timestamp` always win — a caller-supplied id/timestamp must never
165
+ // override the stream's own identity fields.
159
166
  const newEvent: DefenseEvent = {
160
- id,
161
- timestamp: new Date().toISOString(),
162
167
  ...event,
168
+ id: `def-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
169
+ timestamp: new Date().toISOString(),
163
170
  }
164
171
 
165
172
  // Always recompute from the events array instead of mutating a possibly
@@ -72,9 +72,12 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
72
72
  ctx.tools.register(
73
73
  defineTool({
74
74
  name: 'iterate_experience',
75
- // List/search/get never write; only `add` upserts the bank → read shapes
76
- // join a parallel dispatch group.
77
- isConcurrencySafe: (args) => (args as { operation?: unknown }).operation !== 'add',
75
+ // List/search/get never write; `add` upserts the bank and `remove`
76
+ // rewrites it → only the read shapes may join a parallel dispatch group.
77
+ isConcurrencySafe: (args) => {
78
+ const op = (args as { operation?: unknown }).operation
79
+ return op !== 'add' && op !== 'remove'
80
+ },
78
81
  description:
79
82
  'Query or extend the experience bank: browse/search historical fixes and patterns, ' +
80
83
  'or record a new verified fix (operation:"add"). ' +
@@ -9,6 +9,7 @@ import * as fs from 'node:fs'
9
9
  import * as path from 'node:path'
10
10
  import { iterateDir } from '../paths.ts'
11
11
  import { writeJsonAtomic } from '../atomic-fs.ts'
12
+ import { hashString } from './fix.ts'
12
13
  import type { ExperienceBank, ExperienceEntry } from '../types.ts'
13
14
 
14
15
  const EXPERIENCE_FILE = 'experience.json'
@@ -38,12 +39,15 @@ function stringArray(v: unknown): string[] {
38
39
  * throw or emit NaN. Returns null for non-object entries; every required field
39
40
  * gets a safe default.
40
41
  */
41
- function normalizeEntry(raw: unknown): ExperienceEntry | null {
42
+ function normalizeEntry(raw: unknown, index: number): ExperienceEntry | null {
42
43
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
43
44
  const e = raw as Record<string, unknown>
44
45
  const pattern = typeof e.pattern === 'string' ? e.pattern : ''
45
46
  const dimension = typeof e.dimension === 'string' ? e.dimension : ''
46
- const id = typeof e.id === 'string' && e.id ? e.id : `exp-${Math.random().toString(36).slice(2, 8)}`
47
+ // Deterministic id (content + position based) so a read→write cycle never
48
+ // churns random ids on entries that were hand-edited without one.
49
+ const derivedId = `exp-${hashString(`${index}|${pattern}|${dimension}`)}`
50
+ const id = typeof e.id === 'string' && e.id ? e.id : derivedId
47
51
  if (!pattern && !dimension) return null
48
52
  const hitCount = typeof e.hitCount === 'number' && Number.isFinite(e.hitCount) ? e.hitCount : 0
49
53
  return {
@@ -70,7 +74,7 @@ export function readExperienceBank(projectRoot: string): ExperienceBank {
70
74
  const parsed = JSON.parse(content) as Partial<ExperienceBank>
71
75
  if (parsed && Array.isArray(parsed.entries)) {
72
76
  const entries = parsed.entries
73
- .map(normalizeEntry)
77
+ .map((raw, i) => normalizeEntry(raw, i))
74
78
  .filter((e): e is ExperienceEntry => e !== null)
75
79
  return {
76
80
  entries,
@@ -167,8 +171,11 @@ export type ExperienceEntryInput = Omit<
167
171
  /**
168
172
  * Add or update an experience entry.
169
173
  *
170
- * An entry with an `id` that already exists, OR a new entry whose
171
- * `pattern`+`dimension` pair matches an existing entry, is treated as a HIT:
174
+ * An entry with an `id` that already exists is treated as an UPDATE + HIT:
175
+ * the caller-supplied fields replace the stored ones and the hitCount is
176
+ * incremented (lastHitAt refreshed) — this fulfills the documented
177
+ * "update a specific entry via add" contract. A NEW entry whose
178
+ * `pattern`+`dimension` pair matches an existing entry is treated as a HIT:
172
179
  * the matching entry's hitCount is incremented (lastHitAt refreshed) so
173
180
  * repeated encounters of the same pattern do not create duplicates. Otherwise
174
181
  * a fresh entry is appended with hitCount 1. Never mutates the input bank.
@@ -186,11 +193,14 @@ export function upsertExperience(
186
193
  : bank.entries.find((e) => e.pattern === entry.pattern && e.dimension === entry.dimension)
187
194
 
188
195
  if (existing) {
189
- const updated: ExperienceEntry = {
190
- ...existing,
191
- hitCount: (existing.hitCount ?? 0) + 1,
192
- lastHitAt: lastUpdated,
193
- }
196
+ // Explicit-id updates REPLACE the editable fields (fulfilling the
197
+ // documented "update a specific entry via add" contract); a pattern+
198
+ // dimension HIT only bumps the hit metadata so a repeat encounter never
199
+ // overwrites the curated entry.
200
+ const byId = typeof entry.id === 'string' && entry.id.length > 0
201
+ const updated: ExperienceEntry = byId
202
+ ? { ...existing, ...entry, id: existing.id, hitCount: (existing.hitCount ?? 0) + 1, lastHitAt: lastUpdated }
203
+ : { ...existing, hitCount: (existing.hitCount ?? 0) + 1, lastHitAt: lastUpdated }
194
204
  return {
195
205
  bank: {
196
206
  ...bank,
@@ -203,14 +213,16 @@ export function upsertExperience(
203
213
  }
204
214
  }
205
215
 
206
- // Add new entry
216
+ // 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.
207
219
  const id = entry.id || `exp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
208
220
  const newEntry: ExperienceEntry = {
221
+ ...entry,
209
222
  id,
210
223
  timestamp: lastUpdated,
211
224
  hitCount: 1,
212
225
  lastHitAt: lastUpdated,
213
- ...entry,
214
226
  }
215
227
 
216
228
  return {