moflo 4.12.8 → 4.12.10

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.
package/bin/gate.cjs CHANGED
@@ -240,10 +240,11 @@ function isEphemeralPath(fp) {
240
240
  }
241
241
  // #1171 — DANGEROUS gained PowerShell additions to match the matcher widening
242
242
  // that now routes the dedicated `PowerShell` tool through check-dangerous-command.
243
- // POSIX entries still apply because PS will execute them when invoked. Substring
244
- // match (case-insensitive) inside the gate.
243
+ // POSIX entries still apply because PS will execute them when invoked. Matched
244
+ // case-insensitively by `matchesDangerous` below — substring for most entries,
245
+ // root-anchored for the ones that end at a filesystem root.
245
246
  var DANGEROUS = [
246
- 'rm -rf /', 'format c:', 'del /s /q c:\\', ':(){:|:&};:', 'mkfs.', '> /dev/sda',
247
+ 'rm -rf /', 'rm -rf ~', 'format c:', 'del /s /q c:\\', ':(){:|:&};:', 'mkfs.', '> /dev/sda',
247
248
  // PowerShell destructive patterns. Won't catch every adversarial spelling
248
249
  // (PS aliases let `ri -r -force C:\` mean the same thing) but covers the
249
250
  // common-typo destruction class — symmetric to the POSIX list's intent.
@@ -254,6 +255,74 @@ var DANGEROUS = [
254
255
  'clear-disk',
255
256
  ];
256
257
 
258
+ // #1449 — the entries above that END at a filesystem root are also a PREFIX of
259
+ // every absolute path beneath it, so plain substring matching blocked routine
260
+ // cleanup: `rm -rf /tmp/scratch` reported as `rm -rf /`. Those entries must
261
+ // match ON the root rather than at the head of a longer path.
262
+ //
263
+ // WHICH entries those are is derived, not listed: a pattern is root-shaped iff
264
+ // it ends at a root — a separator or `~`. That selects `rm -rf /`, `rm -rf ~`,
265
+ // `del /s /q c:\` and the three `remove-item` forms, and leaves `format c:`,
266
+ // `mkfs.`, `> /dev/sda`, `format-volume` and `clear-disk` on plain substring
267
+ // matching, where trailing text is still the same dangerous command. Deriving
268
+ // it rather than keeping a parallel list means a future root-shaped addition to
269
+ // DANGEROUS is anchored automatically instead of silently falling back to the
270
+ // prefix bug this fixes.
271
+ //
272
+ // `rm -rf ~` joins DANGEROUS with this change: it was never listed, so wiping a
273
+ // home directory was allowed outright while cleaning a subdirectory of one was
274
+ // blocked. Anchoring is what makes the entry safe to add.
275
+ function endsAtRoot(pat) {
276
+ var last = pat.charAt(pat.length - 1);
277
+ return last === '/' || last === '\\' || last === '~';
278
+ }
279
+ // What may legally follow a root target: nothing, whitespace, a glob (`rm -rf /*`
280
+ // still blocks), a shell operator, or a closing quote/paren. A path character —
281
+ // letter, digit, `-`, `_` — means another segment follows, i.e. routine cleanup
282
+ // of something below root. Rule #1: pure string logic, no platform branch; both
283
+ // separators are handled for every OS's spelling.
284
+ var ROOT_BOUNDARY_RE = /[\s;&|)"'`*<>]/;
285
+ function isRootBoundary(cmd, i) {
286
+ if (i >= cmd.length) return true;
287
+ var c = cmd.charAt(i);
288
+ return c === '/' || c === '\\' || ROOT_BOUNDARY_RE.test(c);
289
+ }
290
+
291
+ /**
292
+ * Advance past text that does not move OFF the root: repeated separators, and
293
+ * `.` / `..` segments, which resolve back to where they started. `rm -rf //`,
294
+ * `rm -rf /.` and `rm -rf /..` all still mean `rm -rf /` and must block, while
295
+ * `rm -rf //tmp/x` and `rm -rf /.config` are real paths below root and must not.
296
+ * Without the dot handling the anchoring would OPEN a hole the substring match
297
+ * did not have — `.` is not a boundary character, so `rm -rf /.` would read as
298
+ * "a longer path follows" and pass.
299
+ */
300
+ function skipToRootEnd(cmd, i) {
301
+ for (;;) {
302
+ var start = i;
303
+ while (i < cmd.length && (cmd.charAt(i) === '/' || cmd.charAt(i) === '\\')) i++;
304
+ var dots = 0;
305
+ while (cmd.charAt(i + dots) === '.') dots++;
306
+ // Only a BARE `.` or `..` segment is a no-op; `.config` and `..foo` are names.
307
+ if ((dots === 1 || dots === 2) && isRootBoundary(cmd, i + dots)) i += dots;
308
+ if (i === start) return i;
309
+ }
310
+ }
311
+
312
+ /**
313
+ * True when `pat` occurs in `cmd` as a real dangerous command. Root-shaped
314
+ * patterns must land on the root; every occurrence is examined, so a safe
315
+ * leading match (`rm -rf /tmp/a && rm -rf /`) never masks a later real one.
316
+ */
317
+ function matchesDangerous(cmd, pat) {
318
+ if (!endsAtRoot(pat)) return cmd.indexOf(pat) >= 0;
319
+ for (var at = cmd.indexOf(pat); at >= 0; at = cmd.indexOf(pat, at + 1)) {
320
+ var end = skipToRootEnd(cmd, at + pat.length);
321
+ if (end >= cmd.length || ROOT_BOUNDARY_RE.test(cmd.charAt(end))) return true;
322
+ }
323
+ return false;
324
+ }
325
+
257
326
  // #1132 — Bash memory-first gate.
258
327
  //
259
328
  // CREDIT: marks the gate satisfied when Claude invokes a memory-search CLI
@@ -395,6 +464,60 @@ function bashCoordinationInit(rawCmd) {
395
464
  }
396
465
  return null;
397
466
  }
467
+ // #1445 — read shapes whose LEADING command is one BASH_CARVE_OUT_RE carves out
468
+ // wholesale (`node …`, `git …`). They are read-like because of what FOLLOWS the
469
+ // leading token, so anchoring on that token alone can never see them: in one
470
+ // real session 539 file-exploration calls went through `node -e` + readFileSync
471
+ // and `git show <ref>:<path>` with the gate blind to every one of them. That is
472
+ // the dominant exploration shape in repos whose own guidance steers away from
473
+ // `grep` (e.g. where `grep` is `ugrep` and silently skips files, so any
474
+ // inventory a decision rests on must go through `fs.readFileSync`).
475
+ //
476
+ // These sources are spliced into READ_LIKE_BASH_RE below AND kept addressable
477
+ // on their own, because check-bash-memory has to let them override the
478
+ // carve-out — extending READ_LIKE alone would change nothing, the `^\s*(node|
479
+ // git)\s` carve-out arms swallow them one line later.
480
+ var RUNNER_READ_SOURCES = [
481
+ // `node -e "...readFileSync(...)..."`, in every eval spelling (-e/--eval/-p/
482
+ // --print/--input-type=module -e). The eval FLAG is deliberately not required:
483
+ // a script-file invocation (`node scripts/build.mjs`) does not carry a read
484
+ // call in its command line, so the read call itself is the reliable signal and
485
+ // demanding the flag only adds spellings to miss.
486
+ //
487
+ // The negative lookahead is load-bearing, not caution: Rule #1 tells authors
488
+ // to do cross-platform file OPS through `node -e` with fs (`mkdir`/`rm`/`cp`
489
+ // do not exist on Windows), and a read-modify-write is an operation, not
490
+ // exploration. Only fs MUTATION calls carve out — matching a bare `write`
491
+ // would carve out `process.stdout.write`, which nearly every inline read
492
+ // script ends with, and that would hand back the whole gap.
493
+ //
494
+ // Every mutation name covers its sync, callback AND promise spelling: authors
495
+ // reach for `fs.promises.rm` as readily as `rmSync`, and listing only the
496
+ // `*Sync` form would block the delete op Rule #1 sent them to `node -e` for.
497
+ // `rm`/`rmdir`/`cp` carry `\b` because they are short enough to appear inside
498
+ // unrelated words — an unanchored `rm` matches "format" and "transform", and
499
+ // would carve out most of what this arm exists to catch.
500
+ '^\\s*(?:node|nodejs)(?:\\.(?:exe|cmd))?\\s'
501
+ + '(?=[\\s\\S]*(?:read(?:File|dir)|globSync))'
502
+ + '(?![\\s\\S]*(?:writeFile|appendFile|mkdir|mkdtemp|unlink|copyFile|rename'
503
+ + '|symlink|createWriteStream|\\brm(?:dir)?(?:Sync)?\\b|\\bcp(?:Sync)?\\b))',
504
+ // `git show <ref>:<path>` / `git cat-file -p <ref>:<path>` — the colon form
505
+ // reads a blob and is `cat` by another name. Leading `-`-prefixed tokens are
506
+ // consumed as flags first so `--pretty=format:%h` (a colon inside a FLAG)
507
+ // cannot masquerade as a ref:path, and the segment after the colon must carry
508
+ // a path character — same false-negative trade as the `type \S*[\\/.]` arm
509
+ // above (`git show HEAD:src`, a bare directory, passes; source files all have
510
+ // extensions). Bare `git show`, `git show --stat`, `git log` and `git diff`
511
+ // have no ref:path token and stay operational.
512
+ '^\\s*git\\s+(?:show|cat-file)\\s+(?:-{1,2}\\S+\\s+)*[^-\\s]\\S*:\\S*[\\\\/.]',
513
+ // Repo-wide content search and file inventory — the `git` spellings of
514
+ // `grep -r` and `find`. NOT `git log --grep`, which searches commit messages,
515
+ // is already in the passing set, and does not match `git\s+grep`.
516
+ '^\\s*git\\s+(?:grep|ls-files)\\b',
517
+ ];
518
+ // Same sources as their own matcher — check-bash-memory tests this to let a
519
+ // carved-out leading command still block when the rest of the command is a read.
520
+ var RUNNER_READ_BASH_RE = new RegExp(RUNNER_READ_SOURCES.join('|'), 'i');
398
521
  // BLOCK: read-like Bash commands that bypass the existing check-before-read /
399
522
  // check-before-scan gates by going through the shell. Anchored to the start of
400
523
  // the line so subcommands inside pipelines or `npm install grep` don't trip.
@@ -427,7 +550,7 @@ var READ_LIKE_BASH_RE = new RegExp([
427
550
  '^\\s*dir\\b[^|]*\\s\\/[sS]\\b',
428
551
  // #1171 — PowerShell hex dump, parallel to POSIX `xxd`/`hexdump`.
429
552
  '^\\s*Format-Hex\\b',
430
- ].join('|'), 'i');
553
+ ].concat(RUNNER_READ_SOURCES).join('|'), 'i');
431
554
  // CARVE-OUT: commands that LOOK read-like but are operational. Anchored to the
432
555
  // LEADING command — the pipe-filter case (`npm test | grep FAIL`) is already
433
556
  // handled by READ_LIKE's `^\s*` anchor never matching the leading `npm`, so
@@ -474,8 +597,73 @@ function stripQuotedAndHeredocs(cmd) {
474
597
  return out;
475
598
  }
476
599
 
477
- var DIRECTIVE_RE = /^(yes|no|yeah|yep|nope|sure|ok|okay|correct|right|exactly|perfect)\b/i;
478
- var TASK_RE = /\b(fix|bug|error|implement|add|create|build|write|refactor|debug|test|feature|issue|security|optimi)\b/i;
600
+ // #1447 — words that carry no subject to search FOR: assent, acknowledgement,
601
+ // "keep going", and conversational filler. A prompt built ONLY from these
602
+ // continues work already under way, so the memory gate stays down; anything
603
+ // that survives the strip is a subject, and the gate arms.
604
+ //
605
+ // Supersedes two things. `DIRECTIVE_RE` — a `^(yes|ok|sure|…)` prefix test that
606
+ // had already been dead code for some time, unreferenced by the reset it was
607
+ // written for. And the live rule, `TASK_RE.test(p) || p.length > 20`, whose
608
+ // real work was skipping short replies: every prompt of 20 characters or fewer
609
+ // without a task word was exempt. That caught trivia ("hmm", "got it") and
610
+ // equally caught "check the daemon" — a genuine topic change — which is the
611
+ // half this replaces. The list below has to carry the trivia half on its own,
612
+ // so it covers considerably more ground than DIRECTIVE_RE ever did.
613
+ //
614
+ // Matching by SUBTRACTION, not by a leading-token test, is what lets
615
+ // "yes, now fix the daemon" arm while "yes" does not — a `^(yes|ok)\b` test
616
+ // sees the same first word in both. It is also why the list can safely hold
617
+ // ordinary words like `do`/`it`/`work`/`the`: a word only exempts a prompt when
618
+ // NOTHING else in that prompt survives, so each addition costs precision only
619
+ // for prompts made entirely of listed words. Deliberately absent for that
620
+ // reason: task words (`fix`, `test`, `build`) and any noun.
621
+ var CONTINUATION_WORD_RE = new RegExp('\\b(?:' + [
622
+ // Assent / dissent / acknowledgement
623
+ 'yes', 'yeah', 'yep', 'yup', 'no', 'nope', 'sure', 'ok', 'okay', 'k',
624
+ 'correct', 'right', 'exactly', 'perfect', 'agreed', 'true', 'indeed',
625
+ 'understood', 'gotcha', 'got', 'makes', 'sense', 'fine', 'alright',
626
+ // Continuation / assent to proceed
627
+ 'continue', 'proceed', 'carry', 'keep', 'going', 'go', 'ahead', 'on', 'next',
628
+ 'again', 'more', 'rest', 'both', 'all', 'them', 'those',
629
+ // Politeness and praise
630
+ 'please', 'thanks', 'thank', 'ty', 'great', 'nice', 'cool', 'good', 'awesome',
631
+ 'excellent', 'sounds', 'lgtm', 'love', 'well', 'work', 'job', 'wow', 'yay',
632
+ // Conversational filler / hesitation / greetings
633
+ 'hmm', 'hm', 'huh', 'ah', 'oh', 'ha', 'haha', 'lol', 'wait', 'hold', 'hang',
634
+ 'actually', 'anyway', 'whatever', 'nvm', 'nevermind', 'sorry', 'oops',
635
+ 'hi', 'hello', 'hey', 'stop', 'pause', 'never', 'mind',
636
+ 'think', 'know', 'see', 'guess', 'suppose', 'maybe', 'probably',
637
+ // Function words with no subject of their own
638
+ 'do', 'it', 'that', 'this', 'the', 'a', 'an', 'and', 'is', 'are', 'was',
639
+ 'you', 'your', 'i', 'we', 'lets', 'let', 'us', 'me', 'my', 'now', 'then',
640
+ 'done', 'finish', 'finished', 'ready', 'too', 'also', 'just', 'still',
641
+ ].join('|') + ')\\b', 'gi');
642
+ /**
643
+ * Does this prompt consist of nothing but continuation filler?
644
+ *
645
+ * An EMPTY prompt answers true — a prompt the gate cannot see is not evidence
646
+ * that a search is needed, and arming on it would block every read in a
647
+ * consumer whose host omits the field, with nothing on screen explaining why.
648
+ * Fail-open here; `prompt-state-reset` separately refuses to WRITE a verdict it
649
+ * derived from an empty prompt (#1447), so an unreadable prompt now leaves the
650
+ * gate exactly as prompt-reminder set it rather than silently disarming it.
651
+ */
652
+ function isContinuationPrompt(promptText) {
653
+ var t = (promptText || '').trim();
654
+ if (!t) return true;
655
+ // Subtract continuation words, then every non-alphanumeric character. What
656
+ // remains is the prompt's actual subject matter; nothing remaining means the
657
+ // prompt introduced no new subject.
658
+ // `\p{L}\p{N}` with /u, NOT `A-Za-z0-9`: an ASCII-only class strips every
659
+ // character of a CJK, Cyrillic, Arabic, Hebrew, Greek or Thai prompt — and
660
+ // most of an accented French or Spanish one — leaving nothing, so a
661
+ // substantive non-English request would score as pure filler and silently
662
+ // disarm the gate. Stripping only what is neither letter nor number keeps
663
+ // every script's content intact and removes just punctuation and spacing.
664
+ var rest = t.replace(CONTINUATION_WORD_RE, ' ').replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
665
+ return rest.length === 0;
666
+ }
479
667
 
480
668
  // Namespace classification (#931). The hint used to be emitted on every prompt
481
669
  // by prompt-hook.mjs which cost ~40 tokens × every prompt × every consumer.
@@ -510,7 +698,9 @@ var NS_NAV_RES = [
510
698
  // explicitly opted in to the protected coordination surface, so falling back to
511
699
  // raw Agent dispatch silently regresses headline moflo product capability.
512
700
  //
513
- // SYNC: duplicated verbatim in src/cli/init/helpers-generator.ts.
701
+ // This file is the single source; #1443 replaced helpers-generator.ts's
702
+ // hand-maintained copy with a build-time embed of this exact file, so there is
703
+ // no longer a second copy of this function to keep in step.
514
704
  function detectFlMode(promptText) {
515
705
  var p = promptText || '';
516
706
  if (!/^\s*\/(?:fl|flo)\b/i.test(p)) return null;
@@ -653,10 +843,14 @@ function classifyNamespaceHint(promptText) {
653
843
  // full sentence in the same shape as classifyNamespaceHint so the BLOCK arm
654
844
  // can write either source's hint without branching on format.
655
845
  //
656
- // SYNC: duplicated verbatim in src/cli/init/helpers-generator.ts.
846
+ // This file is the single source — see the note on detectFlMode above (#1443).
657
847
  function classifyBashNamespaceHint(cmd) {
658
848
  // Search-like tools — the user is hunting for a symbol/file, code-map wins.
659
- if (/^\s*(?:grep|rg|ag|fgrep|egrep|find|fd|Select-String|sls)\b/i.test(cmd)) {
849
+ // #1445 — `git grep` / `git ls-files` are the same hunt through a different
850
+ // binary, so they route to the same namespace rather than falling through to
851
+ // the hintless generic message.
852
+ if (/^\s*(?:grep|rg|ag|fgrep|egrep|find|fd|Select-String|sls)\b/i.test(cmd)
853
+ || /^\s*git\s+(?:grep|ls-files)\b/i.test(cmd)) {
660
854
  return 'Memory namespace hint: use "code-map" for codebase navigation.';
661
855
  }
662
856
  // Reading a .md / RST / TXT, or a well-known doc file — guidance/learnings win.
@@ -674,7 +868,7 @@ function classifyBashNamespaceHint(cmd) {
674
868
  // UserPromptSubmit hooks can run it without compounding any field. Caller
675
869
  // owns interactionCount and the user-visible REMINDER/Context emissions, so
676
870
  // this helper stays silent.
677
- function applyPromptStateReset(state, promptText) {
871
+ function applyPromptStateReset(state, promptText, opts) {
678
872
  // #352/#1331 — this is the ONLY place the memory gate resets. Deliberately
679
873
  // NOT on task transitions: within a single prompt (e.g. a /flo workflow)
680
874
  // memory stays searched so Read/Grep aren't blocked mid-execution. A
@@ -688,9 +882,24 @@ function applyPromptStateReset(state, promptText) {
688
882
  state.memorySearchedBy = {};
689
883
  // learningsStored is session-scoped — once stored, it stays true until session reset.
690
884
  // Resetting per-prompt caused false blocks when PR creation was on a later prompt.
691
- var DIRECTIVE_MAX_LEN = 20;
692
- var escaped = /^@@\s*/.test(promptText || '');
693
- state.memoryRequired = !escaped && (promptText || '').length >= 4 && (TASK_RE.test(promptText || '') || (promptText || '').length > DIRECTIVE_MAX_LEN);
885
+ // #1447 — arm by DEFAULT; exempt only prompts that carry no subject of their
886
+ // own. This replaces `TASK_RE.test(p) || p.length > 20`, whose length cliff
887
+ // was arbitrary and invisible: "now look at the daemon" (22) armed the gate
888
+ // and "check the daemon" (16) did not, which is most of why the gate felt
889
+ // like it fired at random. Memory-first is this project's first rule, so the
890
+ // default has to be "search", with continuations as the carve-out — not the
891
+ // reverse. The per-prompt latch bounds the cost at one search per new prompt.
892
+ //
893
+ // `opts.skipArming` splits the reset in two. Invalidating the credits above
894
+ // is prompt-INDEPENDENT and always correct; deciding whether this prompt
895
+ // needs a search is not, and a caller holding no prompt text must not decide
896
+ // it from nothing. Skipping the whole reset instead would be worse than
897
+ // either: `memorySearchedBy` would survive, and a stale per-actor credit from
898
+ // the previous prompt would satisfy the gate for a prompt it never saw.
899
+ if (!(opts && opts.skipArming)) {
900
+ var escaped = /^@@\s*/.test(promptText || '');
901
+ state.memoryRequired = !escaped && !isContinuationPrompt(promptText);
902
+ }
694
903
  // Stash namespace hint for check-before-agent to emit when Claude actually
695
904
  // spawns an Agent (#931). Empty string when nothing matched — overwriting
696
905
  // any stale value from the previous prompt.
@@ -1051,11 +1260,32 @@ function isPrCreateCommand(cmd) {
1051
1260
  // Fail-safe: any error (no classifier, no git, no merge-base) returns null,
1052
1261
  // which forces /simplify to run as today.
1053
1262
  function classifyForGateSkip(state) {
1054
- var classify;
1263
+ var mod;
1055
1264
  try {
1056
- classify = require('./simplify-classify.cjs').classifyDiff;
1265
+ mod = require('./simplify-classify.cjs');
1057
1266
  } catch (e) { return null; }
1058
- if (typeof classify !== 'function') return null;
1267
+ var classify = mod && mod.classifyDiff;
1268
+ var readUntracked = mod && mod.readUntrackedDiff;
1269
+ // EXEC_MAX_BUFFER is checked alongside the functions because falling back to
1270
+ // Node's 1 MiB default would silently reinstate the very cliff #1451 removed.
1271
+ if (typeof classify !== 'function' || typeof readUntracked !== 'function'
1272
+ || typeof mod.EXEC_MAX_BUFFER !== 'number') return null;
1273
+
1274
+ // Untracked files show up in no `git diff` output, so without them the gate
1275
+ // could auto-pass a branch of brand-new unstaged files as TRIVIAL (#1451).
1276
+ // Reading every one of them is real work, so it is deferred until a path is
1277
+ // actually about to classify. Returns null if the read failed — the caller
1278
+ // must then fall through and force /simplify rather than classify a partial
1279
+ // diff.
1280
+ var untrackedText = null;
1281
+ function untrackedSuffix() {
1282
+ if (untrackedText !== null) return untrackedText;
1283
+ var u;
1284
+ try { u = readUntracked(PROJECT_DIR); } catch (e) { return null; }
1285
+ if (!u || u.unreadable) return null;
1286
+ untrackedText = u.text ? '\n' + u.text : '';
1287
+ return untrackedText;
1288
+ }
1059
1289
 
1060
1290
  function tryClassify(diffText, label, allowSmallReviewFix) {
1061
1291
  try {
@@ -1079,11 +1309,16 @@ function classifyForGateSkip(state) {
1079
1309
  return null;
1080
1310
  }
1081
1311
 
1312
+ // maxBuffer comes FROM the classifier (#1451) rather than being a matching
1313
+ // literal here, so the gate and the skill cannot drift on which diffs are
1314
+ // readable at all. Past it execFileSync throws ENOBUFS and gitDiff returns
1315
+ // null — which every caller below must treat as "unknown", never "empty".
1316
+ var maxBuffer = mod.EXEC_MAX_BUFFER;
1082
1317
  function gitDiff(args) {
1083
1318
  try {
1084
1319
  return cp.execFileSync('git', args, {
1085
1320
  cwd: PROJECT_DIR, encoding: 'utf-8', timeout: 5000, windowsHide: true,
1086
- stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 8 * 1024 * 1024
1321
+ stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: maxBuffer
1087
1322
  });
1088
1323
  } catch (e) { return null; }
1089
1324
  }
@@ -1091,9 +1326,14 @@ function classifyForGateSkip(state) {
1091
1326
  // Snapshot path: classify everything since /simplify last ran.
1092
1327
  if (state.simplifySnapshotSha) {
1093
1328
  var snapDiff = gitDiff(['diff', state.simplifySnapshotSha + '...HEAD']);
1094
- var workTreeA = gitDiff(['diff', 'HEAD']) || '';
1095
- if (snapDiff !== null) {
1096
- var combined = snapDiff + (workTreeA ? '\n' + workTreeA : '');
1329
+ var workTreeA = gitDiff(['diff', 'HEAD']);
1330
+ // BOTH reads must succeed. Coalescing a failed working-tree read to '' is
1331
+ // how an over-buffer working tree used to read as "no working-tree changes"
1332
+ // and let the gate skip review (#1451).
1333
+ if (snapDiff !== null && workTreeA !== null) {
1334
+ var suffixA = untrackedSuffix();
1335
+ if (suffixA === null) return null;
1336
+ var combined = snapDiff + (workTreeA ? '\n' + workTreeA : '') + suffixA;
1097
1337
  // Snapshot path: allow SMALL review-fix shape because the original /simplify
1098
1338
  // already covered the surface and only tiny no-decl-touching tweaks followed.
1099
1339
  var hit = tryClassify(combined, 'delta since last /simplify', true);
@@ -1113,9 +1353,11 @@ function classifyForGateSkip(state) {
1113
1353
  } catch (e) { continue; }
1114
1354
  if (!base) continue;
1115
1355
  var branchDiff = gitDiff(['diff', base + '...HEAD']);
1116
- var workTreeB = gitDiff(['diff', 'HEAD']) || '';
1117
- if (branchDiff !== null) {
1118
- return tryClassify(branchDiff + (workTreeB ? '\n' + workTreeB : ''), 'branch diff');
1356
+ var workTreeB = gitDiff(['diff', 'HEAD']);
1357
+ if (branchDiff !== null && workTreeB !== null) {
1358
+ var suffixB = untrackedSuffix();
1359
+ if (suffixB === null) return null;
1360
+ return tryClassify(branchDiff + (workTreeB ? '\n' + workTreeB : '') + suffixB, 'branch diff');
1119
1361
  }
1120
1362
  break;
1121
1363
  }
@@ -1454,7 +1696,12 @@ switch (command) {
1454
1696
  // → state read → memory gate.
1455
1697
  if (!config.memory_first) break;
1456
1698
  if (!READ_LIKE_BASH_RE.test(cmd)) break;
1457
- if (BASH_CARVE_OUT_RE.test(cmd)) break;
1699
+ // #1445 — the carve-out is anchored to the LEADING command, so its `node`
1700
+ // and `git` arms swallow every shape RUNNER_READ_SOURCES exists to catch.
1701
+ // Those shapes earn their read classification from what follows the leading
1702
+ // token, so they override the carve-out; every other command keeps #1132's
1703
+ // semantics exactly (`npm test | grep FAIL`, `git diff`, `node build.mjs`).
1704
+ if (BASH_CARVE_OUT_RE.test(cmd) && !RUNNER_READ_BASH_RE.test(cmd)) break;
1458
1705
  var s2 = readState();
1459
1706
  if (!s2.memoryRequired || isMemorySearchedFor(s2)) break;
1460
1707
  // Hint precedence: prompt-derived classification (set by applyPromptStateReset
@@ -1965,7 +2212,7 @@ switch (command) {
1965
2212
  var raw = process.env.TOOL_INPUT_command || '';
1966
2213
  var cmd = stripQuotedAndHeredocs(raw).toLowerCase();
1967
2214
  for (var i = 0; i < DANGEROUS.length; i++) {
1968
- if (cmd.indexOf(DANGEROUS[i]) >= 0) {
2215
+ if (matchesDangerous(cmd, DANGEROUS[i])) {
1969
2216
  console.log('[BLOCKED] Dangerous command: ' + DANGEROUS[i]);
1970
2217
  process.exit(2);
1971
2218
  }
@@ -2037,10 +2284,22 @@ switch (command) {
2037
2284
  // already wrote the byte-identical post-reset state. Only writeState when
2038
2285
  // the reset actually changed something (i.e., prompt-reminder was skipped
2039
2286
  // because prompt-hook.mjs threw before invoking it).
2040
- var s = readState();
2041
2287
  var prompt = process.env.CLAUDE_USER_PROMPT || '';
2288
+ // #1447 — a safety net that cannot see the prompt must do NOTHING, not
2289
+ // decide. This bridge did not forward `prompt` until #1447, so this hook
2290
+ // classified the empty string on every prompt, concluded "no memory
2291
+ // required", and wrote that over the correct value prompt-reminder had just
2292
+ // computed — disarming the memory gate it exists to protect, intermittently,
2293
+ // depending on which UserPromptSubmit hook wrote last. The forwarding fix
2294
+ // addresses the cause; this guard makes the failure mode survivable if the
2295
+ // field ever goes missing again (a host that omits it, a payload change):
2296
+ // still invalidate the credits — that is this hook's whole reason to exist
2297
+ // and needs no prompt — but leave the arming verdict to prompt-reminder,
2298
+ // which has the text. Deciding "not required" from a prompt it cannot see
2299
+ // is exactly the bug.
2300
+ var s = readState();
2042
2301
  var before = JSON.stringify(s);
2043
- applyPromptStateReset(s, prompt);
2302
+ applyPromptStateReset(s, prompt, { skipArming: !prompt });
2044
2303
  if (JSON.stringify(s) !== before) writeState(s);
2045
2304
  break;
2046
2305
  }
@@ -2,8 +2,10 @@
2
2
  * Skills that ship in the npm tarball (under `node_modules/moflo/.claude/skills/`)
3
3
  * but must NEVER be installed into consumer projects — strictly moflo-internal
4
4
  * dev tooling. `/publish` bumps moflo's own version and publishes to npm;
5
- * `/reset-epic` torches epic test data. Both are meaningless or harmful in a
6
- * consumer repo.
5
+ * `/reset-epic` torches epic test data; `/flfl` runs `/fl` preloaded with the
6
+ * rules that govern developing MOFLO (cross-platform, consumer blast radius,
7
+ * dogfooding), which are not constraints on a consumer's own project. All three
8
+ * are meaningless, noisy, or harmful in a consumer repo.
7
9
  *
8
10
  * The session-start launcher's recursive skills sync (`syncDirRecursive` in
9
11
  * `file-sync.mjs`) copies every shipped skill into the consumer on each run, so
@@ -13,4 +15,4 @@
13
15
  * so this leaf mirrors it. `tests/bin/internal-skills-parity.test.ts` asserts
14
16
  * the two lists never drift.
15
17
  */
16
- export const INTERNAL_SKILLS = ['publish', 'reset-epic'];
18
+ export const INTERNAL_SKILLS = ['publish', 'reset-epic', 'flfl'];
@@ -206,6 +206,115 @@ export function hasPrivateOptOut(text) {
206
206
  return typeof text === 'string' && /<private>/i.test(text);
207
207
  }
208
208
 
209
+ // ── Session goal extraction (pure) ──────────────────────────────────────────
210
+
211
+ /**
212
+ * Claude Code stamps `isMeta: true` on user-role transcript entries it injected
213
+ * itself rather than ones the human typed — expanded skill preambles, the
214
+ * local-command caveat, "skill is already loaded" notes. An audit of every
215
+ * transcript on a dogfooding box found this marker on boilerplate ONLY, never on
216
+ * a real goal, which makes it the structural signal to filter on. The literal
217
+ * prefixes below are belt-and-braces for a Claude Code build that stops emitting
218
+ * the flag — a missing `isMeta` must not silently restore the #1452 bug.
219
+ */
220
+ const BOILERPLATE_OPENERS = [
221
+ /^Base directory for this skill:/i,
222
+ /^Skill \S+ is already loaded/i,
223
+ /^Caveat: The messages below were generated by the user/i,
224
+ /^\[Request interrupted by user/i,
225
+ /^This session is being continued from a previous conversation/i,
226
+ ];
227
+
228
+ /** Slash commands that steer the SESSION rather than state a goal. `/clear` is
229
+ * the near-universal opener in an agent-driven repo, so returning it as the
230
+ * Goal would reintroduce the same "boilerplate as intent" failure. */
231
+ const SESSION_CONTROL_COMMANDS = new Set(['clear', 'compact', 'resume', 'exit', 'quit', 'login', 'logout', 'help']);
232
+
233
+ const COMMAND_NAME_RE = /<command-name>\s*\/?\s*([\w:.-]+)\s*<\/command-name>/i;
234
+ const COMMAND_ARGS_RE = /<command-args>([\s\S]*?)<\/command-args>/i;
235
+ const BARE_COMMAND_RE = /^\/([\w:.-]+)(?:\s+([\s\S]*))?$/;
236
+
237
+ /**
238
+ * Render a slash-command invocation as a goal string, e.g. `/flo 1452`.
239
+ *
240
+ * Claude Code writes the invocation two ways: an XML envelope
241
+ * (`<command-name>/flo</command-name><command-args>1452</command-args>`) and,
242
+ * for some built-ins, the bare text the user typed (`/compact`). Both are
243
+ * handled — the ARGUMENTS carry the actual intent (the issue number), which is
244
+ * exactly what the old filter threw away.
245
+ *
246
+ * `name` is lower-cased for the session-control lookup only; `goal` preserves
247
+ * the case the user actually typed, since it is a record of their invocation.
248
+ *
249
+ * Text after the closing `</command-args>` tag is intentionally dropped — the
250
+ * command plus its arguments IS the goal, and the surrounding envelope carries
251
+ * a `<command-message>` label that is never part of the ask.
252
+ *
253
+ * @param {string} flat - message content flattened to a single line
254
+ * @returns {{ name: string, goal: string } | null} null if not a slash command
255
+ */
256
+ function parseSlashCommand(flat) {
257
+ let name = null;
258
+ let args = '';
259
+ const envelope = COMMAND_NAME_RE.exec(flat);
260
+ if (envelope) {
261
+ name = envelope[1];
262
+ const a = COMMAND_ARGS_RE.exec(flat);
263
+ args = a ? a[1].trim() : '';
264
+ } else if (!flat.startsWith('<')) {
265
+ const bare = BARE_COMMAND_RE.exec(flat);
266
+ if (!bare) return null;
267
+ name = bare[1];
268
+ args = (bare[2] || '').trim();
269
+ }
270
+ if (!name) return null;
271
+ return { name: name.toLowerCase(), goal: args ? `/${name} ${args}` : `/${name}` };
272
+ }
273
+
274
+ /**
275
+ * First user message that actually states a goal, from a transcript JSONL head.
276
+ *
277
+ * Returns `null` rather than a boilerplate string when nothing qualifies: this
278
+ * value is re-injected at SessionStart after a compaction, to a model that has
279
+ * just lost its context and cannot tell a junk Goal from a real one. An absent
280
+ * Goal is strictly better than a misleading one. See issue #1452.
281
+ *
282
+ * @param {string} headText - raw JSONL text from the head of the transcript
283
+ * @returns {string|null} goal (≤200 chars), or null if none was stated
284
+ */
285
+ export function extractFirstUserGoal(headText) {
286
+ if (typeof headText !== 'string') return null;
287
+ for (const line of headText.split('\n')) {
288
+ const t = line.trim();
289
+ if (!t) continue;
290
+ let obj;
291
+ try { obj = JSON.parse(t); } catch { continue; }
292
+ const role = obj?.message?.role ?? obj?.role;
293
+ if (role !== 'user') continue;
294
+ // Injected by Claude Code, not typed by the human — never a goal.
295
+ if (obj?.isMeta === true) continue;
296
+ let content = obj?.message?.content ?? obj?.content;
297
+ if (Array.isArray(content)) {
298
+ content = content.map((b) => (typeof b === 'string' ? b : b?.text || '')).join(' ');
299
+ }
300
+ if (typeof content !== 'string') continue;
301
+ const flat = content.replace(/\s+/g, ' ').trim();
302
+ if (!flat) continue;
303
+
304
+ const command = parseSlashCommand(flat);
305
+ if (command) {
306
+ // `/clear`, `/compact` — session plumbing; keep scanning for the real ask.
307
+ if (SESSION_CONTROL_COMMANDS.has(command.name)) continue;
308
+ return command.goal.slice(0, 200);
309
+ }
310
+ // Other hook/tool XML noise (`<system-reminder>`, `<task-notification>`, …).
311
+ if (flat.startsWith('<')) continue;
312
+ if (BOILERPLATE_OPENERS.some((re) => re.test(flat))) continue;
313
+ return flat.slice(0, 200);
314
+ }
315
+ return null;
316
+ }
317
+
209
318
  // ── Digest assembly (pure) ──────────────────────────────────────────────────
210
319
 
211
320
  /** statSync mtime that never throws (file may vanish between readdir and stat). */
@@ -35,6 +35,7 @@ import {
35
35
  readContinuityConfig,
36
36
  readGitState,
37
37
  hasPrivateOptOut,
38
+ extractFirstUserGoal,
38
39
  assembleDigestContent,
39
40
  buildDigestMetadata,
40
41
  writeDigest,
@@ -48,28 +49,6 @@ function warn(msg) {
48
49
  try { process.stderr.write(`moflo: session-continuity ${msg}\n`); } catch { /* never throw from a hook */ }
49
50
  }
50
51
 
51
- /** First user message (the session goal) from a transcript JSONL head. */
52
- function extractFirstUserGoal(headText) {
53
- for (const line of headText.split('\n')) {
54
- const t = line.trim();
55
- if (!t) continue;
56
- let obj;
57
- try { obj = JSON.parse(t); } catch { continue; }
58
- const role = obj?.message?.role ?? obj?.role;
59
- if (role !== 'user') continue;
60
- let content = obj?.message?.content ?? obj?.content;
61
- if (Array.isArray(content)) {
62
- content = content.map((b) => (typeof b === 'string' ? b : b?.text || '')).join(' ');
63
- }
64
- if (typeof content !== 'string') continue;
65
- const firstLine = content.replace(/\s+/g, ' ').trim();
66
- // Skip slash-command / hook-noise openers — they aren't a goal statement.
67
- if (!firstLine || firstLine.startsWith('<')) continue;
68
- return firstLine.slice(0, 200);
69
- }
70
- return null;
71
- }
72
-
73
52
  /** Bounded transcript read: goal from the head, `<private>` opt-out scan over
74
53
  * head+tail. Large transcripts are read at the edges only. */
75
54
  function readTranscriptInfo(path) {