moflo 4.12.4 → 4.12.5

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 (108) hide show
  1. package/.claude/guidance/shipped/moflo-agent-rules.md +1 -0
  2. package/.claude/guidance/shipped/moflo-claude-swarm-cohesion.md +1 -1
  3. package/.claude/guidance/shipped/moflo-cli-reference.md +4 -4
  4. package/.claude/guidance/shipped/moflo-core-guidance.md +4 -4
  5. package/.claude/guidance/shipped/moflo-guidance-rules.md +21 -0
  6. package/.claude/guidance/shipped/moflo-inline-fixes.md +98 -0
  7. package/.claude/guidance/shipped/moflo-root-cause-discipline.md +10 -10
  8. package/.claude/guidance/shipped/moflo-sdd.md +5 -5
  9. package/.claude/guidance/shipped/moflo-spell-engine.md +1 -1
  10. package/.claude/guidance/shipped/moflo-yaml-reference.md +8 -8
  11. package/.claude/helpers/gate.cjs +46 -7
  12. package/.claude/helpers/pr-create-command.cjs +440 -0
  13. package/.claude/skills/eldar/SKILL.md +1 -1
  14. package/.claude/skills/fl/SKILL.md +6 -6
  15. package/.claude/skills/fl/execution-modes.md +3 -3
  16. package/.claude/skills/fl/phases.md +8 -8
  17. package/.claude/skills/fl/sdd.md +6 -6
  18. package/.claude/skills/guidance/SKILL.md +3 -0
  19. package/.claude/skills/verify/SKILL.md +4 -4
  20. package/bin/gate.cjs +46 -7
  21. package/bin/hooks.mjs +4 -1
  22. package/bin/lib/retired-files.mjs +38 -6
  23. package/bin/lib/shipped-scripts.json +2 -1
  24. package/bin/lib/skill-categories.mjs +81 -17
  25. package/bin/pr-create-command.cjs +440 -0
  26. package/bin/session-start-launcher.mjs +11 -6
  27. package/dist/src/cli/aidefence/domain/entities/threat.js +2 -1
  28. package/dist/src/cli/commands/doctor-checks-deep.js +3 -2
  29. package/dist/src/cli/commands/doctor-checks-memory-access.js +3 -2
  30. package/dist/src/cli/commands/doctor-checks-swarm.js +3 -2
  31. package/dist/src/cli/commands/hooks.js +2 -1
  32. package/dist/src/cli/commands/mcp.js +3 -2
  33. package/dist/src/cli/commands/memory.js +2 -1
  34. package/dist/src/cli/commands/performance.js +2 -1
  35. package/dist/src/cli/commands/retire.js +1 -1
  36. package/dist/src/cli/commands/spell-schedule.js +2 -1
  37. package/dist/src/cli/commands/swarm.js +216 -104
  38. package/dist/src/cli/guidance/headless.js +5 -8
  39. package/dist/src/cli/guidance/retriever.js +27 -6
  40. package/dist/src/cli/guidance/ruvbot-integration.js +2 -1
  41. package/dist/src/cli/guidance/uncertainty.js +2 -1
  42. package/dist/src/cli/hooks/bridge/official-hooks-bridge.js +2 -1
  43. package/dist/src/cli/hooks/mcp/index.js +3 -2
  44. package/dist/src/cli/hooks/reasoningbank/index.js +2 -1
  45. package/dist/src/cli/hooks/registry/index.js +2 -1
  46. package/dist/src/cli/hooks/swarm/index.js +6 -5
  47. package/dist/src/cli/init/claudemd-generator.js +2 -1
  48. package/dist/src/cli/init/helpers-generator.js +1 -1
  49. package/dist/src/cli/init/moflo-yaml-template.js +4 -4
  50. package/dist/src/cli/mcp-client.js +5 -2
  51. package/dist/src/cli/mcp-tools/agent-tools.js +4 -1
  52. package/dist/src/cli/mcp-tools/hive-mind-tools.js +4 -3
  53. package/dist/src/cli/mcp-tools/hooks-tools.js +6 -5
  54. package/dist/src/cli/mcp-tools/json-store.js +3 -2
  55. package/dist/src/cli/mcp-tools/neural-tools.js +2 -1
  56. package/dist/src/cli/mcp-tools/performance-tools.js +2 -1
  57. package/dist/src/cli/mcp-tools/session-tools.js +4 -2
  58. package/dist/src/cli/mcp-tools/spell-tools.js +2 -1
  59. package/dist/src/cli/memory/bridge-core.js +8 -2
  60. package/dist/src/cli/memory/controllers/_shared.js +8 -2
  61. package/dist/src/cli/memory/controllers/nightly-learner.js +2 -1
  62. package/dist/src/cli/memory/domain/services/memory-domain-service.js +2 -1
  63. package/dist/src/cli/memory/entries-write.js +2 -1
  64. package/dist/src/cli/memory/intelligence.js +2 -1
  65. package/dist/src/cli/memory/persistent-sona.js +1 -3
  66. package/dist/src/cli/memory/types.js +2 -3
  67. package/dist/src/cli/memory/verify.js +3 -2
  68. package/dist/src/cli/movector/graph-analyzer.js +14 -3
  69. package/dist/src/cli/neural/pattern-learner.js +4 -2
  70. package/dist/src/cli/neural/reasoning-bank.js +2 -1
  71. package/dist/src/cli/neural/reasoningbank-adapter.js +2 -1
  72. package/dist/src/cli/neural/sona-manager.js +4 -3
  73. package/dist/src/cli/production/monitoring.js +2 -1
  74. package/dist/src/cli/services/daemon-service.js +57 -46
  75. package/dist/src/cli/services/daemon-spell-executor.js +3 -2
  76. package/dist/src/cli/services/headless-worker-executor.js +4 -2
  77. package/dist/src/cli/services/learning-service.js +2 -1
  78. package/dist/src/cli/services/worker-daemon.js +2 -1
  79. package/dist/src/cli/shared/hooks/example-usage.js +3 -2
  80. package/dist/src/cli/shared/hooks/safety/git-commit.js +11 -2
  81. package/dist/src/cli/shared/hooks/session-hooks.js +3 -2
  82. package/dist/src/cli/shared/hooks/task-hooks.js +2 -1
  83. package/dist/src/cli/shared/mcp/session-manager.js +2 -1
  84. package/dist/src/cli/shared/plugins/official/hive-mind-plugin.js +2 -1
  85. package/dist/src/cli/shared/plugins/official/maestro-plugin.js +3 -2
  86. package/dist/src/cli/shared/security/index.js +6 -2
  87. package/dist/src/cli/shared/utils/atomic-file-write.js +9 -1
  88. package/dist/src/cli/shared/utils/id.js +54 -0
  89. package/dist/src/cli/shared/utils/platform.js +36 -5
  90. package/dist/src/cli/spells/commands/destructive-pattern-checker.js +6 -1
  91. package/dist/src/cli/spells/core/dry-run-validator.js +2 -1
  92. package/dist/src/cli/spells/core/interpolation.js +8 -3
  93. package/dist/src/cli/spells/core/permission-resolver.js +2 -2
  94. package/dist/src/cli/spells/core/runner.js +2 -1
  95. package/dist/src/cli/spells/core/shell.js +7 -8
  96. package/dist/src/cli/spells/factory/runner-bridge.js +3 -2
  97. package/dist/src/cli/spells/factory/runner-factory.js +3 -2
  98. package/dist/src/cli/spells/scheduler/scheduler.js +2 -1
  99. package/dist/src/cli/swarm/consensus/gossip.js +2 -1
  100. package/dist/src/cli/swarm/federation-hub.js +5 -4
  101. package/dist/src/cli/swarm/queen-coordinator.js +5 -4
  102. package/dist/src/cli/swarm/shared/events.js +2 -1
  103. package/dist/src/cli/swarm/unified-coordinator.js +3 -2
  104. package/dist/src/cli/version.js +1 -1
  105. package/package.json +2 -2
  106. package/retired-files.json +0 -72
  107. package/dist/src/cli/mcp-tools/coordination-tools.js +0 -86
  108. package/dist/src/cli/shared/security/secure-random.js +0 -142
@@ -0,0 +1,440 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Does this Bash command actually invoke `gh pr create`?
5
+ *
6
+ * Issue #1410. The gates in `gate.cjs` (`check-before-pr`, `check-before-done`)
7
+ * used to answer this with a single regex applied to raw command text:
8
+ *
9
+ * /(?:^|&&\s*|\|\|\s*|;\s*)\s*(?:[A-Z_][A-Z0-9_]*=\S+\s+)*gh\s+pr\s+create\b/
10
+ *
11
+ * That was wrong in both directions at once. It MISSED shapes people routinely
12
+ * type — a newline-separated multi-line command, `cat body.md | gh pr create
13
+ * --body-file -`, `( gh pr create )` — each of which opened a PR with every gate
14
+ * silently skipped. And it FIRED on commands that merely quote the literal — a
15
+ * `git commit -m "...gh pr create..."`, a `node -e '...'` probe, a heredoc body —
16
+ * blocking work that has nothing to do with opening a PR.
17
+ *
18
+ * One cause, both symptoms: a regex over raw text cannot tell a command from a
19
+ * string that quotes one. That is why the obvious repair (widen the separator
20
+ * set to include newline and `|`) makes the over-match strictly worse — every
21
+ * heredoc body line starting with the literal would become a match.
22
+ *
23
+ * So: two passes.
24
+ *
25
+ * 1. `sanitizeShellData` blanks the regions that are data rather than command
26
+ * — comments, heredoc bodies, all quote flavours, escapes — replacing them
27
+ * with spaces and PRESERVING LENGTH, so nothing outside a blanked region
28
+ * shifts or merges.
29
+ * 2. `isPrCreateCommand` searches the sanitised text for the literal, then
30
+ * walks BACKWARDS from each hit to confirm it starts a command.
31
+ *
32
+ * Four traps, three of which are silent BYPASSES (the gate stops blocking with
33
+ * no signal at all), are handled deliberately below and are marked `TRAP` at
34
+ * their sites:
35
+ *
36
+ * - `<<<` is a herestring, not a heredoc. Reading it as a heredoc opener
37
+ * means the delimiter is never found, the "body" blanks the rest of the
38
+ * input, and a real invocation on the next line is swallowed.
39
+ * - Heredoc headers are matched with a STICKY regex, never against a
40
+ * fixed-size `slice(i, i + N)` window. A window truncates a bare delimiter
41
+ * longer than it, registers the truncated prefix as the delimiter, and the
42
+ * real terminator line then never matches — body blanks to EOF.
43
+ * - `\` + newline is a line continuation, not a separator: the words after it
44
+ * are arguments, and heredoc bodies queued on that line do not start there.
45
+ * - Left-context stays OUT of the regex. `(?:^|[\n;&|(){}])\s*(?:NAME=val\s+)*gh…`
46
+ * is quadratic on sanitised input (long runs of spaces that `\s*` consumes,
47
+ * fails on, and backtracks through at every position). Searching for the
48
+ * literal first and walking back by hand is flat.
49
+ *
50
+ * Mirrored verbatim into `.claude/helpers/pr-create-command.cjs` — the dogfood
51
+ * parity guard enforces byte-identity. Dependency-free CommonJS, `node:`
52
+ * builtins only (it uses none), so it loads from either location.
53
+ */
54
+
55
+ /**
56
+ * Chars that can immediately precede a command word. `<` and `>` are absent on
57
+ * purpose: `> out.txt gh pr create` is a redirect target followed by arguments
58
+ * in the shapes we care about, not a fresh command.
59
+ *
60
+ * The backtick is present — and is deliberately NOT blanked as data the way
61
+ * quotes are. #1410's write-up lists backticks with the quote flavours, but an
62
+ * UNQUOTED backtick is command substitution: blanking it would make
63
+ * `` `gh pr create` `` stop blocking, i.e. exactly the silent-bypass class the
64
+ * issue is about. Backticks inside `"…"` are still blanked with the rest of the
65
+ * quoted run, which is where the over-match risk actually lives.
66
+ */
67
+ var SEPARATOR_CHARS = '\n;&|(){}`';
68
+
69
+ /**
70
+ * Words that may sit between a separator and the command without changing the
71
+ * fact that a command follows. Kept short and literal — anything not listed
72
+ * (e.g. `echo`) correctly makes the walk fail, because its following words are
73
+ * arguments, not a command.
74
+ */
75
+ var PREFIX_WORDS = ['!', 'then', 'else', 'elif', 'do', 'time', 'command', 'builtin', 'exec', 'env', 'nohup', 'xargs', 'sudo'];
76
+
77
+ /** `NAME=value` prefix, e.g. `GH_TOKEN=x gh pr create`. Bounded to one word. */
78
+ var ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=\S*$/;
79
+
80
+ /**
81
+ * Heredoc header, matched STICKY at a known index. Alternatives, in order:
82
+ * `'DELIM'`, `"DELIM"`, `$'DELIM'`, bare DELIM. The quoted forms come first so
83
+ * a quoted delimiter is never mis-read as a bare one starting with a quote.
84
+ */
85
+ var HEREDOC_RE = /<<(-?)[ \t]*(?:'([^'\n]*)'|"([^"\n]*)"|\$'([^'\n]*)'|([^\s;&|<>()'"`]+))/y;
86
+
87
+ /** The invocation itself. No left-context — see the fourth trap above. */
88
+ var PR_CREATE_RE = /gh\s+pr\s+create\b/g;
89
+
90
+ /**
91
+ * Pre-sanitise reject: `gh` as a whole token. A bare `indexOf('gh')` is far too
92
+ * weak — "gh" is a common English digraph, so `highlight`, `github`, `though`,
93
+ * and `flight` all fall through to the full two-pass scan on a hook that runs as
94
+ * a fresh process on every Bash call, at both gate call sites.
95
+ *
96
+ * Safe against false negatives: `PR_CREATE_RE` requires whitespace after `gh`,
97
+ * and sanitising only ever replaces characters with spaces — it never turns a
98
+ * non-word character into a word one — so a `gh` that survives to match here
99
+ * cannot have had a word character in front of it in the raw text.
100
+ *
101
+ * No quantifiers, so the scan is linear with no backtracking.
102
+ */
103
+ var GH_TOKEN_RE = /(?:^|[^A-Za-z0-9_])gh(?![A-Za-z0-9_])/;
104
+
105
+ /** Horizontal whitespace only. `\n` is a separator and must not be skipped as blank. */
106
+ function isBlank(ch) {
107
+ return ch === ' ' || ch === '\t' || ch === '\r';
108
+ }
109
+
110
+ /** A `#` opens a comment only when it begins a word. */
111
+ function startsWord(cmd, i) {
112
+ if (i === 0) return true;
113
+ var prev = cmd.charAt(i - 1);
114
+ return isBlank(prev) || SEPARATOR_CHARS.indexOf(prev) !== -1;
115
+ }
116
+
117
+ /** Index of the first character of the whitespace/separator-delimited token containing `from`. */
118
+ function tokenStartAt(s, from) {
119
+ var k = from;
120
+ while (k > 0 && !isBlank(s.charAt(k - 1)) && SEPARATOR_CHARS.indexOf(s.charAt(k - 1)) === -1) k--;
121
+ return k;
122
+ }
123
+
124
+ /**
125
+ * How deep `$(…)` inside `"…"` inside `$(…)` is followed before giving up and
126
+ * leaving the region blanked. Nothing real nests anywhere near this; the cap
127
+ * exists so a pathological input cannot recurse into a stack overflow.
128
+ */
129
+ var MAX_SUBSTITUTION_DEPTH = 8;
130
+
131
+ /**
132
+ * Replace every data region with spaces, preserving length. What remains is
133
+ * command text — the only thing safe to match a command against.
134
+ *
135
+ * Exported for tests: asserting on the sanitised string is how the blanking
136
+ * rules are pinned independently of the matcher on top of them.
137
+ *
138
+ * @param {string} cmd
139
+ * @param {number} [depth] internal — substitution recursion depth
140
+ * @returns {string} same length as `cmd`
141
+ */
142
+ function sanitizeShellData(cmd, depth) {
143
+ var n = cmd.length;
144
+ var out = cmd.split('');
145
+ var pending = [];
146
+ var level = depth || 0;
147
+ var i = 0;
148
+
149
+ function blank(from, to) {
150
+ var stop = to > n ? n : to;
151
+ for (var k = from; k < stop; k++) out[k] = ' ';
152
+ }
153
+
154
+ /** Copy `raw`'s chars back into `out` at `from` — the inverse of `blank`. */
155
+ function restore(from, raw) {
156
+ for (var k = 0; k < raw.length; k++) out[from + k] = raw.charAt(k);
157
+ }
158
+
159
+ /** Scan a quoted run from `i` to its closing `close`, honouring `\` escapes if asked. */
160
+ function closingIndex(from, close, escapes) {
161
+ var j = from;
162
+ while (j < n) {
163
+ var ch = cmd.charAt(j);
164
+ if (escapes && ch === '\\') { j += 2; continue; }
165
+ if (ch === close) return j + 1;
166
+ j++;
167
+ }
168
+ return n;
169
+ }
170
+
171
+ /**
172
+ * Index of the `)` closing the `(` at `openIdx`, or -1 before `limit`.
173
+ * Skips quoted runs so a `)` inside a string doesn't close the substitution.
174
+ */
175
+ function matchingParen(openIdx, limit) {
176
+ var d = 0;
177
+ var k = openIdx;
178
+ while (k < limit) {
179
+ var ch = cmd.charAt(k);
180
+ if (ch === '\\') { k += 2; continue; }
181
+ if (ch === "'") { k = closingIndex(k + 1, "'", false); continue; }
182
+ if (ch === '"') { k = doubleQuoteEnd(k + 1); continue; }
183
+ if (ch === '(') d++;
184
+ else if (ch === ')') { d--; if (d === 0) return k; }
185
+ k++;
186
+ }
187
+ return -1;
188
+ }
189
+
190
+ /**
191
+ * Index just past the `"` that closes the run opened before `from`.
192
+ *
193
+ * Not `closingIndex(from, '"', true)`: quoting RESTARTS inside a command
194
+ * substitution, so in `"a $(cmd "b") c"` the quotes around `b` belong to the
195
+ * substitution and do not end the outer run. Scanning for the next unescaped
196
+ * `"` would cut the run short at `b`'s opening quote and leave the second half
197
+ * — including anything the substitution runs — mis-parsed.
198
+ *
199
+ * Mutually recursive with `matchingParen`; both only ever move forward, so the
200
+ * pair terminates.
201
+ */
202
+ function doubleQuoteEnd(from) {
203
+ var j = from;
204
+ while (j < n) {
205
+ var ch = cmd.charAt(j);
206
+ if (ch === '\\') { j += 2; continue; }
207
+ if (ch === '"') return j + 1;
208
+ if (ch === '$' && cmd.charAt(j + 1) === '(') {
209
+ var close = matchingParen(j + 1, n);
210
+ if (close !== -1) { j = close + 1; continue; }
211
+ j += 2;
212
+ continue;
213
+ }
214
+ if (ch === '`') {
215
+ var bt = cmd.indexOf('`', j + 1);
216
+ if (bt !== -1) { j = bt + 1; continue; }
217
+ }
218
+ j++;
219
+ }
220
+ return n;
221
+ }
222
+
223
+ /**
224
+ * A double-quoted run and an unquoted-delimiter heredoc body are data —
225
+ * EXCEPT for the command substitutions inside them, which the shell still
226
+ * runs. Blanking those wholesale would leave `echo "$(gh pr create)"` and its
227
+ * heredoc equivalent as silent bypasses, so restore each `$(…)` / `` `…` ``
228
+ * region over the already-blanked range: its delimiters (both of which the
229
+ * backwards walk reads as separators) plus its body, sanitised in its own
230
+ * right so quoting INSIDE the substitution still applies.
231
+ *
232
+ * `'…'`, `$'…'` and quoted-delimiter heredocs (`<<'EOF'`) are left blanked —
233
+ * the shell does not expand those, so there is nothing to restore.
234
+ */
235
+ function restoreSubstitutions(from, to) {
236
+ if (level >= MAX_SUBSTITUTION_DEPTH) return;
237
+ var k = from;
238
+ while (k < to) {
239
+ var ch = cmd.charAt(k);
240
+ if (ch === '\\') { k += 2; continue; }
241
+ var openLen = 0;
242
+ var close = -1;
243
+ if (ch === '$' && cmd.charAt(k + 1) === '(') {
244
+ openLen = 2;
245
+ close = matchingParen(k + 1, to);
246
+ } else if (ch === '`') {
247
+ openLen = 1;
248
+ close = cmd.indexOf('`', k + 1);
249
+ if (close >= to) close = -1;
250
+ }
251
+ if (close === -1) { k++; continue; }
252
+ restore(k, cmd.slice(k, k + openLen));
253
+ restore(close, cmd.charAt(close));
254
+ restore(k + openLen, sanitizeShellData(cmd.slice(k + openLen, close), level + 1));
255
+ k = close + 1;
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Blank one heredoc body, starting at `start` (the first char of the line
261
+ * after the newline that ended the header's logical line).
262
+ *
263
+ * Blanks each body line's content and the terminator line's content, leaving
264
+ * every newline in place — so the text after the heredoc still begins after a
265
+ * real separator, and offsets outside the body are untouched.
266
+ *
267
+ * Returns the index to resume scanning from. An unterminated heredoc blanks to
268
+ * end of input, which is what the shell does with it too.
269
+ */
270
+ function blankHeredocBody(start, delim, stripTabs, expands) {
271
+ var pos = start;
272
+ while (pos < n) {
273
+ var eol = cmd.indexOf('\n', pos);
274
+ var lineEnd = eol === -1 ? n : eol;
275
+ var line = cmd.slice(pos, lineEnd);
276
+ // CRLF: the terminator is the line's content, not its line ending.
277
+ if (line.charAt(line.length - 1) === '\r') line = line.slice(0, -1);
278
+ var candidate = stripTabs ? line.replace(/^\t+/, '') : line;
279
+ blank(pos, lineEnd);
280
+ if (expands) restoreSubstitutions(pos, lineEnd);
281
+ if (candidate === delim) return eol === -1 ? n : eol + 1;
282
+ if (eol === -1) return n;
283
+ pos = eol + 1;
284
+ }
285
+ return n;
286
+ }
287
+
288
+ while (i < n) {
289
+ var c = cmd.charAt(i);
290
+
291
+ // TRAP: `\` + newline is a line continuation. Blanking BOTH characters keeps
292
+ // the logical line going, so the words after it stay arguments — otherwise
293
+ // `cat <<EOF \` + newline + `gh pr create` reads as a command when the shell
294
+ // reads it as arguments to `cat`. Any other `\x` is an escape; blanking the
295
+ // pair likewise stops `\;` and friends from reading as separators.
296
+ if (c === '\\') {
297
+ blank(i, i + 2);
298
+ i += 2;
299
+ continue;
300
+ }
301
+
302
+ if (c === '#' && startsWord(cmd, i)) {
303
+ var eol = cmd.indexOf('\n', i);
304
+ if (eol === -1) eol = n;
305
+ blank(i, eol);
306
+ i = eol;
307
+ continue;
308
+ }
309
+
310
+ if (c === '<' && cmd.charAt(i + 1) === '<') {
311
+ // TRAP: `<<<` is a herestring. Skip the operator — its word is handled by
312
+ // the ordinary quote/plain-text rules below.
313
+ if (cmd.charAt(i + 2) === '<') { i += 3; continue; }
314
+ // TRAP: sticky match at `i`, no window, no substring copy.
315
+ HEREDOC_RE.lastIndex = i;
316
+ var h = HEREDOC_RE.exec(cmd);
317
+ if (h) {
318
+ var delim = h[2] !== undefined ? h[2] : h[3] !== undefined ? h[3] : h[4] !== undefined ? h[4] : h[5];
319
+ // A BARE delimiter (`<<EOF`) expands `$(…)` in the body; any quoted form
320
+ // (`<<'EOF'`, `<<"EOF"`, `<<$'EOF'`) suppresses expansion entirely.
321
+ pending.push({ delim: delim, stripTabs: h[1] === '-', expands: h[5] !== undefined });
322
+ blank(i, i + h[0].length);
323
+ i += h[0].length;
324
+ continue;
325
+ }
326
+ // Not a header we recognise (`<< ;`, `<<` at end of input) — treat as text.
327
+ i += 2;
328
+ continue;
329
+ }
330
+
331
+ // `$'...'` processes `\` escapes; plain `'...'` does not.
332
+ if (c === '$' && cmd.charAt(i + 1) === "'") {
333
+ var ansiEnd = closingIndex(i + 2, "'", true);
334
+ blank(i, ansiEnd);
335
+ i = ansiEnd;
336
+ continue;
337
+ }
338
+
339
+ if (c === "'") {
340
+ var sqEnd = closingIndex(i + 1, "'", false);
341
+ blank(i, sqEnd);
342
+ i = sqEnd;
343
+ continue;
344
+ }
345
+
346
+ if (c === '"') {
347
+ var dqEnd = doubleQuoteEnd(i + 1);
348
+ blank(i, dqEnd);
349
+ // Substitutions inside the run still execute — see restoreSubstitutions.
350
+ restoreSubstitutions(i + 1, dqEnd - 1);
351
+ i = dqEnd;
352
+ continue;
353
+ }
354
+
355
+ if (c === '\n') {
356
+ i++;
357
+ // Bodies for every heredoc announced on the logical line just ended, in
358
+ // the order they were announced (`cat <<A <<B` reads A's body then B's).
359
+ while (pending.length > 0) {
360
+ var hd = pending.shift();
361
+ i = blankHeredocBody(i, hd.delim, hd.stripTabs, hd.expands);
362
+ }
363
+ continue;
364
+ }
365
+
366
+ i++;
367
+ }
368
+
369
+ return out.join('');
370
+ }
371
+
372
+ /**
373
+ * Does the token at `idx` in sanitised text `s` begin a command? True when what
374
+ * precedes it is the start of input, a separator, or a run of `NAME=value` /
375
+ * command-prefix words that itself bottoms out at one of those.
376
+ */
377
+ function startsCommand(s, idx) {
378
+ // The hit must be the whole command word. `PR_CREATE_RE` carries no leading
379
+ // `\b`, so it can land mid-token — and the backwards walk below would then
380
+ // read a TRUNCATED prefix as a command prefix: `dogh pr create` yields `do`,
381
+ // which is in PREFIX_WORDS, and `FOO=bargh pr create` yields `FOO=bar`, which
382
+ // matches ASSIGNMENT_RE. Both are one unrelated token; both used to over-match.
383
+ //
384
+ // A leading `\b` would not fix it on its own — `\b` admits `=`, so the command
385
+ // in `FOO=gh pr create` (which is `pr`, not `gh`) would still walk back over
386
+ // `FOO=` and report a command start.
387
+ //
388
+ // Checking the whole token rather than just the character before it also keeps
389
+ // an invocation BY PATH working: `/usr/bin/gh pr create`, `./gh pr create`, and
390
+ // on Windows `C:/tools/gh` or `/c/tools/gh` — the forms that are valid command
391
+ // words for the shell the Bash tool runs.
392
+ //
393
+ // Only `/` is accepted as the separator, deliberately. A `\` is an ESCAPE here
394
+ // and is blanked in pass 1, so it can never reach this token; accepting it too
395
+ // would be unreachable code. The cost is that a backslash-escaped invocation
396
+ // (`C:\tools\gh`, or `\gh`) is not recognised — a miss, not an over-block, and
397
+ // the same miss the pre-#1410 regex had. Blanking the escape is what makes
398
+ // `echo a\; gh pr create` correctly NOT read as a command, which is worth more.
399
+ var tokenStart = tokenStartAt(s, idx);
400
+ var token = s.slice(tokenStart, idx + 2);
401
+ if (token !== 'gh' && !/\/gh$/.test(token)) return false;
402
+
403
+ var i = tokenStart;
404
+ for (;;) {
405
+ while (i > 0 && isBlank(s.charAt(i - 1))) i--;
406
+ if (i === 0) return true;
407
+ if (SEPARATOR_CHARS.indexOf(s.charAt(i - 1)) !== -1) return true;
408
+ var end = i;
409
+ i = tokenStartAt(s, i);
410
+ if (i === end) return false;
411
+ var word = s.slice(i, end);
412
+ if (!ASSIGNMENT_RE.test(word) && PREFIX_WORDS.indexOf(word) === -1) return false;
413
+ }
414
+ }
415
+
416
+ /**
417
+ * True when `cmd` invokes `gh pr create` as a command — not when it merely
418
+ * contains those words inside a string, comment, or heredoc body.
419
+ *
420
+ * @param {string} cmd raw Bash command text (Claude Code's `tool_input.command`)
421
+ * @returns {boolean}
422
+ */
423
+ function isPrCreateCommand(cmd) {
424
+ // Fast reject before sanitising. These gates run on EVERY Bash call, and a
425
+ // large non-`gh` command would otherwise pay the full two-pass cost. `indexOf`
426
+ // first because it is the cheapest possible screen; the token check then
427
+ // discards the digraph false positives (`highlight`, `github`, `though`) that
428
+ // `indexOf` alone lets through.
429
+ if (typeof cmd !== 'string' || cmd.indexOf('gh') === -1) return false;
430
+ if (!GH_TOKEN_RE.test(cmd)) return false;
431
+ var s = sanitizeShellData(cmd);
432
+ PR_CREATE_RE.lastIndex = 0;
433
+ var m;
434
+ while ((m = PR_CREATE_RE.exec(s)) !== null) {
435
+ if (startsCommand(s, m.index)) return true;
436
+ }
437
+ return false;
438
+ }
439
+
440
+ module.exports = { isPrCreateCommand: isPrCreateCommand, sanitizeShellData: sanitizeShellData };
@@ -1334,13 +1334,18 @@ try {
1334
1334
  // prune when the consumer's file matches a known-shipped hash —
1335
1335
  // customized files (different hash) get preserved with a one-line
1336
1336
  // notice the user can act on.
1337
+ //
1338
+ // The package root is passed so entries can be cross-checked against
1339
+ // what moflo actually ships (#1414). A path can be retired and later
1340
+ // RESTORED under the same name; the stale entry then carries only
1341
+ // pre-deletion hashes, so the copy Mechanism A just synced matches
1342
+ // nothing and gets reported as a "customized retired file" on every
1343
+ // session — inviting the user to delete an agent the CLI still routes to.
1337
1344
  let prunedRetiredPaths = new Set();
1338
1345
  try {
1339
- const retiredManifestPath = resolve(
1340
- projectRoot,
1341
- 'node_modules/moflo/retired-files.json',
1342
- );
1343
- const report = applyRetiredPrune(projectRoot, retiredManifestPath);
1346
+ const mofloPackageRoot = resolve(projectRoot, 'node_modules', 'moflo');
1347
+ const retiredManifestPath = resolve(mofloPackageRoot, 'retired-files.json');
1348
+ const report = applyRetiredPrune(projectRoot, retiredManifestPath, mofloPackageRoot);
1344
1349
  prunedRetiredPaths = new Set(report.pruned);
1345
1350
  if (report.pruned.length > 0) {
1346
1351
  emitMutation(
@@ -1365,7 +1370,7 @@ try {
1365
1370
  if (report.preservedDetails.length > 0) {
1366
1371
  try {
1367
1372
  recordVersion = JSON.parse(readFileSync(
1368
- resolve(projectRoot, 'node_modules/moflo/package.json'), 'utf-8',
1373
+ resolve(mofloPackageRoot, 'package.json'), 'utf-8',
1369
1374
  )).version;
1370
1375
  } catch { /* record just omits the version */ }
1371
1376
  }
@@ -3,12 +3,13 @@
3
3
  *
4
4
  * Represents a detected security threat from AI manipulation attempts.
5
5
  */
6
+ import { generateId } from '../../../shared/utils/id.js';
6
7
  /**
7
8
  * Factory function to create a Threat entity
8
9
  */
9
10
  export function createThreat(params) {
10
11
  return {
11
- id: `threat-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
12
+ id: generateId('threat'),
12
13
  type: params.type,
13
14
  severity: params.severity,
14
15
  confidence: params.confidence,
@@ -19,6 +19,7 @@ import { pathToFileURL } from 'url';
19
19
  import { findProjectRoot as findConsumerProjectDir } from '../services/project-root.js';
20
20
  import { findMofloPackageRoot } from '../services/moflo-require.js';
21
21
  import { errorDetail } from '../shared/utils/error-detail.js';
22
+ import { generateId } from '../shared/utils/id.js';
22
23
  // ============================================================================
23
24
  // Path Resolution
24
25
  // ============================================================================
@@ -75,7 +76,7 @@ export async function checkSubagentHealth() {
75
76
  // Spawn a test agent
76
77
  const spawnResult = await spawnTool.handler({
77
78
  agentType: 'tester',
78
- id: `doctor-probe-${Date.now()}`,
79
+ id: generateId('doctor-probe'),
79
80
  priority: 'low',
80
81
  metadata: { purpose: 'doctor-health-check' },
81
82
  }, {});
@@ -149,7 +150,7 @@ steps:
149
150
  output: result
150
151
  `;
151
152
  const result = await runSpellFromContent(minimalSpell, 'doctor-probe.yaml', {
152
- spellId: `doctor-probe-${Date.now()}`,
153
+ spellId: generateId('doctor-probe'),
153
154
  timeout: 10_000,
154
155
  });
155
156
  if (!result) {
@@ -28,6 +28,7 @@ import { memoryDbPath } from '../services/moflo-paths.js';
28
28
  import { findProjectRoot } from '../services/project-root.js';
29
29
  import { purgeMemoryProbeNamespaces } from '../services/ephemeral-namespace-purge.js';
30
30
  import { loadToolArrays, getTool, pushDetail, summarizeFunctional, } from './doctor-checks-functional-shared.js';
31
+ import { randomSuffix } from '../shared/utils/id.js';
31
32
  const MEMORY_ACCESS_CHECK = 'Memory Access Functional';
32
33
  const MEMORY_ACCESS_FAIL_FIX = 'Run `flo doctor --json` for per-subcheck details. Common fixes: ensure fastembed installed (memory_store.hasEmbedding=false), explicit threshold:0 honored (#837), or rebuild HNSW index (`flo memory rebuild-index`)';
33
34
  async function invokeOrThrow(tools, name, input) {
@@ -49,7 +50,7 @@ export async function _runMemoryRoundTripForTest(ctx) {
49
50
  return runMemoryRoundTrip(ctx);
50
51
  }
51
52
  async function runMemoryRoundTrip(ctx) {
52
- const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
53
+ const stamp = `${Date.now()}-${randomSuffix()}`;
53
54
  const key = `doctor-memprobe-${ctx.persona}-${stamp}`;
54
55
  const namespace = `doctor-memprobe-${ctx.persona}`;
55
56
  const sentinel = `memprobe-${ctx.persona}-${stamp}`;
@@ -286,7 +287,7 @@ async function probeMemoryGetNeighbors(memoryTools, details) {
286
287
  observed: { registered: true },
287
288
  expected: 'memory_get_neighbors registered',
288
289
  });
289
- const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
290
+ const stamp = `${Date.now()}-${randomSuffix()}`;
290
291
  const namespace = `doctor-neighbors-${stamp}`;
291
292
  const prefix = `chunk-doctor-neighbors-${stamp}`;
292
293
  const chunkKeys = [`${prefix}-0`, `${prefix}-1`, `${prefix}-2`];
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { errorDetail } from '../shared/utils/error-detail.js';
15
15
  import { loadToolArrays, getTool, safeInvoke, summarizeFunctional, } from './doctor-checks-functional-shared.js';
16
+ import { generateId } from '../shared/utils/id.js';
16
17
  const SWARM_FUNCTIONAL_CHECK = 'Swarm Functional';
17
18
  const HIVE_FUNCTIONAL_CHECK = 'Hive-Mind Functional';
18
19
  const SWARM_FAIL_FIX = 'Run `flo doctor --json` for per-subcheck details; investigate any handler that disconnected from UnifiedSwarmCoordinator (epic #798)';
@@ -450,8 +451,8 @@ export async function checkHiveMindFunctional() {
450
451
  // 6. hive-mind_memory roundtrips via Memory DB write-through.
451
452
  // Memory backend unavailable (e.g. fresh smoke fixture before `memory init`)
452
453
  // is environmental, not a regression — softFailMessage downgrades to warn.
453
- const probeKey = `doctor-probe-${Date.now()}`;
454
- const sentinel = `hive-doctor-${Date.now()}`;
454
+ const probeKey = generateId('doctor-probe');
455
+ const sentinel = generateId('hive-doctor');
455
456
  const setOut = await safeInvoke(hiveMindTools, 'hive-mind_memory', {
456
457
  action: 'set', key: probeKey, value: { sentinel },
457
458
  }, details, {
@@ -11,6 +11,7 @@ import { errorDetail } from '../shared/utils/error-detail.js';
11
11
  // #1315 — the intelligence/maturity probes below read `.moflo/` state; anchor
12
12
  // them at the resolved root so a subdirectory invocation doesn't report zeroes.
13
13
  import { resolveStateRoot } from '../services/project-root.js';
14
+ import { generateId } from '../shared/utils/id.js';
14
15
  // Hook types
15
16
  const HOOK_TYPES = [
16
17
  { value: 'pre-edit', label: 'Pre-Edit', hint: 'Get context before editing files' },
@@ -1204,7 +1205,7 @@ const postTaskCommand = {
1204
1205
  ],
1205
1206
  action: async (ctx) => {
1206
1207
  // Auto-generate task ID if not provided
1207
- const taskId = ctx.flags.taskId || `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
1208
+ const taskId = ctx.flags.taskId || generateId('task', { separator: '_' });
1208
1209
  // Default success to true for backward compatibility
1209
1210
  const success = ctx.flags.success !== undefined ? ctx.flags.success : true;
1210
1211
  output.printInfo(`Recording outcome for task: ${output.highlight(taskId)}`);
@@ -11,6 +11,7 @@ import { getServerManager, getMCPServerStatus, } from '../mcp-server.js';
11
11
  import { listMCPTools, callMCPTool, hasTool } from '../mcp-client.js';
12
12
  import { acquireDaemonLock, releaseDaemonLock, getDaemonLockHolder } from '../services/daemon-lock.js';
13
13
  import { readMofloEnv } from '../services/env-compat.js';
14
+ import { generateId } from '../shared/utils/id.js';
14
15
  // MCP tool categories offered by `flo mcp tools --category`.
15
16
  //
16
17
  // #1353 removed the `github` entry along with the four tools that were the
@@ -494,8 +495,8 @@ const execCommand = {
494
495
  }
495
496
  const startTime = performance.now();
496
497
  const result = await callMCPTool(tool, params, {
497
- sessionId: `cli-${Date.now().toString(36)}`,
498
- requestId: `exec-${Date.now()}`,
498
+ sessionId: generateId('cli', { base36Time: true }),
499
+ requestId: generateId('exec'),
499
500
  });
500
501
  const duration = performance.now() - startTime;
501
502
  output.writeln();
@@ -13,6 +13,7 @@ import { errorDetail } from '../shared/utils/error-detail.js';
13
13
  import { memoryDbPath } from '../services/moflo-paths.js';
14
14
  import { resolveBridgeDbPath } from '../memory/bridge-core.js';
15
15
  import { findProjectRoot } from '../services/project-root.js';
16
+ import { generateId } from '../shared/utils/id.js';
16
17
  // Memory backends
17
18
  const BACKENDS = [
18
19
  { value: 'agentdb', label: 'AgentDB', hint: 'Vector database with HNSW approximate-nearest-neighbor (ANN) indexing' },
@@ -1327,7 +1328,7 @@ function saveAndCloseDb(db, _dbPath) {
1327
1328
  db.close();
1328
1329
  }
1329
1330
  function batchGenerateId() {
1330
- return `mem_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
1331
+ return generateId('mem', { separator: '_' });
1331
1332
  }
1332
1333
  function batchStoreEntry(db, key, namespace, content, metadata = {}, tags = [], embedding, embeddingModel, embeddingDimensions) {
1333
1334
  const now = Date.now();
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { output } from '../output.js';
8
8
  import { readLoadAverage, NOT_MEASURED } from '../shared/utils/load-average.js';
9
+ import { generateId } from '../shared/utils/id.js';
9
10
  // Benchmark subcommand - REAL measurements
10
11
  const benchmarkCommand = {
11
12
  name: 'benchmark',
@@ -165,7 +166,7 @@ const benchmarkCommand = {
165
166
  for (let i = 0; i < Math.min(iterations, 20); i++) {
166
167
  const start = performance.now();
167
168
  await storeEntry({
168
- key: `bench_${Date.now()}_${i}`,
169
+ key: generateId('bench', { separator: '_' }),
169
170
  value: `Benchmark test entry ${i} with some content for testing storage performance`,
170
171
  namespace: 'benchmark',
171
172
  generateEmbeddingFlag: true,
@@ -57,7 +57,7 @@ export const retireCommand = {
57
57
  },
58
58
  {
59
59
  name: 'rebuild-hashes',
60
- description: 'Recompute knownContentHashes[] for every existing entry from full git history (#1133 backfill)',
60
+ description: 'Recompute knownContentHashes[] for every entry from full git history, and drop entries whose path is back in the tree (#1133 backfill, #1414 heal)',
61
61
  type: 'boolean',
62
62
  default: false,
63
63
  },
@@ -19,6 +19,7 @@ import { checkScheduleAcceptance } from '../services/schedule-acceptance-check.j
19
19
  import { reconcileDaemonAutostart } from '../services/daemon-autostart-lifecycle.js';
20
20
  import { isDaemonInstalled } from '../services/daemon-service.js';
21
21
  import { validateSchedule, computeNextRun } from '../spells/scheduler/cron-parser.js';
22
+ import { randomSuffix } from '../shared/utils/id.js';
22
23
  const NAMESPACE_SCHEDULES = 'scheduled-spells';
23
24
  const NAMESPACE_EXECUTIONS = 'schedule-executions';
24
25
  const DEFAULT_EXECUTIONS_LIMIT = 10;
@@ -135,7 +136,7 @@ const createCommand = {
135
136
  output.printWarning(acceptance.message);
136
137
  }
137
138
  // Always create the schedule, regardless of daemon state
138
- const id = `sched-adhoc-${now}-${Math.random().toString(36).slice(2, 8)}`;
139
+ const id = `sched-adhoc-${now}-${randomSuffix()}`;
139
140
  const record = {
140
141
  id,
141
142
  spellName: name,