@ijfw/memory-server 1.5.1 → 1.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.
Files changed (48) hide show
  1. package/package.json +6 -5
  2. package/src/brain/budget-guard.js +86 -0
  3. package/src/brain/citation-resolver.js +41 -0
  4. package/src/brain/context-injection.js +69 -0
  5. package/src/brain/discovery.js +83 -0
  6. package/src/brain/dream-pipeline.js +324 -0
  7. package/src/brain/dump-ingest.js +88 -0
  8. package/src/brain/entity-collapse.js +28 -0
  9. package/src/brain/export.js +121 -0
  10. package/src/brain/extractors/index.js +24 -0
  11. package/src/brain/extractors/markdown.js +27 -0
  12. package/src/brain/extractors/pdf.js +31 -0
  13. package/src/brain/extractors/transcript.js +38 -0
  14. package/src/brain/first-run-scan.js +61 -0
  15. package/src/brain/index.js +1 -0
  16. package/src/brain/layout-sentinel.js +29 -0
  17. package/src/brain/migrate-facts-internal-once.js +87 -0
  18. package/src/brain/path-guard.js +103 -0
  19. package/src/brain/paths.js +26 -0
  20. package/src/brain/promotion-suggester.js +41 -0
  21. package/src/brain/stub-detector.js +33 -0
  22. package/src/brain/tiered-llm.js +83 -0
  23. package/src/brain/wiki-compiler.js +144 -0
  24. package/src/brain/wiki-sentinels.js +45 -0
  25. package/src/brain/wiki-templates.js +94 -0
  26. package/src/cross-orchestrator-cli.js +132 -5
  27. package/src/cross-orchestrator.js +2 -2
  28. package/src/dashboard-server.js +1 -1
  29. package/src/dream/runner.mjs +21 -0
  30. package/src/extension-registry.js +2 -2
  31. package/src/handlers/brain-handler.js +319 -0
  32. package/src/memory/auto-linker.js +5 -1
  33. package/src/memory/benchmark.js +4 -3
  34. package/src/memory/layout-migrations/001-visible-layer.js +131 -0
  35. package/src/memory/layout-migrations/index.js +50 -0
  36. package/src/memory/migration-runner.js +31 -2
  37. package/src/memory/obsidian-parser.js +3 -1
  38. package/src/memory/reader.js +2 -1
  39. package/src/memory/search.js +144 -16
  40. package/src/memory/temporal.js +40 -1
  41. package/src/orchestrator/agents-md-blackboard.js +114 -1
  42. package/src/orchestrator/discipline-selector.js +259 -0
  43. package/src/orchestrator/merge-block-aware.js +15 -5
  44. package/src/orchestrator/state-sdk.js +42 -4
  45. package/src/orchestrator/wave-state.js +38 -0
  46. package/src/recovery/code-fixer.js +1 -1
  47. package/src/server.js +290 -75
  48. package/src/update-apply.js +1 -1
@@ -0,0 +1,259 @@
1
+ /**
2
+ * discipline-selector.js — pure functions to pick and load a project-discipline
3
+ * template body for the DISCIPLINE AGENTS.md marker block.
4
+ *
5
+ * Two exports:
6
+ *
7
+ * selectDisciplineTemplate(projectType)
8
+ * Synchronous. Returns the template body string (utf-8) for the given
9
+ * project type, or an empty string for `unknown` / `mixed`. Throws a
10
+ * TypeError when projectType is null or undefined. Resolves template
11
+ * paths relative to this module, using the same fileURLToPath + dirname
12
+ * pattern as merge-block-aware.js.
13
+ *
14
+ * detectProjectTypeFromRepo(repoRoot)
15
+ * Synchronous, deterministic. Inspects the repo root directory to infer
16
+ * project type. Priority order:
17
+ * (a) .ijfw/memory/brief.md frontmatter `type` key — highest fidelity,
18
+ * set explicitly by the user or brainstorm-LOCK hook.
19
+ * (b) Well-known file/dir signals — cheap existsSync probes, no glob.
20
+ * Returns one of: 'code' | 'narrative' | 'business' | 'design' |
21
+ * 'research' | 'unknown'
22
+ *
23
+ * No deps beyond node:fs and node:path (Node >=18, ESM).
24
+ */
25
+
26
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
27
+ import { join, dirname } from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+ import { validateSafeRepoPath } from '../brain/path-guard.js';
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Module-relative template root
33
+ // ---------------------------------------------------------------------------
34
+
35
+ const __filename = fileURLToPath(import.meta.url);
36
+ const __dirname = dirname(__filename);
37
+
38
+ /**
39
+ * Root of the ijfw-agents-md templates directory, resolved relative to this
40
+ * module. Same anchor pattern used by merge-block-aware.js for DEFAULT_TEMPLATE.
41
+ */
42
+ const TEMPLATES_DIR = join(
43
+ __dirname,
44
+ '..', '..', '..', 'claude', 'skills', 'ijfw-agents-md', 'templates',
45
+ );
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Valid project types
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /** All recognized project type codes (non-empty body returned for these). */
52
+ const TYPED_CODES = new Set(['code', 'narrative', 'business', 'design', 'research']);
53
+
54
+ /** Project types that legitimately produce an empty template body. */
55
+ const EMPTY_BODY_CODES = new Set(['unknown', 'mixed']);
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // selectDisciplineTemplate
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /**
62
+ * Return the template body string for `projectType`.
63
+ *
64
+ * @param {string} projectType one of: code | narrative | business | design |
65
+ * research | unknown | mixed
66
+ * @param {object} [opts]
67
+ * @param {string} [opts.templatesDir] Override the templates directory. Used
68
+ * by tests to exercise the missing-file
69
+ * Error path against a directory that
70
+ * contains no `discipline-*.md` files
71
+ * (W3 D1 / L2-04 follow-up). Default:
72
+ * shipped TEMPLATES_DIR.
73
+ * @returns {string} template body (utf-8). For unknown/mixed (and any
74
+ * unrecognized string), returns an HTML-comment hint
75
+ * body documenting how the user activates a domain.
76
+ * @throws {TypeError} when projectType is null, undefined, or non-string.
77
+ * @throws {Error} when the template file is absent for a typed project.
78
+ */
79
+ export function selectDisciplineTemplate(projectType, opts = {}) {
80
+ if (projectType === null || projectType === undefined) {
81
+ throw new TypeError(
82
+ 'selectDisciplineTemplate: projectType must be a string (got null/undefined)',
83
+ );
84
+ }
85
+ if (typeof projectType !== 'string') {
86
+ throw new TypeError(
87
+ `selectDisciplineTemplate: projectType must be a string (got ${typeof projectType})`,
88
+ );
89
+ }
90
+
91
+ const type = String(projectType).trim().toLowerCase();
92
+
93
+ // Helpful hint body for "no domain rules to apply" cases. Replaces what
94
+ // was previously an empty marker block (Wave 5B finding L3-03 + L3-04):
95
+ // a labelled-but-empty DISCIPLINE region left users unsure what to do.
96
+ // The hint is an HTML comment so it stays invisible in rendered markdown
97
+ // but visible to anyone reading the raw AGENTS.md, and documents the
98
+ // correction path inline.
99
+ const emptyBodyHint = (label) =>
100
+ `<!-- IJFW: project type is "${label}" -- domain-specific discipline rules not yet activated.\n`
101
+ + `To activate, set frontmatter \`type: code\` (or narrative | business | design | research)\n`
102
+ + `in .ijfw/memory/brief.md and re-run the brainstorm-LOCK or plan-LOCK in the IJFW workflow. -->`;
103
+
104
+ if (EMPTY_BODY_CODES.has(type)) {
105
+ return emptyBodyHint(type);
106
+ }
107
+
108
+ if (!TYPED_CODES.has(type)) {
109
+ // Unrecognized type string -- treat as 'unknown' for the hint label so
110
+ // the message stays sensible, rather than echoing whatever garbage came
111
+ // in. The graceful-degradation policy is preserved: no throw.
112
+ return emptyBodyHint('unknown');
113
+ }
114
+
115
+ const templatesDir = (opts && typeof opts.templatesDir === 'string')
116
+ ? opts.templatesDir
117
+ : TEMPLATES_DIR;
118
+ const templatePath = join(templatesDir, `discipline-${type}.md`);
119
+ if (!existsSync(templatePath)) {
120
+ throw new Error(
121
+ `selectDisciplineTemplate: template file missing for type "${type}" at ${templatePath}`,
122
+ );
123
+ }
124
+
125
+ return readFileSync(templatePath, 'utf8');
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // detectProjectTypeFromRepo -- frontmatter + file-signal fallback
130
+ // ---------------------------------------------------------------------------
131
+
132
+ /**
133
+ * Inline frontmatter parser -- minimal, zero deps.
134
+ * Reads only the `type` key from the first YAML fence (--- ... ---).
135
+ *
136
+ * Mirrors parseFrontmatter in mcp-server/src/memory/reader.js.
137
+ *
138
+ * @param {string} raw file contents
139
+ * @returns {string|null} value of `type` key, or null if absent
140
+ */
141
+ function extractTypeFromFrontmatter(raw) {
142
+ const stripped = String(raw).replace(/^/, '').replace(/^\s+/, '');
143
+ const m = stripped.match(/^---\r?\n([\s\S]*?)\r?\n---/);
144
+ if (!m) return null;
145
+ for (const line of m[1].split('\n')) {
146
+ const kv = line.match(/^type:\s*(.+)/i);
147
+ if (kv) return kv[1].trim().toLowerCase();
148
+ }
149
+ return null;
150
+ }
151
+
152
+ /**
153
+ * Return true if `name` is a direct child of `repoRoot` that is a directory
154
+ * AND contains at least one `.md` file. Avoids false-positive on plain files
155
+ * named `chapters` or `manuscript`.
156
+ *
157
+ * @param {string} repoRoot
158
+ * @param {string} name directory name to check
159
+ * @returns {boolean}
160
+ */
161
+ function isDirWithMd(repoRoot, name) {
162
+ const p = join(repoRoot, name);
163
+ try {
164
+ if (!statSync(p).isDirectory()) return false;
165
+ return readdirSync(p).some((n) => n.endsWith('.md'));
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Detect the project type from a repository root.
173
+ *
174
+ * Priority:
175
+ * (a) .ijfw/memory/brief.md frontmatter `type` key -- set by brainstorm-LOCK.
176
+ * (b) Well-known file/dir signals via existsSync (no glob, cheap).
177
+ *
178
+ * Signal table (first match wins within each tier):
179
+ * code: package.json | tsconfig.json | Cargo.toml | go.mod |
180
+ * *.csproj | pyproject.toml | setup.py | Gemfile
181
+ * narrative: chapters/ dir | manuscript/ dir
182
+ * business: pitch-deck* | business-plan* | *.numbers
183
+ * design: figma-* | *.sketch | design-system/ dir
184
+ * research: research/ dir | notebooks/ dir | *.ipynb
185
+ * unknown: fallback
186
+ *
187
+ * @param {string} repoRoot absolute path to repository root
188
+ * @returns {'code'|'narrative'|'business'|'design'|'research'|'unknown'}
189
+ */
190
+ export function detectProjectTypeFromRepo(repoRoot) {
191
+ if (!repoRoot || typeof repoRoot !== 'string') {
192
+ return 'unknown';
193
+ }
194
+
195
+ // (a) Brief.md frontmatter -- highest fidelity
196
+ const briefPath = join(repoRoot, '.ijfw', 'memory', 'brief.md');
197
+ if (existsSync(briefPath)) {
198
+ try {
199
+ const guard = validateSafeRepoPath(repoRoot, briefPath);
200
+ if (guard.ok) {
201
+ const briefStat = statSync(briefPath);
202
+ if (briefStat.size <= 256 * 1024) {
203
+ const raw = readFileSync(briefPath, 'utf8');
204
+ const type = extractTypeFromFrontmatter(raw);
205
+ if (type && (TYPED_CODES.has(type) || EMPTY_BODY_CODES.has(type))) {
206
+ return /** @type {any} */ (type);
207
+ }
208
+ }
209
+ // brief.md too large -- fall through to file signals
210
+ }
211
+ // symlink outside repo (guard.ok === false) -- skip frontmatter
212
+ } catch {
213
+ // Brief read failure is non-fatal -- fall through to file signals.
214
+ }
215
+ }
216
+
217
+ // (b) File-signal fallback -- read root entries once, then use cached list.
218
+ // Priority: earlier tier wins. e.g., pyproject.toml beats notebooks/ when both present (code projects may contain notebooks).
219
+ let _entries = null;
220
+ try { _entries = readdirSync(repoRoot); } catch { _entries = []; }
221
+ function has(predicate) { return _entries.some(predicate); }
222
+
223
+ // --- code signals ---
224
+ const CODE_FILES = [
225
+ 'package.json', 'tsconfig.json', 'Cargo.toml', 'go.mod',
226
+ 'pyproject.toml', 'setup.py', 'Gemfile',
227
+ ];
228
+ for (const f of CODE_FILES) {
229
+ if (_entries.includes(f)) return 'code';
230
+ }
231
+ // *.csproj -- project-named file; scan cached root entries.
232
+ if (has((n) => n.endsWith('.csproj'))) return 'code';
233
+
234
+ // --- narrative signals --- (must be a directory containing at least one .md)
235
+ if (isDirWithMd(repoRoot, 'chapters') || isDirWithMd(repoRoot, 'manuscript')) return 'narrative';
236
+
237
+ // --- business signals ---
238
+ if (
239
+ has((n) => n.startsWith('pitch-deck')) ||
240
+ has((n) => n.startsWith('business-plan')) ||
241
+ has((n) => n.endsWith('.numbers'))
242
+ ) return 'business';
243
+
244
+ // --- design signals ---
245
+ if (
246
+ has((n) => n.startsWith('figma-')) ||
247
+ has((n) => n.endsWith('.sketch')) ||
248
+ _entries.includes('design-system')
249
+ ) return 'design';
250
+
251
+ // --- research signals ---
252
+ if (
253
+ _entries.includes('research') ||
254
+ _entries.includes('notebooks') ||
255
+ has((n) => n.endsWith('.ipynb'))
256
+ ) return 'research';
257
+
258
+ return 'unknown';
259
+ }
@@ -34,7 +34,7 @@
34
34
  *
35
35
  * RESERVED BLOCK NAMES
36
36
  * Matches the shell script verbatim:
37
- * MEMORY | ROUTING | AGENTS | BLACKBOARD | FRONTMATTER
37
+ * MEMORY | ROUTING | AGENTS | BLACKBOARD | FRONTMATTER | DISCIPLINE
38
38
  *
39
39
  * ESM, Node ≥18, zero new prod deps.
40
40
  */
@@ -60,7 +60,7 @@ import { writeAtomic } from '../lib/atomic-io.js';
60
60
  * name yields `ERR_BAD_BLOCK` (the same exit-2 the shell script returns).
61
61
  */
62
62
  export const RESERVED_BLOCKS = Object.freeze([
63
- 'MEMORY', 'ROUTING', 'AGENTS', 'BLACKBOARD', 'FRONTMATTER',
63
+ 'MEMORY', 'ROUTING', 'AGENTS', 'BLACKBOARD', 'FRONTMATTER', 'DISCIPLINE',
64
64
  ]);
65
65
 
66
66
  const RESERVED_BLOCK_SET = new Set(RESERVED_BLOCKS);
@@ -149,7 +149,7 @@ export function mergeBlocks(src, pairs) {
149
149
  if (typeof block !== 'string' || !RESERVED_BLOCK_SET.has(block)) {
150
150
  throw new MergeBlockAwareError(
151
151
  'ERR_BAD_BLOCK',
152
- `mergeBlocks: block name reserved set: ${RESERVED_BLOCKS.join(' ')} (got ${JSON.stringify(block)})`,
152
+ `mergeBlocks: unknown block ${JSON.stringify(block)} -- reserved set: ${RESERVED_BLOCKS.join(' ')}`,
153
153
  );
154
154
  }
155
155
  const content = (pair.content === undefined || pair.content === null)
@@ -310,6 +310,18 @@ export function mergeFile(targetAbsPath, pairs, opts = {}) {
310
310
  seeded = true;
311
311
  }
312
312
 
313
+ const src = readFileSync(abs, 'utf8');
314
+ const next = mergeBlocks(src, pairs);
315
+
316
+ // No-op short-circuit: if content is unchanged and this is not a seed,
317
+ // skip backup rotation and the atomic write entirely. Idempotent calls
318
+ // will no longer accumulate identical-content backups.
319
+ if (!seeded && next === src) {
320
+ return {
321
+ ok: true, path: abs, bytes: Buffer.byteLength(next), seeded: false, noop: true,
322
+ };
323
+ }
324
+
313
325
  // Backup + retention (best-effort, defaults on). `opts.backups === false`
314
326
  // suppresses (used by some tests to keep tmp clean).
315
327
  let backup;
@@ -318,8 +330,6 @@ export function mergeFile(targetAbsPath, pairs, opts = {}) {
318
330
  if (rot.taken && rot.path) backup = rot.path;
319
331
  }
320
332
 
321
- const src = readFileSync(abs, 'utf8');
322
- const next = mergeBlocks(src, pairs);
323
333
  const res = writeAtomic(abs, next, { mode: 0o644, ensureDir: true });
324
334
  return {
325
335
  ok: true, path: res.path, bytes: res.bytes, backup, seeded,
@@ -37,9 +37,9 @@
37
37
  */
38
38
 
39
39
  import {
40
- readFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync, readdirSync,
40
+ readFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync, readdirSync, realpathSync,
41
41
  } from 'node:fs';
42
- import { join, isAbsolute, dirname, basename } from 'node:path';
42
+ import { join, isAbsolute, isAbsolute as pathIsAbsolute, relative as pathRelative, dirname, basename } from 'node:path';
43
43
  import { homedir } from 'node:os';
44
44
  import { randomUUID, createHash } from 'node:crypto';
45
45
  import { gunzipSync } from 'node:zlib';
@@ -1159,8 +1159,46 @@ const handlers = {
1159
1159
  const envLines = Object.keys(inheritedEnv).sort()
1160
1160
  .map((k) => ` ${k}=${inheritedEnv[k]}`);
1161
1161
  const eventLogPath = resolveEventLogPath(root, waveId, subagentId);
1162
- const eventLogRel = eventLogPath.startsWith(root + '/')
1163
- ? eventLogPath.slice(root.length + 1) : eventLogPath;
1162
+ // F5.6: the prior `startsWith(root + '/')` form (a) wired only on POSIX
1163
+ // because the separator is `/`, breaking on Windows where the separator
1164
+ // is `\`, and (b) silently leaked absolute paths into the dispatch brief
1165
+ // on Windows because the startsWith check always returned false. Use
1166
+ // path.relative + isAbsolute so the relative form is computed correctly
1167
+ // cross-platform, and fall back to the absolute path only when the
1168
+ // event log is genuinely outside the repo (rel '..' or different drive).
1169
+ // F-LENS2-13: pre-canonicalize via realpathSync so a Windows 8.3 short-
1170
+ // name (PROGRA~1) or a macOS /tmp -> /private/tmp symlink doesn't make
1171
+ // the path.relative containment check spuriously claim "outside repo".
1172
+ //
1173
+ // Canonicalization MUST be atomic across both sides: realpathSync(root)
1174
+ // can succeed (root exists) while realpathSync(eventLogPath) throws
1175
+ // ENOENT (event log file is about to be created). Independent try/catches
1176
+ // produce ASYMMETRIC results — on macOS /var/folders is a symlink to
1177
+ // /private/var/folders, so canonicalRoot becomes the long form while
1178
+ // canonicalEventLog stays short, path.relative returns a `..`-leading
1179
+ // string, and the containment check spuriously rejects — leaking the
1180
+ // absolute path into the dispatch brief. One try/catch keeps both sides
1181
+ // either canonical or both un-canonical.
1182
+ let canonicalRoot, canonicalEventLog;
1183
+ try {
1184
+ canonicalRoot = realpathSync(root);
1185
+ canonicalEventLog = realpathSync(eventLogPath);
1186
+ } catch {
1187
+ canonicalRoot = root;
1188
+ canonicalEventLog = eventLogPath;
1189
+ }
1190
+ const _rel = pathRelative(canonicalRoot, canonicalEventLog);
1191
+ // F-LENS2-14: when the event log is genuinely outside the repo, the
1192
+ // earlier behaviour leaked the FULL absolute path (often containing
1193
+ // $HOME / $USERPROFILE) into the dispatch brief — which the subagent
1194
+ // sees and may forward to an external LLM provider. Redact to a
1195
+ // pseudo-path so the subagent knows the log exists by basename without
1196
+ // leaking the absolute prefix. The orchestrator still has the real
1197
+ // eventLogPath in its return value for its own I/O.
1198
+ const _basename = (eventLogPath || '').split(/[\\/]/).pop() || 'events.jsonl';
1199
+ const eventLogRel = (_rel === '' || _rel.startsWith('..') || pathIsAbsolute(_rel))
1200
+ ? `<external>/${_basename}`
1201
+ : _rel;
1164
1202
  const dispatchBrief = [
1165
1203
  `# Subagent dispatch — ${subagentId} (wave ${waveId})`,
1166
1204
  role ? `Role: ${role}` : null,
@@ -59,6 +59,27 @@ function loadPopulateBlackboardBlock() {
59
59
  return _populateBlackboardBlockPromise;
60
60
  }
61
61
 
62
+ // Wave 5B wiring (post-cross-audit W1 fix): same lazy-Promise-singleton
63
+ // pattern as populateBlackboardBlock above. populateDisciplineBlock is
64
+ // idempotent (no-op short-circuit when content unchanged), so firing on
65
+ // every wave checkpoint is free and guarantees the DISCIPLINE marker block
66
+ // in AGENTS.md actually gets populated during a real workflow — closes the
67
+ // "ships as dead code" wiring gap the cross-audit caught.
68
+ let _populateDisciplineBlockPromise = null;
69
+ function loadPopulateDisciplineBlock() {
70
+ if (_populateDisciplineBlockPromise === null) {
71
+ _populateDisciplineBlockPromise = (async () => {
72
+ try {
73
+ const mod = await import('./agents-md-blackboard.js');
74
+ return mod.populateDisciplineBlock ?? null;
75
+ } catch {
76
+ return null;
77
+ }
78
+ })();
79
+ }
80
+ return _populateDisciplineBlockPromise;
81
+ }
82
+
62
83
  /**
63
84
  * Test-only helper: reset the populateBlackboardBlock promise singleton so a
64
85
  * test can simulate "first call after process start" semantics. Internal.
@@ -69,6 +90,14 @@ export function _resetPopulateBlackboardBlockSingleton() {
69
90
  _populateBlackboardBlockPromise = null;
70
91
  }
71
92
 
93
+ /**
94
+ * Test-only helper: reset the populateDisciplineBlock promise singleton.
95
+ * @internal
96
+ */
97
+ export function _resetPopulateDisciplineBlockSingleton() {
98
+ _populateDisciplineBlockPromise = null;
99
+ }
100
+
72
101
  // ---------------------------------------------------------------------------
73
102
  // Internal YAML helpers — flat subset only (string/number/boolean/string[])
74
103
  // ---------------------------------------------------------------------------
@@ -560,5 +589,14 @@ export async function checkpointWave(waveId, projectRoot) {
560
589
  try { await populateBlackboardBlock(waveId, projectRoot); } catch { /* advisory */ }
561
590
  }
562
591
 
592
+ // Wave 5B wiring (cross-audit W1 fix): populate the DISCIPLINE block too.
593
+ // Same advisory-failure semantics as the BLACKBOARD call above. Auto-detects
594
+ // project type from .ijfw/memory/brief.md frontmatter or repo signals — no
595
+ // explicit projectType passed, the detector handles it.
596
+ const populateDisciplineBlock = await loadPopulateDisciplineBlock();
597
+ if (populateDisciplineBlock) {
598
+ try { await populateDisciplineBlock(projectRoot, undefined, { waveId }); } catch { /* advisory */ }
599
+ }
600
+
563
601
  return next;
564
602
  }
@@ -470,7 +470,7 @@ async function applyEdit(filePath, fix) {
470
470
  // Exactly-one occurrence guarantee — same rule the Edit tool uses.
471
471
  const occurrences = before.split(fix.old_string).length - 1;
472
472
  if (occurrences > 1 && !fix.replace_all) {
473
- return { ok: false, evidence: `old_string occurs ${occurrences}×; ambiguous`, before };
473
+ return { ok: false, evidence: `old_string occurs ${occurrences}x; ambiguous`, before };
474
474
  }
475
475
  after = fix.replace_all
476
476
  ? before.split(fix.old_string).join(fix.new_string)