moflo 4.12.11 → 4.13.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 (43) hide show
  1. package/.claude/guidance/shipped/moflo-cli-reference.md +45 -1
  2. package/.claude/guidance/shipped/moflo-cross-install-memory-sharing.md +7 -2
  3. package/.claude/guidance/shipped/moflo-skills-reference.md +2 -0
  4. package/.claude/skills/fl/phases.md +51 -17
  5. package/.claude/skills/optimize-learnings/SKILL.md +220 -0
  6. package/README.md +95 -1
  7. package/bin/lib/get-backend.mjs +150 -12
  8. package/bin/lib/skill-categories.mjs +1 -0
  9. package/bin/session-start-launcher.mjs +13 -5
  10. package/dist/src/cli/commands/daemon.js +5 -2
  11. package/dist/src/cli/commands/epic.js +5 -1
  12. package/dist/src/cli/commands/hive-mind.js +6 -4
  13. package/dist/src/cli/commands/hooks.js +8 -8
  14. package/dist/src/cli/commands/index.js +5 -0
  15. package/dist/src/cli/commands/memory-audit-learnings.js +587 -0
  16. package/dist/src/cli/commands/memory.js +71 -10
  17. package/dist/src/cli/commands/spell-schedule.js +5 -3
  18. package/dist/src/cli/commands/worktree.js +408 -0
  19. package/dist/src/cli/config/moflo-config.js +57 -0
  20. package/dist/src/cli/index.js +4 -2
  21. package/dist/src/cli/init/executor.js +1 -0
  22. package/dist/src/cli/mcp-tools/memory-admin-tools.js +46 -8
  23. package/dist/src/cli/mcp-tools/moflodb-tools.js +30 -6
  24. package/dist/src/cli/memory/bridge-entries.js +157 -9
  25. package/dist/src/cli/memory/controllers/batch-operations.js +7 -2
  26. package/dist/src/cli/memory/daemon-backend.js +152 -11
  27. package/dist/src/cli/memory/entries-read.js +47 -2
  28. package/dist/src/cli/memory/entries-write.js +73 -10
  29. package/dist/src/cli/memory/hnsw-singleton.js +112 -9
  30. package/dist/src/cli/memory/learnings-audit.js +420 -0
  31. package/dist/src/cli/memory/learnings-dead-paths.js +202 -0
  32. package/dist/src/cli/memory/learnings-tree.js +187 -0
  33. package/dist/src/cli/memory/memory-bridge.js +37 -27
  34. package/dist/src/cli/memory/tool-call-markup.js +218 -0
  35. package/dist/src/cli/parser.js +7 -3
  36. package/dist/src/cli/services/cherry-pick-learnings.js +9 -3
  37. package/dist/src/cli/services/durable-reconcile.js +161 -0
  38. package/dist/src/cli/services/durable-store-io.js +291 -0
  39. package/dist/src/cli/services/durable-sync.js +159 -24
  40. package/dist/src/cli/services/team-artifact-sync.js +462 -163
  41. package/dist/src/cli/services/worktree-provision.js +400 -0
  42. package/dist/src/cli/version.js +1 -1
  43. package/package.json +2 -2
@@ -0,0 +1,187 @@
1
+ /**
2
+ * The filesystem half of the dead-path pass (#1479).
3
+ *
4
+ * `memory/learnings-dead-paths.ts` decides what counts as a dead path and never
5
+ * touches a disk; this decides what "resolves" means against a real checkout.
6
+ * Kept out of the command so the split the audit is built on — pure judgement,
7
+ * injected I/O — survives having a second kind of I/O in it.
8
+ *
9
+ * Cross-platform (Rule #1): a cited path arrives in the forward-slash form
10
+ * entries are authored with on every platform and is re-joined with `path.join`,
11
+ * so the only string that reaches the filesystem carries the host separator.
12
+ *
13
+ * @module memory/learnings-tree
14
+ */
15
+ import * as fs from 'fs';
16
+ import * as pathModule from 'path';
17
+ import { COMMON_WALK_SKIP_NAMES } from '../services/moflo-paths.js';
18
+ /**
19
+ * Directories never offered as a workspace prefix.
20
+ *
21
+ * `COMMON_WALK_SKIP_NAMES` is exactly the right list and is shared rather than
22
+ * restated so a second copy cannot drift from it: `node_modules` is skipped for
23
+ * the same reason a `node_modules/` path is never scored — whether it resolves
24
+ * is a fact about the checkout, not the entry — and every build or vendor output
25
+ * in it (`dist`, `build`, `target`, `.next`, `vendor`, …) is skipped because a
26
+ * stale one would resolve a path whose source has been deleted, which is the one
27
+ * answer a dead-path pass must never give.
28
+ *
29
+ * Matched case-insensitively, as every other call site does: NTFS and APFS are
30
+ * case-insensitive by default, so `Dist/` is the same directory (Rule #1).
31
+ */
32
+ const PREFIX_SCAN_SKIP = COMMON_WALK_SKIP_NAMES;
33
+ /**
34
+ * How deep below the project root a prefix may reach.
35
+ *
36
+ * One level is not enough in practice. Measured against moflo's own store, a
37
+ * top-level-only retry left five of twelve nominations citing paths that plainly
38
+ * exist — `commands/index.ts` for `src/cli/commands/index.ts`, `fl/phases.md`
39
+ * for `.claude/skills/fl/phases.md` — because the source root a learning writes
40
+ * relative to is `src/cli`, not `src`. Two levels reaches those; a third starts
41
+ * resolving paths by coincidence rather than by layout, which costs real
42
+ * findings.
43
+ */
44
+ const MAX_PREFIX_DEPTH = 2;
45
+ /**
46
+ * The widest a directory may be and still be descended into.
47
+ *
48
+ * This is what makes depth 2 affordable AND useful, and it is a statement about
49
+ * layout rather than a budget. A directory holding one or two entries —
50
+ * `src/` over `cli/`, `packages/` over its packages — is a container, and the
51
+ * thing learnings write relative to is inside it. A directory holding thirty is
52
+ * already the source root, and its children are ordinary code directories that
53
+ * no one writes paths relative to.
54
+ *
55
+ * Without it, one wide scratch directory starves the whole prefix budget:
56
+ * measured on this repo, a `tmp/` full of test fixtures took every slot after
57
+ * the top level.
58
+ */
59
+ const MAX_CHILDREN_TO_DESCEND = 12;
60
+ /**
61
+ * Cap on workspace prefixes.
62
+ *
63
+ * Every unresolved path costs one `existsSync` per prefix. The list is built
64
+ * most-specific-first — declared workspaces, then depth 1, then depth 2 — so the
65
+ * cap truncates the least valuable end rather than an arbitrary one, and
66
+ * truncating only ever costs extra nominations, never a wrong archive.
67
+ */
68
+ export const MAX_WORKSPACE_PREFIXES = 80;
69
+ /** Immediate subdirectories of `dir`, sorted; unreadable reads as none. */
70
+ function subdirectoryNames(dir) {
71
+ try {
72
+ return fs
73
+ .readdirSync(dir, { withFileTypes: true })
74
+ .filter((e) => (e.isDirectory() || e.isSymbolicLink()) && !PREFIX_SCAN_SKIP.has(e.name.toLowerCase()))
75
+ .map((e) => e.name)
76
+ .sort();
77
+ }
78
+ catch {
79
+ return [];
80
+ }
81
+ }
82
+ /**
83
+ * Workspace directories an unresolved path is retried under.
84
+ *
85
+ * Learnings are authored from inside a workspace and routinely cite
86
+ * `src/routes/foo.ts` meaning `packages/api/src/routes/foo.ts`. Declared
87
+ * workspaces come first because `packages/api` is a more specific answer than
88
+ * the bare `packages` a directory walk offers; the walk then covers the flat
89
+ * repo, which has no manifest entry to read, and goes {@link MAX_PREFIX_DEPTH}
90
+ * deep because the directory a learning writes relative to is usually a source
91
+ * root nested inside a top-level one.
92
+ *
93
+ * Dot directories are included: `.claude/` and `.github/` hold files learnings
94
+ * cite constantly, and the only one worth skipping is named in
95
+ * {@link PREFIX_SCAN_SKIP}.
96
+ *
97
+ * Returned in forward-slash form: this is the wire format
98
+ * `memory/learnings-dead-paths.ts` composes with, not a host path.
99
+ */
100
+ export function listWorkspacePrefixes(projectRoot) {
101
+ const prefixes = [];
102
+ const add = (value) => {
103
+ const clean = value.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
104
+ if (clean && !prefixes.includes(clean))
105
+ prefixes.push(clean);
106
+ };
107
+ try {
108
+ const manifest = JSON.parse(fs.readFileSync(pathModule.join(projectRoot, 'package.json'), 'utf-8'));
109
+ const declared = Array.isArray(manifest?.workspaces)
110
+ ? manifest.workspaces
111
+ : manifest?.workspaces?.packages;
112
+ for (const glob of Array.isArray(declared) ? declared : []) {
113
+ // `**` is left to the directory walk: expanding it means walking the whole
114
+ // tree, and the one level `*` covers is the shape every workspace uses.
115
+ if (typeof glob !== 'string' || glob.includes('**'))
116
+ continue;
117
+ const star = glob.indexOf('*');
118
+ if (star === -1) {
119
+ add(glob);
120
+ continue;
121
+ }
122
+ const base = glob.slice(0, star).replace(/\/+$/, '');
123
+ const baseDir = pathModule.join(projectRoot, ...base.split('/').filter(Boolean));
124
+ for (const child of subdirectoryNames(baseDir))
125
+ add(base ? pathModule.posix.join(base, child) : child);
126
+ }
127
+ }
128
+ catch {
129
+ /* No manifest, or not JSON. The directory walk below still applies. */
130
+ }
131
+ // Breadth-first, so every depth-1 prefix is in the list before any depth-2 one
132
+ // and the cap below never trades a shallower prefix for a deeper one.
133
+ let frontier = subdirectoryNames(projectRoot);
134
+ for (const name of frontier)
135
+ add(name);
136
+ for (let depth = 2; depth <= MAX_PREFIX_DEPTH && frontier.length > 0; depth++) {
137
+ const next = [];
138
+ for (const parent of frontier) {
139
+ // Every descent past the cap costs a `readdirSync` for a prefix the slice
140
+ // below is about to discard.
141
+ if (prefixes.length >= MAX_WORKSPACE_PREFIXES)
142
+ break;
143
+ const children = subdirectoryNames(pathModule.join(projectRoot, ...parent.split('/')));
144
+ if (children.length > MAX_CHILDREN_TO_DESCEND)
145
+ continue;
146
+ for (const child of children) {
147
+ const nested = pathModule.posix.join(parent, child);
148
+ next.push(nested);
149
+ add(nested);
150
+ }
151
+ }
152
+ frontier = next;
153
+ }
154
+ return prefixes.slice(0, MAX_WORKSPACE_PREFIXES);
155
+ }
156
+ /**
157
+ * Existence check for one repo-relative path cited by a learning.
158
+ *
159
+ * Rule #1: the path arrives in the forward-slash form entries are authored with
160
+ * on every platform, is split on that, and is re-joined with `path.join`, so the
161
+ * string reaching the filesystem carries the host separator and this file never
162
+ * writes one. A file OR a directory counts — a moved directory is the same
163
+ * finding as a moved file.
164
+ *
165
+ * Existence is the host's own answer, case-folding included, so a citation that
166
+ * differs from the real file only in case reads as alive on NTFS/APFS and dead
167
+ * on a case-sensitive filesystem. That is deliberate: matching the platform is
168
+ * the only defensible definition of "still in the tree", and the divergence can
169
+ * only change whether an entry is NOMINATED — never whether one is archived,
170
+ * which takes a model verdict either way.
171
+ */
172
+ export function makeTreeResolver(projectRoot) {
173
+ return (relativePath) => {
174
+ const segments = relativePath.split('/').filter((s) => s.length > 0 && s !== '.');
175
+ // A traversal segment would resolve outside the project entirely, so it can
176
+ // say nothing about whether the repo still contains the cited file.
177
+ if (segments.length === 0 || segments.includes('..'))
178
+ return false;
179
+ try {
180
+ return fs.existsSync(pathModule.join(projectRoot, ...segments));
181
+ }
182
+ catch {
183
+ return false;
184
+ }
185
+ };
186
+ }
187
+ //# sourceMappingURL=learnings-tree.js.map
@@ -456,7 +456,35 @@ export async function bridgeConsolidate(_params) {
456
456
  return { success: false, error: e.message };
457
457
  }
458
458
  }
459
+ /**
460
+ * Operations removed in #1465. Both wrote to the `episodes` store via a schema
461
+ * with no `namespace`, so neither could address a `memory_entries` row the
462
+ * caller had stored — yet both reported `success: true` with a count taken
463
+ * from the input array. Callers are redirected to the tools that work.
464
+ *
465
+ * A Map, not an object literal: `operation` is caller-controlled, and a plain
466
+ * object would resolve 'constructor'/'toString'/'__proto__' up the prototype
467
+ * chain to a truthy function.
468
+ */
469
+ const BATCH_OPERATION_REDIRECTS = new Map([
470
+ [
471
+ 'delete',
472
+ "moflodb_batch no longer supports 'delete' (#1465): it targeted the episodes store and could not address a namespaced memory entry, while reporting success. Use memory_delete with an explicit namespace.",
473
+ ],
474
+ [
475
+ 'update',
476
+ "moflodb_batch no longer supports 'update' (#1465): it targeted the episodes store and could not address a namespaced memory entry, while reporting success. Use memory_store to overwrite an entry.",
477
+ ],
478
+ ]);
459
479
  export async function bridgeBatchOperation(params) {
480
+ // Rejected before the registry lookup so a removed operation reports the
481
+ // same error whether or not the bridge happens to be available.
482
+ const redirect = BATCH_OPERATION_REDIRECTS.get(params.operation);
483
+ if (redirect)
484
+ return { success: false, error: redirect };
485
+ if (params.operation !== 'insert') {
486
+ return { success: false, error: `Unknown operation: ${params.operation}` };
487
+ }
460
488
  const registry = await getRegistry();
461
489
  if (!registry)
462
490
  return null;
@@ -464,33 +492,15 @@ export async function bridgeBatchOperation(params) {
464
492
  const batch = registry.get('batchOperations');
465
493
  if (!batch)
466
494
  return { success: false, error: 'BatchOperations not available' };
467
- let result;
468
- switch (params.operation) {
469
- case 'insert': {
470
- const episodes = params.entries.map((e) => ({
471
- content: e.value || e.content || JSON.stringify(e),
472
- metadata: e.metadata || { key: e.key },
473
- }));
474
- result = await batch.insertEpisodes(episodes);
475
- break;
476
- }
477
- case 'delete': {
478
- const keys = params.entries.map((e) => e.key).filter(Boolean);
479
- for (const key of keys)
480
- await batch.bulkDelete('episodes', { key });
481
- result = { deleted: keys.length };
482
- break;
483
- }
484
- case 'update': {
485
- for (const entry of params.entries) {
486
- await batch.bulkUpdate('episodes', { content: entry.value || entry.content }, { key: entry.key });
487
- }
488
- result = { updated: params.entries.length };
489
- break;
490
- }
491
- default: return { success: false, error: `Unknown operation: ${params.operation}` };
492
- }
493
- return { success: true, operation: params.operation, count: params.entries.length, result };
495
+ const episodes = params.entries.map((e) => ({
496
+ content: e.value || e.content || JSON.stringify(e),
497
+ metadata: e.metadata || { key: e.key },
498
+ }));
499
+ // `count` reports rows the store actually wrote, never `entries.length` —
500
+ // an input-derived count is how #1465's delete/update reported success
501
+ // while changing nothing.
502
+ const result = await batch.insertEpisodes(episodes);
503
+ return { success: true, operation: 'insert', count: result.inserted, result };
494
504
  }
495
505
  catch (e) {
496
506
  return { success: false, error: e.message };
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Detect Claude Code tool-call markup captured into a memory value (#1467).
3
+ *
4
+ * A model emitting a `memory_store` call can have the harness' own parameter
5
+ * markup spill into the `value` string it is writing. The value then arrives at
6
+ * moflo already malformed, always as a fragment the text trails off into:
7
+ *
8
+ * ...the actual lesson text.",
9
+ * <parameter name="tags">["a","b","source:manual"]
10
+ *
11
+ * moflo does not cause this — the markup exists only in the model/harness layer —
12
+ * but it is the last component that can catch it. Left alone it is written to
13
+ * disk, embedded (so it degrades the vector for that entry and every search that
14
+ * would have matched it), and shared to the team artifact, at which point it is
15
+ * permanent and propagates to every machine that imports.
16
+ *
17
+ * ## Why the detector is anchored rather than a bare marker match
18
+ *
19
+ * The naive test — "does the value contain `</value>` or `<parameter name=`" —
20
+ * rejects the lesson that documents this very bug, and every guidance doc that
21
+ * quotes the markup. So a marker only counts when it is *structurally broken*
22
+ * AND *trailing*:
23
+ *
24
+ * | Rule | Fires on | Passes |
25
+ * |---|---|---|
26
+ * | A — trailing unmatched closer | value ends on a `</value>` / `</parameter>` that never had an opener | balanced `<value>x</value>` prose; a closer mentioned mid-text |
27
+ * | B — unterminated trailing opener | an opener with no closer, on its own line, within the last {@link TRAILING_WINDOW} chars | an opener quoted inline in a sentence; one explained in the 200+ chars that follow |
28
+ *
29
+ * A value that discusses the markup explains it, and the explanation is the
30
+ * anchor. A value that was *cut off by* the markup has nothing after it.
31
+ *
32
+ * ## Accepted blind spots
33
+ *
34
+ * The detector is deliberately tuned to miss rather than over-reject, because a
35
+ * false reject blocks a legitimate write in every consumer while a miss only
36
+ * leaves one entry as bad as it is today:
37
+ *
38
+ * - A captured fragment longer than {@link TRAILING_WINDOW}. Every fragment
39
+ * observed in #1467 is under 60 characters; widening the window would start
40
+ * rejecting docs that quote the markup.
41
+ * - A value that quotes `<value>` earlier and is *then* truncated by a stray
42
+ * `</value>`: the quotation's opener absorbs the closer and both rules go
43
+ * quiet. Matching by tag name is what makes the balanced-prose cases pass.
44
+ * - A value that begins with markup, having lost all of its own text.
45
+ *
46
+ * Rule A's one accepted false positive is the mirror image: a truncated XML
47
+ * snippet that happens to end on an unmatched `</value>`. It is refusable by
48
+ * design — the ticket asks for that shape — and {@link MARKUP_OVERRIDE_ENV} is
49
+ * the way through. Bulk writes (`storeEntries`, whose only caller is the
50
+ * pattern pre-trainer) are not checked at all, which is where machine-generated
51
+ * XML-bearing content actually arrives.
52
+ *
53
+ * Pure string logic — no fs, no db, no platform surface (Rule #1).
54
+ *
55
+ * @module memory/tool-call-markup
56
+ */
57
+ /**
58
+ * How close to the end of the value an unterminated opener must sit to count as
59
+ * a captured fragment rather than a quotation. Observed fragments are short —
60
+ * `<parameter name="tags">["a","b","source:manual"]` is 47 characters — while
61
+ * prose that quotes an opener goes on to say something about it.
62
+ */
63
+ export const TRAILING_WINDOW = 200;
64
+ /** Escape hatch for a value that is genuinely shaped like the corruption. */
65
+ export const MARKUP_OVERRIDE_ENV = 'MOFLO_ALLOW_TOOL_CALL_MARKUP';
66
+ /** How much of the offending tail the error message quotes back. */
67
+ const EXCERPT_LIMIT = 120;
68
+ /**
69
+ * Matches the four token shapes the harness emits. `[^"]*` for the attribute
70
+ * keeps the pattern linear — no nested quantifier, so no backtracking blow-up
71
+ * on a long value. Built per scan rather than shared, so no `lastIndex` state
72
+ * outlives a call.
73
+ */
74
+ function tokenPattern() {
75
+ return /<(\/?)(value|parameter)(\s+name="[^"]*")?\s*>/g;
76
+ }
77
+ /**
78
+ * Visit every token in `text` at or after `from`. Streaming rather than
79
+ * array-returning: Rule A has to see the whole value, and a 1 MB value of
80
+ * repeated `<value>` would otherwise build a token object per match.
81
+ */
82
+ function scanTokens(text, from, visit) {
83
+ const re = tokenPattern();
84
+ re.lastIndex = from;
85
+ let match;
86
+ while ((match = re.exec(text)) !== null) {
87
+ const [full, slash, name, attr] = match;
88
+ // `<parameter>` without a name, or `<value name="x">`, is neither shape the
89
+ // harness emits; ignoring them keeps the detector to what it can justify.
90
+ if (name === 'parameter' && !slash && !attr)
91
+ continue;
92
+ if (name === 'value' && attr)
93
+ continue;
94
+ visit({
95
+ name: name,
96
+ kind: slash ? 'close' : 'open',
97
+ text: full,
98
+ index: match.index,
99
+ });
100
+ }
101
+ }
102
+ /**
103
+ * True when `index` starts a line that already had text before it.
104
+ *
105
+ * Both halves matter. The harness always puts the captured fragment on its own
106
+ * line, so prose that quotes an opener mid-sentence is excluded by the line
107
+ * break. And a value that *starts* at index 0 with markup never had text to be
108
+ * cut off from, so it is not the shape this detects — without the second half
109
+ * every value shorter than {@link TRAILING_WINDOW} that opens with markup would
110
+ * be rejected no matter what followed it.
111
+ */
112
+ function startsOwnLineAfterText(text, index) {
113
+ for (let i = index - 1; i >= 0; i--) {
114
+ const ch = text[i];
115
+ if (ch === '\n' || ch === '\r')
116
+ return text.slice(0, i).trim().length > 0;
117
+ if (ch !== ' ' && ch !== '\t')
118
+ return false;
119
+ }
120
+ return false; // reached the start of the value with no text before the markup
121
+ }
122
+ /**
123
+ * Return the captured-markup fragment in `value`, or `null` when the value is
124
+ * clean. See the module header for what "captured" means and why a value that
125
+ * merely quotes the markup is not.
126
+ */
127
+ export function detectToolCallMarkup(value) {
128
+ if (typeof value !== 'string' || value.length === 0)
129
+ return null;
130
+ if (!value.includes('<'))
131
+ return null;
132
+ const trimmed = value.trimEnd();
133
+ // Rule A — the value ends on a closing tag that never had an opener. This is
134
+ // the `</value>` shape: the harness closed the parameter and the closer
135
+ // landed inside the text it was closing. Two counters, no allocation, so the
136
+ // cost of a pathological value is time only.
137
+ const depth = { value: 0, parameter: 0 };
138
+ let trailingCloser = null;
139
+ scanTokens(trimmed, 0, (token) => {
140
+ if (token.kind === 'open') {
141
+ depth[token.name]++;
142
+ return;
143
+ }
144
+ if (depth[token.name] > 0) {
145
+ depth[token.name]--;
146
+ return;
147
+ }
148
+ trailingCloser = token.index + token.text.length === trimmed.length ? token : null;
149
+ });
150
+ if (trailingCloser) {
151
+ // The cast is for the narrowing only: TypeScript cannot see that the
152
+ // callback above assigns, so it holds `trailingCloser` at its initial
153
+ // `null` type. `scanTokens` is fully synchronous, so the assignment has
154
+ // certainly happened by here.
155
+ const hit = trailingCloser;
156
+ return { marker: hit.text, index: hit.index, reason: 'trailing-closer' };
157
+ }
158
+ // Rule B — an opener that is never closed, on its own line, near the end.
159
+ // This is the `<parameter name="tags">` shape: the value was cut off and the
160
+ // next parameter of the same call was appended to it.
161
+ //
162
+ // Scanned over the trailing window alone: nothing follows the end of the
163
+ // value, so an opener inside the window can only be closed inside it, and the
164
+ // cost is bounded no matter how large the value is.
165
+ //
166
+ // Not quite identical to a whole-value scan, in one direction only. A tag
167
+ // that straddles `windowStart` is invisible to this scan, so if its closer
168
+ // falls inside the window that closer reads as unmatched and can absorb a
169
+ // genuinely unterminated opener found later in the backward walk. That is a
170
+ // miss, never a false reject — the same direction every trade-off in this
171
+ // module leans.
172
+ const windowStart = Math.max(0, trimmed.length - TRAILING_WINDOW);
173
+ const windowTokens = [];
174
+ scanTokens(trimmed, windowStart, (token) => { windowTokens.push(token); });
175
+ const pendingClosers = { value: 0, parameter: 0 };
176
+ for (let i = windowTokens.length - 1; i >= 0; i--) {
177
+ const token = windowTokens[i];
178
+ if (token.kind === 'close') {
179
+ pendingClosers[token.name]++;
180
+ continue;
181
+ }
182
+ if (pendingClosers[token.name] > 0) {
183
+ pendingClosers[token.name]--;
184
+ continue;
185
+ }
186
+ // First unmatched opener found scanning backwards — the last one in the
187
+ // value, and the only one that can be the captured fragment: a fragment
188
+ // runs to the end, so anything after it is part of it. Stopping here rather
189
+ // than continuing to earlier openers is the miss-biased choice on purpose —
190
+ // if the text running to the end started mid-sentence, it is prose.
191
+ if (startsOwnLineAfterText(trimmed, token.index)) {
192
+ return { marker: token.text, index: token.index, reason: 'unterminated-opener' };
193
+ }
194
+ break;
195
+ }
196
+ return null;
197
+ }
198
+ /** True when the operator has explicitly opted this process out of the check. */
199
+ export function markupCheckDisabled() {
200
+ return process.env[MARKUP_OVERRIDE_ENV] === '1';
201
+ }
202
+ /**
203
+ * The rejection message. It quotes the offending tail back so the caller can
204
+ * see exactly what leaked and re-send the write — the silent `{success: true}`
205
+ * this replaces is why 68 corrupted entries accumulated unnoticed.
206
+ */
207
+ export function toolCallMarkupError(hit, value, namespace, key) {
208
+ const tail = value.trimEnd().slice(hit.index);
209
+ const excerpt = tail.length > EXCERPT_LIMIT ? `${tail.slice(0, EXCERPT_LIMIT)}…` : tail;
210
+ const shape = hit.reason === 'trailing-closer'
211
+ ? 'the value ends on a closing tag that was never opened'
212
+ : 'the value trails off into an unclosed opening tag';
213
+ return (`Refusing to store ${namespace}/${key}: the value contains captured tool-call markup — `
214
+ + `${shape}. Offending text at offset ${hit.index}: ${JSON.stringify(excerpt)}. `
215
+ + `Re-send the write with the markup removed. `
216
+ + `(Set ${MARKUP_OVERRIDE_ENV}=1 to store it anyway.)`);
217
+ }
218
+ //# sourceMappingURL=tool-call-markup.js.map
@@ -59,10 +59,14 @@ export class CommandParser {
59
59
  choices: ['text', 'json', 'table']
60
60
  },
61
61
  {
62
- name: 'no-color',
63
- description: 'Disable colored output',
62
+ // Declared POSITIVELY. The long-flag branch below turns `--no-<x>` into
63
+ // `<x> = false`, so an option named `no-color` can never produce a
64
+ // `noColor` key and every reader of one is dead (#1474). `--no-color`
65
+ // still works — that spelling IS the negation this parser performs.
66
+ name: 'color',
67
+ description: 'Colored output (--no-color to disable)',
64
68
  type: 'boolean',
65
- default: false
69
+ default: true
66
70
  },
67
71
  {
68
72
  name: 'interactive',
@@ -65,6 +65,14 @@ export function isDurableNamespace(namespace) {
65
65
  * between the two writers would silently mis-bind or drop rows (INSERT OR IGNORE
66
66
  * swallows the constraint violation). Single source of truth for both.
67
67
  */
68
+ /**
69
+ * The 14 durable-row columns, in the order {@link DURABLE_INSERT_OR_IGNORE_SQL}
70
+ * binds them. Exported so every reader SELECTs exactly what the writer expects
71
+ * — the read half used to be retyped per call site, which is the same drift
72
+ * hazard the shared INSERT exists to prevent.
73
+ */
74
+ export const DURABLE_ROW_COLUMNS = `id, key, namespace, content, type, embedding, embedding_model, ` +
75
+ `embedding_dimensions, tags, metadata, owner_id, created_at, updated_at, status`;
68
76
  export const DURABLE_INSERT_OR_IGNORE_SQL = `INSERT OR IGNORE INTO memory_entries ` +
69
77
  `(id, key, namespace, content, type, embedding, embedding_model, ` +
70
78
  ` embedding_dimensions, tags, metadata, owner_id, created_at, updated_at, status) ` +
@@ -151,9 +159,7 @@ export async function cherryPickLearningsFromLegacy(options = {}) {
151
159
  try {
152
160
  targetDb.run(MEMORY_SCHEMA_V3);
153
161
  const placeholders = namespaces.map(() => '?').join(',');
154
- const selectSql = `SELECT id, key, namespace, content, type, embedding, embedding_model, ` +
155
- `embedding_dimensions, tags, metadata, owner_id, created_at, updated_at, status ` +
156
- `FROM memory_entries WHERE namespace IN (${placeholders})`;
162
+ const selectSql = `SELECT ${DURABLE_ROW_COLUMNS} FROM memory_entries WHERE namespace IN (${placeholders})`;
157
163
  // Hoisted prepare — avoids re-parsing the SQL for every INSERT. Matters
158
164
  // for legacy DBs with hundreds of learnings rows.
159
165
  insertStmt = targetDb.prepare(DURABLE_INSERT_OR_IGNORE_SQL);