moflo 4.12.8 → 4.12.9

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.
@@ -41,6 +41,22 @@ if (typeof hookContext.session_id === 'string' && hookContext.session_id) {
41
41
  if (typeof hookContext.transcript_path === 'string' && hookContext.transcript_path) {
42
42
  env.HOOK_TRANSCRIPT_PATH = hookContext.transcript_path;
43
43
  }
44
+ // #1447 — forward the user prompt. `gate.cjs` reads CLAUDE_USER_PROMPT to decide
45
+ // whether a prompt needs a memory search, and this bridge never set it, so the
46
+ // `prompt-state-reset` safety-net hook classified the EMPTY STRING on every
47
+ // prompt, concluded "no memory required", and wrote that over the correct value
48
+ // prompt-hook.mjs had just computed. A safety net that disarmed the gate it
49
+ // exists to protect — intermittently, since the surviving value depended on
50
+ // which of the two UserPromptSubmit hooks wrote last.
51
+ //
52
+ // Same field precedence as prompt-hook.mjs (`user_prompt` then `prompt`) so both
53
+ // UserPromptSubmit paths classify identical text and the reset is genuinely
54
+ // idempotent, which is the only thing that makes a safety-net hook safe.
55
+ if (typeof hookContext.user_prompt === 'string' && hookContext.user_prompt) {
56
+ env.CLAUDE_USER_PROMPT = hookContext.user_prompt;
57
+ } else if (typeof hookContext.prompt === 'string' && hookContext.prompt) {
58
+ env.CLAUDE_USER_PROMPT = hookContext.prompt;
59
+ }
44
60
  // #1332: structured tool inputs are forwarded as JSON, not dropped.
45
61
  //
46
62
  // This previously forwarded ONLY string values, so any object-valued input was
@@ -395,6 +395,60 @@ function bashCoordinationInit(rawCmd) {
395
395
  }
396
396
  return null;
397
397
  }
398
+ // #1445 — read shapes whose LEADING command is one BASH_CARVE_OUT_RE carves out
399
+ // wholesale (`node …`, `git …`). They are read-like because of what FOLLOWS the
400
+ // leading token, so anchoring on that token alone can never see them: in one
401
+ // real session 539 file-exploration calls went through `node -e` + readFileSync
402
+ // and `git show <ref>:<path>` with the gate blind to every one of them. That is
403
+ // the dominant exploration shape in repos whose own guidance steers away from
404
+ // `grep` (e.g. where `grep` is `ugrep` and silently skips files, so any
405
+ // inventory a decision rests on must go through `fs.readFileSync`).
406
+ //
407
+ // These sources are spliced into READ_LIKE_BASH_RE below AND kept addressable
408
+ // on their own, because check-bash-memory has to let them override the
409
+ // carve-out — extending READ_LIKE alone would change nothing, the `^\s*(node|
410
+ // git)\s` carve-out arms swallow them one line later.
411
+ var RUNNER_READ_SOURCES = [
412
+ // `node -e "...readFileSync(...)..."`, in every eval spelling (-e/--eval/-p/
413
+ // --print/--input-type=module -e). The eval FLAG is deliberately not required:
414
+ // a script-file invocation (`node scripts/build.mjs`) does not carry a read
415
+ // call in its command line, so the read call itself is the reliable signal and
416
+ // demanding the flag only adds spellings to miss.
417
+ //
418
+ // The negative lookahead is load-bearing, not caution: Rule #1 tells authors
419
+ // to do cross-platform file OPS through `node -e` with fs (`mkdir`/`rm`/`cp`
420
+ // do not exist on Windows), and a read-modify-write is an operation, not
421
+ // exploration. Only fs MUTATION calls carve out — matching a bare `write`
422
+ // would carve out `process.stdout.write`, which nearly every inline read
423
+ // script ends with, and that would hand back the whole gap.
424
+ //
425
+ // Every mutation name covers its sync, callback AND promise spelling: authors
426
+ // reach for `fs.promises.rm` as readily as `rmSync`, and listing only the
427
+ // `*Sync` form would block the delete op Rule #1 sent them to `node -e` for.
428
+ // `rm`/`rmdir`/`cp` carry `\b` because they are short enough to appear inside
429
+ // unrelated words — an unanchored `rm` matches "format" and "transform", and
430
+ // would carve out most of what this arm exists to catch.
431
+ '^\\s*(?:node|nodejs)(?:\\.(?:exe|cmd))?\\s'
432
+ + '(?=[\\s\\S]*(?:read(?:File|dir)|globSync))'
433
+ + '(?![\\s\\S]*(?:writeFile|appendFile|mkdir|mkdtemp|unlink|copyFile|rename'
434
+ + '|symlink|createWriteStream|\\brm(?:dir)?(?:Sync)?\\b|\\bcp(?:Sync)?\\b))',
435
+ // `git show <ref>:<path>` / `git cat-file -p <ref>:<path>` — the colon form
436
+ // reads a blob and is `cat` by another name. Leading `-`-prefixed tokens are
437
+ // consumed as flags first so `--pretty=format:%h` (a colon inside a FLAG)
438
+ // cannot masquerade as a ref:path, and the segment after the colon must carry
439
+ // a path character — same false-negative trade as the `type \S*[\\/.]` arm
440
+ // above (`git show HEAD:src`, a bare directory, passes; source files all have
441
+ // extensions). Bare `git show`, `git show --stat`, `git log` and `git diff`
442
+ // have no ref:path token and stay operational.
443
+ '^\\s*git\\s+(?:show|cat-file)\\s+(?:-{1,2}\\S+\\s+)*[^-\\s]\\S*:\\S*[\\\\/.]',
444
+ // Repo-wide content search and file inventory — the `git` spellings of
445
+ // `grep -r` and `find`. NOT `git log --grep`, which searches commit messages,
446
+ // is already in the passing set, and does not match `git\s+grep`.
447
+ '^\\s*git\\s+(?:grep|ls-files)\\b',
448
+ ];
449
+ // Same sources as their own matcher — check-bash-memory tests this to let a
450
+ // carved-out leading command still block when the rest of the command is a read.
451
+ var RUNNER_READ_BASH_RE = new RegExp(RUNNER_READ_SOURCES.join('|'), 'i');
398
452
  // BLOCK: read-like Bash commands that bypass the existing check-before-read /
399
453
  // check-before-scan gates by going through the shell. Anchored to the start of
400
454
  // the line so subcommands inside pipelines or `npm install grep` don't trip.
@@ -427,7 +481,7 @@ var READ_LIKE_BASH_RE = new RegExp([
427
481
  '^\\s*dir\\b[^|]*\\s\\/[sS]\\b',
428
482
  // #1171 — PowerShell hex dump, parallel to POSIX `xxd`/`hexdump`.
429
483
  '^\\s*Format-Hex\\b',
430
- ].join('|'), 'i');
484
+ ].concat(RUNNER_READ_SOURCES).join('|'), 'i');
431
485
  // CARVE-OUT: commands that LOOK read-like but are operational. Anchored to the
432
486
  // LEADING command — the pipe-filter case (`npm test | grep FAIL`) is already
433
487
  // handled by READ_LIKE's `^\s*` anchor never matching the leading `npm`, so
@@ -474,8 +528,73 @@ function stripQuotedAndHeredocs(cmd) {
474
528
  return out;
475
529
  }
476
530
 
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;
531
+ // #1447 words that carry no subject to search FOR: assent, acknowledgement,
532
+ // "keep going", and conversational filler. A prompt built ONLY from these
533
+ // continues work already under way, so the memory gate stays down; anything
534
+ // that survives the strip is a subject, and the gate arms.
535
+ //
536
+ // Supersedes two things. `DIRECTIVE_RE` — a `^(yes|ok|sure|…)` prefix test that
537
+ // had already been dead code for some time, unreferenced by the reset it was
538
+ // written for. And the live rule, `TASK_RE.test(p) || p.length > 20`, whose
539
+ // real work was skipping short replies: every prompt of 20 characters or fewer
540
+ // without a task word was exempt. That caught trivia ("hmm", "got it") and
541
+ // equally caught "check the daemon" — a genuine topic change — which is the
542
+ // half this replaces. The list below has to carry the trivia half on its own,
543
+ // so it covers considerably more ground than DIRECTIVE_RE ever did.
544
+ //
545
+ // Matching by SUBTRACTION, not by a leading-token test, is what lets
546
+ // "yes, now fix the daemon" arm while "yes" does not — a `^(yes|ok)\b` test
547
+ // sees the same first word in both. It is also why the list can safely hold
548
+ // ordinary words like `do`/`it`/`work`/`the`: a word only exempts a prompt when
549
+ // NOTHING else in that prompt survives, so each addition costs precision only
550
+ // for prompts made entirely of listed words. Deliberately absent for that
551
+ // reason: task words (`fix`, `test`, `build`) and any noun.
552
+ var CONTINUATION_WORD_RE = new RegExp('\\b(?:' + [
553
+ // Assent / dissent / acknowledgement
554
+ 'yes', 'yeah', 'yep', 'yup', 'no', 'nope', 'sure', 'ok', 'okay', 'k',
555
+ 'correct', 'right', 'exactly', 'perfect', 'agreed', 'true', 'indeed',
556
+ 'understood', 'gotcha', 'got', 'makes', 'sense', 'fine', 'alright',
557
+ // Continuation / assent to proceed
558
+ 'continue', 'proceed', 'carry', 'keep', 'going', 'go', 'ahead', 'on', 'next',
559
+ 'again', 'more', 'rest', 'both', 'all', 'them', 'those',
560
+ // Politeness and praise
561
+ 'please', 'thanks', 'thank', 'ty', 'great', 'nice', 'cool', 'good', 'awesome',
562
+ 'excellent', 'sounds', 'lgtm', 'love', 'well', 'work', 'job', 'wow', 'yay',
563
+ // Conversational filler / hesitation / greetings
564
+ 'hmm', 'hm', 'huh', 'ah', 'oh', 'ha', 'haha', 'lol', 'wait', 'hold', 'hang',
565
+ 'actually', 'anyway', 'whatever', 'nvm', 'nevermind', 'sorry', 'oops',
566
+ 'hi', 'hello', 'hey', 'stop', 'pause', 'never', 'mind',
567
+ 'think', 'know', 'see', 'guess', 'suppose', 'maybe', 'probably',
568
+ // Function words with no subject of their own
569
+ 'do', 'it', 'that', 'this', 'the', 'a', 'an', 'and', 'is', 'are', 'was',
570
+ 'you', 'your', 'i', 'we', 'lets', 'let', 'us', 'me', 'my', 'now', 'then',
571
+ 'done', 'finish', 'finished', 'ready', 'too', 'also', 'just', 'still',
572
+ ].join('|') + ')\\b', 'gi');
573
+ /**
574
+ * Does this prompt consist of nothing but continuation filler?
575
+ *
576
+ * An EMPTY prompt answers true — a prompt the gate cannot see is not evidence
577
+ * that a search is needed, and arming on it would block every read in a
578
+ * consumer whose host omits the field, with nothing on screen explaining why.
579
+ * Fail-open here; `prompt-state-reset` separately refuses to WRITE a verdict it
580
+ * derived from an empty prompt (#1447), so an unreadable prompt now leaves the
581
+ * gate exactly as prompt-reminder set it rather than silently disarming it.
582
+ */
583
+ function isContinuationPrompt(promptText) {
584
+ var t = (promptText || '').trim();
585
+ if (!t) return true;
586
+ // Subtract continuation words, then every non-alphanumeric character. What
587
+ // remains is the prompt's actual subject matter; nothing remaining means the
588
+ // prompt introduced no new subject.
589
+ // `\p{L}\p{N}` with /u, NOT `A-Za-z0-9`: an ASCII-only class strips every
590
+ // character of a CJK, Cyrillic, Arabic, Hebrew, Greek or Thai prompt — and
591
+ // most of an accented French or Spanish one — leaving nothing, so a
592
+ // substantive non-English request would score as pure filler and silently
593
+ // disarm the gate. Stripping only what is neither letter nor number keeps
594
+ // every script's content intact and removes just punctuation and spacing.
595
+ var rest = t.replace(CONTINUATION_WORD_RE, ' ').replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
596
+ return rest.length === 0;
597
+ }
479
598
 
480
599
  // Namespace classification (#931). The hint used to be emitted on every prompt
481
600
  // by prompt-hook.mjs which cost ~40 tokens × every prompt × every consumer.
@@ -510,7 +629,9 @@ var NS_NAV_RES = [
510
629
  // explicitly opted in to the protected coordination surface, so falling back to
511
630
  // raw Agent dispatch silently regresses headline moflo product capability.
512
631
  //
513
- // SYNC: duplicated verbatim in src/cli/init/helpers-generator.ts.
632
+ // This file is the single source; #1443 replaced helpers-generator.ts's
633
+ // hand-maintained copy with a build-time embed of this exact file, so there is
634
+ // no longer a second copy of this function to keep in step.
514
635
  function detectFlMode(promptText) {
515
636
  var p = promptText || '';
516
637
  if (!/^\s*\/(?:fl|flo)\b/i.test(p)) return null;
@@ -653,10 +774,14 @@ function classifyNamespaceHint(promptText) {
653
774
  // full sentence in the same shape as classifyNamespaceHint so the BLOCK arm
654
775
  // can write either source's hint without branching on format.
655
776
  //
656
- // SYNC: duplicated verbatim in src/cli/init/helpers-generator.ts.
777
+ // This file is the single source — see the note on detectFlMode above (#1443).
657
778
  function classifyBashNamespaceHint(cmd) {
658
779
  // 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)) {
780
+ // #1445 — `git grep` / `git ls-files` are the same hunt through a different
781
+ // binary, so they route to the same namespace rather than falling through to
782
+ // the hintless generic message.
783
+ if (/^\s*(?:grep|rg|ag|fgrep|egrep|find|fd|Select-String|sls)\b/i.test(cmd)
784
+ || /^\s*git\s+(?:grep|ls-files)\b/i.test(cmd)) {
660
785
  return 'Memory namespace hint: use "code-map" for codebase navigation.';
661
786
  }
662
787
  // Reading a .md / RST / TXT, or a well-known doc file — guidance/learnings win.
@@ -674,7 +799,7 @@ function classifyBashNamespaceHint(cmd) {
674
799
  // UserPromptSubmit hooks can run it without compounding any field. Caller
675
800
  // owns interactionCount and the user-visible REMINDER/Context emissions, so
676
801
  // this helper stays silent.
677
- function applyPromptStateReset(state, promptText) {
802
+ function applyPromptStateReset(state, promptText, opts) {
678
803
  // #352/#1331 — this is the ONLY place the memory gate resets. Deliberately
679
804
  // NOT on task transitions: within a single prompt (e.g. a /flo workflow)
680
805
  // memory stays searched so Read/Grep aren't blocked mid-execution. A
@@ -688,9 +813,24 @@ function applyPromptStateReset(state, promptText) {
688
813
  state.memorySearchedBy = {};
689
814
  // learningsStored is session-scoped — once stored, it stays true until session reset.
690
815
  // 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);
816
+ // #1447 arm by DEFAULT; exempt only prompts that carry no subject of their
817
+ // own. This replaces `TASK_RE.test(p) || p.length > 20`, whose length cliff
818
+ // was arbitrary and invisible: "now look at the daemon" (22) armed the gate
819
+ // and "check the daemon" (16) did not, which is most of why the gate felt
820
+ // like it fired at random. Memory-first is this project's first rule, so the
821
+ // default has to be "search", with continuations as the carve-out — not the
822
+ // reverse. The per-prompt latch bounds the cost at one search per new prompt.
823
+ //
824
+ // `opts.skipArming` splits the reset in two. Invalidating the credits above
825
+ // is prompt-INDEPENDENT and always correct; deciding whether this prompt
826
+ // needs a search is not, and a caller holding no prompt text must not decide
827
+ // it from nothing. Skipping the whole reset instead would be worse than
828
+ // either: `memorySearchedBy` would survive, and a stale per-actor credit from
829
+ // the previous prompt would satisfy the gate for a prompt it never saw.
830
+ if (!(opts && opts.skipArming)) {
831
+ var escaped = /^@@\s*/.test(promptText || '');
832
+ state.memoryRequired = !escaped && !isContinuationPrompt(promptText);
833
+ }
694
834
  // Stash namespace hint for check-before-agent to emit when Claude actually
695
835
  // spawns an Agent (#931). Empty string when nothing matched — overwriting
696
836
  // any stale value from the previous prompt.
@@ -1454,7 +1594,12 @@ switch (command) {
1454
1594
  // → state read → memory gate.
1455
1595
  if (!config.memory_first) break;
1456
1596
  if (!READ_LIKE_BASH_RE.test(cmd)) break;
1457
- if (BASH_CARVE_OUT_RE.test(cmd)) break;
1597
+ // #1445 — the carve-out is anchored to the LEADING command, so its `node`
1598
+ // and `git` arms swallow every shape RUNNER_READ_SOURCES exists to catch.
1599
+ // Those shapes earn their read classification from what follows the leading
1600
+ // token, so they override the carve-out; every other command keeps #1132's
1601
+ // semantics exactly (`npm test | grep FAIL`, `git diff`, `node build.mjs`).
1602
+ if (BASH_CARVE_OUT_RE.test(cmd) && !RUNNER_READ_BASH_RE.test(cmd)) break;
1458
1603
  var s2 = readState();
1459
1604
  if (!s2.memoryRequired || isMemorySearchedFor(s2)) break;
1460
1605
  // Hint precedence: prompt-derived classification (set by applyPromptStateReset
@@ -2037,10 +2182,22 @@ switch (command) {
2037
2182
  // already wrote the byte-identical post-reset state. Only writeState when
2038
2183
  // the reset actually changed something (i.e., prompt-reminder was skipped
2039
2184
  // because prompt-hook.mjs threw before invoking it).
2040
- var s = readState();
2041
2185
  var prompt = process.env.CLAUDE_USER_PROMPT || '';
2186
+ // #1447 — a safety net that cannot see the prompt must do NOTHING, not
2187
+ // decide. This bridge did not forward `prompt` until #1447, so this hook
2188
+ // classified the empty string on every prompt, concluded "no memory
2189
+ // required", and wrote that over the correct value prompt-reminder had just
2190
+ // computed — disarming the memory gate it exists to protect, intermittently,
2191
+ // depending on which UserPromptSubmit hook wrote last. The forwarding fix
2192
+ // addresses the cause; this guard makes the failure mode survivable if the
2193
+ // field ever goes missing again (a host that omits it, a payload change):
2194
+ // still invalidate the credits — that is this hook's whole reason to exist
2195
+ // and needs no prompt — but leave the arming verdict to prompt-reminder,
2196
+ // which has the text. Deciding "not required" from a prompt it cannot see
2197
+ // is exactly the bug.
2198
+ var s = readState();
2042
2199
  var before = JSON.stringify(s);
2043
- applyPromptStateReset(s, prompt);
2200
+ applyPromptStateReset(s, prompt, { skipArming: !prompt });
2044
2201
  if (JSON.stringify(s) !== before) writeState(s);
2045
2202
  break;
2046
2203
  }
@@ -0,0 +1,50 @@
1
+ ---
2
+ name: flfl
3
+ description: Run /fl on a ticket with moflo's three standing considerations loaded first — cross-platform (Rule #1), consumer blast radius, and dogfooding. Use in the moflo repo itself instead of bare /fl. moflo-internal; never installed into consumer projects.
4
+ arguments: "[options] <issue-number | title>"
5
+ ---
6
+
7
+ ```text
8
+ $ARGUMENTS
9
+ ```
10
+
11
+ # /flfl — /fl with moflo's standing considerations loaded first
12
+
13
+ Purpose: run the normal `/fl` ticket workflow, but seat the three things that break moflo changes **before** any research, code, or review happens — not after a reviewer catches them.
14
+
15
+ These are not a preamble to acknowledge and move past. Hold all three for the **whole** run: research, implementation, tests, `/flo-simplify`, `/verify`, and the PR body.
16
+
17
+ ## The three considerations
18
+
19
+ | # | Consideration | What it changes about the work you are about to do |
20
+ |---|---------------|----------------------------------------------------|
21
+ | 1 | **Rule #1 — everything ships cross-platform** | Linux, macOS **and** Windows, identically. Audit every edit for: `path.join`/`path.sep` over hardcoded separators; `fs.realpathSync` on **both** sides of any path comparison; no `Foo.ts` beside `foo.ts`; platform EOL; no `bash`/`grep`/`sed`/`cat`/`find` shell-outs (use Node `fs`/`spawn`); `tasklist` vs `/proc` for process checks; `shell: true` on Windows vs `detached` on POSIX when spawning; `os.tmpdir()` and test ports in 40000–44999. Verify against CI's macOS **and** Ubuntu runs, not just your own OS. |
22
+ | 2 | **moflo is installed into a destination project** | This is a library, not an app. Before writing code, name (a) the **consumer surface** touched — `bin/`, `src/cli/`, `.claude/scripts/`, hooks, `init/`, settings/CLAUDE.md generators, anything synced into `node_modules/moflo/`; (b) the **failure mode** for someone already on the current version who upgrades — does their `.moflo/` state still parse, do their hooks still wire, is a migration needed; (c) the **round-trip cost** — does this need publish-then-reinstall to take effect. If you cannot name all three, re-scope before writing code. |
23
+ | 3 | **moflo dogfoods itself** | The daemon, hooks, statusline, MCP server and indexer all run from `node_modules/moflo/…`, **not** the source tree. A source edit changes nothing for those layers until publish + reinstall + Claude Code restart. Before diagnosing any "X is broken" symptom, establish **which copy is actually running** — diff `bin/` against `.claude/` against `node_modules/moflo/` first. Expect local flapping: the session-start launcher re-syncs `.claude/helpers/` from the **installed** package, so a local fix to a synced file reverts until published. |
24
+
25
+ ## How to run
26
+
27
+ 1. Restate the three considerations in one line each, mapped to **this specific ticket** — which surface it touches, which platform risks it carries, whether it needs a publish round-trip. Generic restatement is worthless; if a consideration genuinely does not apply, say so and why.
28
+ 2. Invoke the real workflow with the arguments above, unchanged and in full — including every flag (`-sd`, `-s`, `-w`, `-m`, …):
29
+
30
+ ```
31
+ Skill({ skill: "fl", args: "<the $ARGUMENTS block above, verbatim>" })
32
+ ```
33
+
34
+ 3. Follow `/fl` from there. `/flfl` adds nothing to the workflow itself — same phases, same gates, same run-mode resolution. Re-check the three at each gate: they most often fail at `/flo-simplify` (a cross-platform miss) and at the PR body (an unnamed consumer failure mode).
35
+
36
+ ## Anti-patterns
37
+
38
+ | Don't | Do |
39
+ |-------|-----|
40
+ | Acknowledge the three, then run `/fl` and never revisit them | Re-check them at implementation, simplify, and PR |
41
+ | Restate them verbatim from this file | Map each to the ticket's actual surface and risk |
42
+ | Drop or reorder `$ARGUMENTS` when calling `/fl` | Pass the argument string through untouched |
43
+ | Verify only on your own OS | Read the macOS and Ubuntu CI runs before claiming green |
44
+ | Debug a runtime symptom against the source tree | Confirm which copy is running first |
45
+
46
+ ## See Also
47
+
48
+ - `.claude/skills/fl/SKILL.md` — the workflow this wraps
49
+ - `CLAUDE.md` — Rule #1, Rule #2, and the dogfooding section these three condense
50
+ - `.claude/guidance/internal/dogfooding.md` — required reading before diagnosing runtime symptoms or adding files under `bin/`
package/bin/gate-hook.mjs CHANGED
@@ -41,6 +41,22 @@ if (typeof hookContext.session_id === 'string' && hookContext.session_id) {
41
41
  if (typeof hookContext.transcript_path === 'string' && hookContext.transcript_path) {
42
42
  env.HOOK_TRANSCRIPT_PATH = hookContext.transcript_path;
43
43
  }
44
+ // #1447 — forward the user prompt. `gate.cjs` reads CLAUDE_USER_PROMPT to decide
45
+ // whether a prompt needs a memory search, and this bridge never set it, so the
46
+ // `prompt-state-reset` safety-net hook classified the EMPTY STRING on every
47
+ // prompt, concluded "no memory required", and wrote that over the correct value
48
+ // prompt-hook.mjs had just computed. A safety net that disarmed the gate it
49
+ // exists to protect — intermittently, since the surviving value depended on
50
+ // which of the two UserPromptSubmit hooks wrote last.
51
+ //
52
+ // Same field precedence as prompt-hook.mjs (`user_prompt` then `prompt`) so both
53
+ // UserPromptSubmit paths classify identical text and the reset is genuinely
54
+ // idempotent, which is the only thing that makes a safety-net hook safe.
55
+ if (typeof hookContext.user_prompt === 'string' && hookContext.user_prompt) {
56
+ env.CLAUDE_USER_PROMPT = hookContext.user_prompt;
57
+ } else if (typeof hookContext.prompt === 'string' && hookContext.prompt) {
58
+ env.CLAUDE_USER_PROMPT = hookContext.prompt;
59
+ }
44
60
  // #1332: structured tool inputs are forwarded as JSON, not dropped.
45
61
  //
46
62
  // This previously forwarded ONLY string values, so any object-valued input was
package/bin/gate.cjs CHANGED
@@ -395,6 +395,60 @@ function bashCoordinationInit(rawCmd) {
395
395
  }
396
396
  return null;
397
397
  }
398
+ // #1445 — read shapes whose LEADING command is one BASH_CARVE_OUT_RE carves out
399
+ // wholesale (`node …`, `git …`). They are read-like because of what FOLLOWS the
400
+ // leading token, so anchoring on that token alone can never see them: in one
401
+ // real session 539 file-exploration calls went through `node -e` + readFileSync
402
+ // and `git show <ref>:<path>` with the gate blind to every one of them. That is
403
+ // the dominant exploration shape in repos whose own guidance steers away from
404
+ // `grep` (e.g. where `grep` is `ugrep` and silently skips files, so any
405
+ // inventory a decision rests on must go through `fs.readFileSync`).
406
+ //
407
+ // These sources are spliced into READ_LIKE_BASH_RE below AND kept addressable
408
+ // on their own, because check-bash-memory has to let them override the
409
+ // carve-out — extending READ_LIKE alone would change nothing, the `^\s*(node|
410
+ // git)\s` carve-out arms swallow them one line later.
411
+ var RUNNER_READ_SOURCES = [
412
+ // `node -e "...readFileSync(...)..."`, in every eval spelling (-e/--eval/-p/
413
+ // --print/--input-type=module -e). The eval FLAG is deliberately not required:
414
+ // a script-file invocation (`node scripts/build.mjs`) does not carry a read
415
+ // call in its command line, so the read call itself is the reliable signal and
416
+ // demanding the flag only adds spellings to miss.
417
+ //
418
+ // The negative lookahead is load-bearing, not caution: Rule #1 tells authors
419
+ // to do cross-platform file OPS through `node -e` with fs (`mkdir`/`rm`/`cp`
420
+ // do not exist on Windows), and a read-modify-write is an operation, not
421
+ // exploration. Only fs MUTATION calls carve out — matching a bare `write`
422
+ // would carve out `process.stdout.write`, which nearly every inline read
423
+ // script ends with, and that would hand back the whole gap.
424
+ //
425
+ // Every mutation name covers its sync, callback AND promise spelling: authors
426
+ // reach for `fs.promises.rm` as readily as `rmSync`, and listing only the
427
+ // `*Sync` form would block the delete op Rule #1 sent them to `node -e` for.
428
+ // `rm`/`rmdir`/`cp` carry `\b` because they are short enough to appear inside
429
+ // unrelated words — an unanchored `rm` matches "format" and "transform", and
430
+ // would carve out most of what this arm exists to catch.
431
+ '^\\s*(?:node|nodejs)(?:\\.(?:exe|cmd))?\\s'
432
+ + '(?=[\\s\\S]*(?:read(?:File|dir)|globSync))'
433
+ + '(?![\\s\\S]*(?:writeFile|appendFile|mkdir|mkdtemp|unlink|copyFile|rename'
434
+ + '|symlink|createWriteStream|\\brm(?:dir)?(?:Sync)?\\b|\\bcp(?:Sync)?\\b))',
435
+ // `git show <ref>:<path>` / `git cat-file -p <ref>:<path>` — the colon form
436
+ // reads a blob and is `cat` by another name. Leading `-`-prefixed tokens are
437
+ // consumed as flags first so `--pretty=format:%h` (a colon inside a FLAG)
438
+ // cannot masquerade as a ref:path, and the segment after the colon must carry
439
+ // a path character — same false-negative trade as the `type \S*[\\/.]` arm
440
+ // above (`git show HEAD:src`, a bare directory, passes; source files all have
441
+ // extensions). Bare `git show`, `git show --stat`, `git log` and `git diff`
442
+ // have no ref:path token and stay operational.
443
+ '^\\s*git\\s+(?:show|cat-file)\\s+(?:-{1,2}\\S+\\s+)*[^-\\s]\\S*:\\S*[\\\\/.]',
444
+ // Repo-wide content search and file inventory — the `git` spellings of
445
+ // `grep -r` and `find`. NOT `git log --grep`, which searches commit messages,
446
+ // is already in the passing set, and does not match `git\s+grep`.
447
+ '^\\s*git\\s+(?:grep|ls-files)\\b',
448
+ ];
449
+ // Same sources as their own matcher — check-bash-memory tests this to let a
450
+ // carved-out leading command still block when the rest of the command is a read.
451
+ var RUNNER_READ_BASH_RE = new RegExp(RUNNER_READ_SOURCES.join('|'), 'i');
398
452
  // BLOCK: read-like Bash commands that bypass the existing check-before-read /
399
453
  // check-before-scan gates by going through the shell. Anchored to the start of
400
454
  // the line so subcommands inside pipelines or `npm install grep` don't trip.
@@ -427,7 +481,7 @@ var READ_LIKE_BASH_RE = new RegExp([
427
481
  '^\\s*dir\\b[^|]*\\s\\/[sS]\\b',
428
482
  // #1171 — PowerShell hex dump, parallel to POSIX `xxd`/`hexdump`.
429
483
  '^\\s*Format-Hex\\b',
430
- ].join('|'), 'i');
484
+ ].concat(RUNNER_READ_SOURCES).join('|'), 'i');
431
485
  // CARVE-OUT: commands that LOOK read-like but are operational. Anchored to the
432
486
  // LEADING command — the pipe-filter case (`npm test | grep FAIL`) is already
433
487
  // handled by READ_LIKE's `^\s*` anchor never matching the leading `npm`, so
@@ -474,8 +528,73 @@ function stripQuotedAndHeredocs(cmd) {
474
528
  return out;
475
529
  }
476
530
 
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;
531
+ // #1447 words that carry no subject to search FOR: assent, acknowledgement,
532
+ // "keep going", and conversational filler. A prompt built ONLY from these
533
+ // continues work already under way, so the memory gate stays down; anything
534
+ // that survives the strip is a subject, and the gate arms.
535
+ //
536
+ // Supersedes two things. `DIRECTIVE_RE` — a `^(yes|ok|sure|…)` prefix test that
537
+ // had already been dead code for some time, unreferenced by the reset it was
538
+ // written for. And the live rule, `TASK_RE.test(p) || p.length > 20`, whose
539
+ // real work was skipping short replies: every prompt of 20 characters or fewer
540
+ // without a task word was exempt. That caught trivia ("hmm", "got it") and
541
+ // equally caught "check the daemon" — a genuine topic change — which is the
542
+ // half this replaces. The list below has to carry the trivia half on its own,
543
+ // so it covers considerably more ground than DIRECTIVE_RE ever did.
544
+ //
545
+ // Matching by SUBTRACTION, not by a leading-token test, is what lets
546
+ // "yes, now fix the daemon" arm while "yes" does not — a `^(yes|ok)\b` test
547
+ // sees the same first word in both. It is also why the list can safely hold
548
+ // ordinary words like `do`/`it`/`work`/`the`: a word only exempts a prompt when
549
+ // NOTHING else in that prompt survives, so each addition costs precision only
550
+ // for prompts made entirely of listed words. Deliberately absent for that
551
+ // reason: task words (`fix`, `test`, `build`) and any noun.
552
+ var CONTINUATION_WORD_RE = new RegExp('\\b(?:' + [
553
+ // Assent / dissent / acknowledgement
554
+ 'yes', 'yeah', 'yep', 'yup', 'no', 'nope', 'sure', 'ok', 'okay', 'k',
555
+ 'correct', 'right', 'exactly', 'perfect', 'agreed', 'true', 'indeed',
556
+ 'understood', 'gotcha', 'got', 'makes', 'sense', 'fine', 'alright',
557
+ // Continuation / assent to proceed
558
+ 'continue', 'proceed', 'carry', 'keep', 'going', 'go', 'ahead', 'on', 'next',
559
+ 'again', 'more', 'rest', 'both', 'all', 'them', 'those',
560
+ // Politeness and praise
561
+ 'please', 'thanks', 'thank', 'ty', 'great', 'nice', 'cool', 'good', 'awesome',
562
+ 'excellent', 'sounds', 'lgtm', 'love', 'well', 'work', 'job', 'wow', 'yay',
563
+ // Conversational filler / hesitation / greetings
564
+ 'hmm', 'hm', 'huh', 'ah', 'oh', 'ha', 'haha', 'lol', 'wait', 'hold', 'hang',
565
+ 'actually', 'anyway', 'whatever', 'nvm', 'nevermind', 'sorry', 'oops',
566
+ 'hi', 'hello', 'hey', 'stop', 'pause', 'never', 'mind',
567
+ 'think', 'know', 'see', 'guess', 'suppose', 'maybe', 'probably',
568
+ // Function words with no subject of their own
569
+ 'do', 'it', 'that', 'this', 'the', 'a', 'an', 'and', 'is', 'are', 'was',
570
+ 'you', 'your', 'i', 'we', 'lets', 'let', 'us', 'me', 'my', 'now', 'then',
571
+ 'done', 'finish', 'finished', 'ready', 'too', 'also', 'just', 'still',
572
+ ].join('|') + ')\\b', 'gi');
573
+ /**
574
+ * Does this prompt consist of nothing but continuation filler?
575
+ *
576
+ * An EMPTY prompt answers true — a prompt the gate cannot see is not evidence
577
+ * that a search is needed, and arming on it would block every read in a
578
+ * consumer whose host omits the field, with nothing on screen explaining why.
579
+ * Fail-open here; `prompt-state-reset` separately refuses to WRITE a verdict it
580
+ * derived from an empty prompt (#1447), so an unreadable prompt now leaves the
581
+ * gate exactly as prompt-reminder set it rather than silently disarming it.
582
+ */
583
+ function isContinuationPrompt(promptText) {
584
+ var t = (promptText || '').trim();
585
+ if (!t) return true;
586
+ // Subtract continuation words, then every non-alphanumeric character. What
587
+ // remains is the prompt's actual subject matter; nothing remaining means the
588
+ // prompt introduced no new subject.
589
+ // `\p{L}\p{N}` with /u, NOT `A-Za-z0-9`: an ASCII-only class strips every
590
+ // character of a CJK, Cyrillic, Arabic, Hebrew, Greek or Thai prompt — and
591
+ // most of an accented French or Spanish one — leaving nothing, so a
592
+ // substantive non-English request would score as pure filler and silently
593
+ // disarm the gate. Stripping only what is neither letter nor number keeps
594
+ // every script's content intact and removes just punctuation and spacing.
595
+ var rest = t.replace(CONTINUATION_WORD_RE, ' ').replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
596
+ return rest.length === 0;
597
+ }
479
598
 
480
599
  // Namespace classification (#931). The hint used to be emitted on every prompt
481
600
  // by prompt-hook.mjs which cost ~40 tokens × every prompt × every consumer.
@@ -510,7 +629,9 @@ var NS_NAV_RES = [
510
629
  // explicitly opted in to the protected coordination surface, so falling back to
511
630
  // raw Agent dispatch silently regresses headline moflo product capability.
512
631
  //
513
- // SYNC: duplicated verbatim in src/cli/init/helpers-generator.ts.
632
+ // This file is the single source; #1443 replaced helpers-generator.ts's
633
+ // hand-maintained copy with a build-time embed of this exact file, so there is
634
+ // no longer a second copy of this function to keep in step.
514
635
  function detectFlMode(promptText) {
515
636
  var p = promptText || '';
516
637
  if (!/^\s*\/(?:fl|flo)\b/i.test(p)) return null;
@@ -653,10 +774,14 @@ function classifyNamespaceHint(promptText) {
653
774
  // full sentence in the same shape as classifyNamespaceHint so the BLOCK arm
654
775
  // can write either source's hint without branching on format.
655
776
  //
656
- // SYNC: duplicated verbatim in src/cli/init/helpers-generator.ts.
777
+ // This file is the single source — see the note on detectFlMode above (#1443).
657
778
  function classifyBashNamespaceHint(cmd) {
658
779
  // 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)) {
780
+ // #1445 — `git grep` / `git ls-files` are the same hunt through a different
781
+ // binary, so they route to the same namespace rather than falling through to
782
+ // the hintless generic message.
783
+ if (/^\s*(?:grep|rg|ag|fgrep|egrep|find|fd|Select-String|sls)\b/i.test(cmd)
784
+ || /^\s*git\s+(?:grep|ls-files)\b/i.test(cmd)) {
660
785
  return 'Memory namespace hint: use "code-map" for codebase navigation.';
661
786
  }
662
787
  // Reading a .md / RST / TXT, or a well-known doc file — guidance/learnings win.
@@ -674,7 +799,7 @@ function classifyBashNamespaceHint(cmd) {
674
799
  // UserPromptSubmit hooks can run it without compounding any field. Caller
675
800
  // owns interactionCount and the user-visible REMINDER/Context emissions, so
676
801
  // this helper stays silent.
677
- function applyPromptStateReset(state, promptText) {
802
+ function applyPromptStateReset(state, promptText, opts) {
678
803
  // #352/#1331 — this is the ONLY place the memory gate resets. Deliberately
679
804
  // NOT on task transitions: within a single prompt (e.g. a /flo workflow)
680
805
  // memory stays searched so Read/Grep aren't blocked mid-execution. A
@@ -688,9 +813,24 @@ function applyPromptStateReset(state, promptText) {
688
813
  state.memorySearchedBy = {};
689
814
  // learningsStored is session-scoped — once stored, it stays true until session reset.
690
815
  // 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);
816
+ // #1447 arm by DEFAULT; exempt only prompts that carry no subject of their
817
+ // own. This replaces `TASK_RE.test(p) || p.length > 20`, whose length cliff
818
+ // was arbitrary and invisible: "now look at the daemon" (22) armed the gate
819
+ // and "check the daemon" (16) did not, which is most of why the gate felt
820
+ // like it fired at random. Memory-first is this project's first rule, so the
821
+ // default has to be "search", with continuations as the carve-out — not the
822
+ // reverse. The per-prompt latch bounds the cost at one search per new prompt.
823
+ //
824
+ // `opts.skipArming` splits the reset in two. Invalidating the credits above
825
+ // is prompt-INDEPENDENT and always correct; deciding whether this prompt
826
+ // needs a search is not, and a caller holding no prompt text must not decide
827
+ // it from nothing. Skipping the whole reset instead would be worse than
828
+ // either: `memorySearchedBy` would survive, and a stale per-actor credit from
829
+ // the previous prompt would satisfy the gate for a prompt it never saw.
830
+ if (!(opts && opts.skipArming)) {
831
+ var escaped = /^@@\s*/.test(promptText || '');
832
+ state.memoryRequired = !escaped && !isContinuationPrompt(promptText);
833
+ }
694
834
  // Stash namespace hint for check-before-agent to emit when Claude actually
695
835
  // spawns an Agent (#931). Empty string when nothing matched — overwriting
696
836
  // any stale value from the previous prompt.
@@ -1454,7 +1594,12 @@ switch (command) {
1454
1594
  // → state read → memory gate.
1455
1595
  if (!config.memory_first) break;
1456
1596
  if (!READ_LIKE_BASH_RE.test(cmd)) break;
1457
- if (BASH_CARVE_OUT_RE.test(cmd)) break;
1597
+ // #1445 — the carve-out is anchored to the LEADING command, so its `node`
1598
+ // and `git` arms swallow every shape RUNNER_READ_SOURCES exists to catch.
1599
+ // Those shapes earn their read classification from what follows the leading
1600
+ // token, so they override the carve-out; every other command keeps #1132's
1601
+ // semantics exactly (`npm test | grep FAIL`, `git diff`, `node build.mjs`).
1602
+ if (BASH_CARVE_OUT_RE.test(cmd) && !RUNNER_READ_BASH_RE.test(cmd)) break;
1458
1603
  var s2 = readState();
1459
1604
  if (!s2.memoryRequired || isMemorySearchedFor(s2)) break;
1460
1605
  // Hint precedence: prompt-derived classification (set by applyPromptStateReset
@@ -2037,10 +2182,22 @@ switch (command) {
2037
2182
  // already wrote the byte-identical post-reset state. Only writeState when
2038
2183
  // the reset actually changed something (i.e., prompt-reminder was skipped
2039
2184
  // because prompt-hook.mjs threw before invoking it).
2040
- var s = readState();
2041
2185
  var prompt = process.env.CLAUDE_USER_PROMPT || '';
2186
+ // #1447 — a safety net that cannot see the prompt must do NOTHING, not
2187
+ // decide. This bridge did not forward `prompt` until #1447, so this hook
2188
+ // classified the empty string on every prompt, concluded "no memory
2189
+ // required", and wrote that over the correct value prompt-reminder had just
2190
+ // computed — disarming the memory gate it exists to protect, intermittently,
2191
+ // depending on which UserPromptSubmit hook wrote last. The forwarding fix
2192
+ // addresses the cause; this guard makes the failure mode survivable if the
2193
+ // field ever goes missing again (a host that omits it, a payload change):
2194
+ // still invalidate the credits — that is this hook's whole reason to exist
2195
+ // and needs no prompt — but leave the arming verdict to prompt-reminder,
2196
+ // which has the text. Deciding "not required" from a prompt it cannot see
2197
+ // is exactly the bug.
2198
+ var s = readState();
2042
2199
  var before = JSON.stringify(s);
2043
- applyPromptStateReset(s, prompt);
2200
+ applyPromptStateReset(s, prompt, { skipArming: !prompt });
2044
2201
  if (JSON.stringify(s) !== before) writeState(s);
2045
2202
  break;
2046
2203
  }
@@ -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'];