moflo 4.12.4-rc.8 → 4.12.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,16 +27,31 @@ Specs and plans persist as Markdown, one directory per unit of work, under the c
27
27
  <specs_dir>/<slug>/plan.md # the "steps" + how each criterion is verified
28
28
  ```
29
29
 
30
- They are indexed into memory on session start, so `mcp__moflo__memory_search` surfaces prior specs across sessions. **Always create and mutate them through the `flo sdd` CLI** — never hand-write the path in a skill step (cross-platform, Rule #1: the CLI builds every path with `path.join`).
30
+ **Always create and mutate them through the `flo sdd` CLI** — never hand-write the path in a skill step (cross-platform, Rule #1: the CLI builds every path with `path.join`).
31
31
 
32
- **Where they live is configurable (`sdd.specs_dir`, #1294).** The default `.moflo/specs` is **gitignored** by `flo init` — specs stay local and do not bloat source control, but they also do **not** appear in PRs. To make specs reviewable, point `sdd.specs_dir` at a **tracked** path and commit them:
32
+ ### Specs are NOT indexed into memory
33
+
34
+ Earlier versions indexed `spec.md` / `plan.md` into the `guidance` namespace. They no longer are, and the specs directory is excluded from the guidance walk even when it sits inside a `guidance.directories` entry.
35
+
36
+ A spec is pre-implementation intent for **one** unit of work, not a project rule. Once implemented it is stale-by-construction, and specs accumulate without bound — so a superseded approach kept surfacing at high similarity alongside real guidance, and the namespace degraded as the project aged. Nothing was gained in exchange: the active spec's path is already known (the `flo sdd` CLI just returned it), so **read it from disk** rather than searching for it.
37
+
38
+ | To… | Use |
39
+ |---|---|
40
+ | Read the spec/plan you are working on | `Read` the path `flo sdd` returned |
41
+ | Find prior specs across sessions | `flo sdd list` / `flo sdd status <slug>` |
42
+ | Recall what an implementation actually taught you | `memory_search` namespace `learnings` |
43
+ | Recall a past verify verdict | `memory_search` namespace `verify` |
44
+
45
+ Existing spec rows are removed by the `purge-spec-chunks` migration on the next session start.
46
+
47
+ **Where specs live is configurable (`sdd.specs_dir`, #1294).** The default `.moflo/specs` is **gitignored** by `flo init`. To make specs reviewable in the PR, point `sdd.specs_dir` at a **tracked** path and commit them:
33
48
 
34
49
  | `sdd.specs_dir` | Committed? | Use when |
35
50
  |-----------------|------------|----------|
36
- | `.moflo/specs` (default) | No (gitignored) | You want the SDD workflow but not spec artifacts in history |
37
- | `docs/specs`, `.specs`, … (tracked) | Yes | You want specs reviewed in the PR alongside the code |
51
+ | `.moflo/specs` (default) | No (gitignored) | Specs are scratch — the PR body carries the acceptance criteria. Best at high spec volume. |
52
+ | `docs/specs`, `.specs`, … (tracked) | Yes | You want the spec diffed and reviewed alongside the code |
38
53
 
39
- Set it once in `moflo.yaml`; the `flo sdd` CLI and the session-start indexer both honor it. If the path sits inside a `guidance.directories` entry, specs are indexed once (as guidance), not twice.
54
+ Set it once in `moflo.yaml`; the `flo sdd` CLI and the session-start indexer both honor it the CLI to write specs there, the indexer to exclude them.
40
55
 
41
56
  Each artifact carries a `status` of `draft` or `reviewed` in its frontmatter. The constitution layer (`CLAUDE.md` + `.claude/guidance/`) is referenced by every stage — never restate its invariants inside a spec.
42
57
 
@@ -132,6 +132,26 @@ function readMofloYaml() {
132
132
  }
133
133
  var MOFLO_YAML = readMofloYaml();
134
134
 
135
+ // #1394 — is the hook that transcribes /verify's verdict into workflow-state.json
136
+ // actually wired? Distinguishes "the agent skipped Step 5" from "nothing exists
137
+ // to record the verdict", which need opposite remedies: the first is fixed by
138
+ // re-running /verify, the second cannot be.
139
+ //
140
+ // Deliberately NOT hoisted to module scope like MOFLO_YAML: this is only ever
141
+ // consulted on an already-blocked check-before-done path, so reading it eagerly
142
+ // would add a syscall to every Write/Edit to answer a question almost no gate
143
+ // invocation asks. Substring match (not a JSON walk) so it holds regardless of
144
+ // which matcher block a consumer's hook lives in, or how they hand-edited it.
145
+ // Unreadable/malformed settings → true, so a parse failure falls back to the
146
+ // pre-existing generic message rather than asserting a wiring bug that may not
147
+ // exist.
148
+ function isVerifyOutcomeHookWired() {
149
+ try {
150
+ return fs.readFileSync(path.join(PROJECT_DIR, '.claude', 'settings.json'), 'utf-8')
151
+ .indexOf('record-verify-outcome') >= 0;
152
+ } catch (e) { return true; }
153
+ }
154
+
135
155
  var config = loadGateConfig();
136
156
  var sddConf = loadSddConfig();
137
157
  var mergeConf = loadMergeConfig();
@@ -724,7 +744,22 @@ var EDIT_RESET_SKIP_BOTH_RE = /\.(md|markdown|txt|rst|adoc|lock|gitignore)$|(?:^
724
744
  // pure noise. Scoped to the reset only — deliberately NOT added to EXEMPT,
725
745
  // which would also un-gate reads of `.moflo/specs/**`, and those are indexed
726
746
  // guidance that memory-first should still route through a search.
727
- var EDIT_RESET_SKIP_PATH_RE = /(?:^|[\\\/])\.github[\\\/](?:workflows|ISSUE_TEMPLATE|PULL_REQUEST_TEMPLATE)(?:[\\\/.]|$)|(?:^|[\\\/])\.moflo[\\\/]/i;
747
+ // #1395 `.claude/` CONFIG joins them, by the same "doesn't expose new runtime
748
+ // surface" reasoning: hook wiring, skills and guidance are not the code under
749
+ // verification. This is the stronger case, in fact — it is the directory a user
750
+ // edits *because a gate told them to*. Before this, fixing hook wiring on a
751
+ // gate's own instruction reset verifyRun and invalidated the verification the
752
+ // fix existed to let through, so the recovery was: fix wiring → verification
753
+ // invalidated → restart session → re-run /verify → retry (#1392 field report).
754
+ //
755
+ // Scoped to config, NOT all of `.claude/**`: `scripts/` and `helpers/` hold
756
+ // executable runtime surface (gate.cjs itself lives there), and editing
757
+ // executable code SHOULD still invalidate a verification. Listing the config
758
+ // subdirectories explicitly keeps that invariant intact.
759
+ //
760
+ // Both separators in every alternative — a bare `/` here would silently no-op
761
+ // on Windows, where TOOL_INPUT paths arrive backslashed (Rule #1).
762
+ var EDIT_RESET_SKIP_PATH_RE = /(?:^|[\\\/])\.github[\\\/](?:workflows|ISSUE_TEMPLATE|PULL_REQUEST_TEMPLATE)(?:[\\\/.]|$)|(?:^|[\\\/])\.moflo[\\\/]|(?:^|[\\\/])\.claude[\\\/](?:settings(?:\.local)?\.json$|skills[\\\/]|guidance[\\\/]|agents[\\\/])/i;
728
763
  // Test files: invalidate the testing gate (tests are stale once test code changes)
729
764
  // but NOT the simplify gate — /simplify already reviewed the production code; touching
730
765
  // a test file or fixture doesn't expose new untested surface for code review (#908).
@@ -1728,14 +1763,25 @@ switch (command) {
1728
1763
  process.stderr.write(' - /verify ran and returned ' + sd.verifyOutcome + ' — fix the failing criteria, then re-run /verify\n');
1729
1764
  process.stderr.write(' (a FAIL is a real result, not a gate error; the PR is blocked because the change did not meet its acceptance criteria)\n');
1730
1765
  } else {
1731
- // Ran, but no verdict reached the gate: /verify was invoked and never
1732
- // recorded a structured outcome (interrupted, or it stored prose only).
1733
- process.stderr.write(' - /verify ran but recorded no verdict re-run it so it stores a structured result\n');
1734
- process.stderr.write(' (Step 5 of the verify skill must pass metadata.overall to memory_store)\n');
1735
- // #1348 the trap this state sets: re-invoking /verify CLEARS any prior
1736
- // verdict by design (#1332), so the obvious recovery lands right back here
1737
- // unless Step 5 completes. Say so, rather than letting it be rediscovered.
1738
- process.stderr.write(' Re-invoking /verify clears the prior verdict, so a re-run that skips Step 5 lands here again.\n');
1766
+ // Ran, but no verdict reached the gate. TWO very different causes, and
1767
+ // #1394 exists because they used to share one message that fit only the
1768
+ // first: either /verify never recorded a structured outcome, or the hook
1769
+ // that TRANSCRIBES the outcome is not wired, in which case a perfectly
1770
+ // correct verdict was stored and nothing could carry it to the gate.
1771
+ // Blaming the agent for the second case sends the user into an unbounded
1772
+ // retry loop re-running /verify cannot fix absent wiring.
1773
+ if (!isVerifyOutcomeHookWired()) {
1774
+ process.stderr.write(' - `record-verify-outcome` is not wired in .claude/settings.json — the verdict cannot be recorded\n');
1775
+ process.stderr.write(' /verify may well have passed; nothing exists to transcribe its result, so re-running it will not help.\n');
1776
+ process.stderr.write(' Fix: run `flo doctor --fix`, restart the session (Claude Code loads hooks only at start), then re-run /verify.\n');
1777
+ } else {
1778
+ process.stderr.write(' - /verify ran but recorded no verdict — re-run it so it stores a structured result\n');
1779
+ process.stderr.write(' (Step 5 of the verify skill must pass metadata.overall to memory_store)\n');
1780
+ // #1348 — the trap this state sets: re-invoking /verify CLEARS any prior
1781
+ // verdict by design (#1332), so the obvious recovery lands right back here
1782
+ // unless Step 5 completes. Say so, rather than letting it be rediscovered.
1783
+ process.stderr.write(' Re-invoking /verify clears the prior verdict, so a re-run that skips Step 5 lands here again.\n');
1784
+ }
1739
1785
  }
1740
1786
  process.stderr.write(ORDER_HINT);
1741
1787
  process.stderr.write('Disable via moflo.yaml:\n');
@@ -23,6 +23,15 @@ try { if (stdinData.trim()) hookContext = JSON.parse(stdinData); } catch (e) {}
23
23
  var userPrompt = hookContext.user_prompt || hookContext.prompt || '';
24
24
  var env = Object.assign({}, process.env, { CLAUDE_USER_PROMPT: userPrompt });
25
25
 
26
+ // #1397 — forward Claude Code's session_id, same contract as gate-hook.mjs:33.
27
+ // `prompt-reminder` stamps it onto workflow-state.json (gate.cjs), which is the
28
+ // only place `flo runs start` can read it from; without it every run record got
29
+ // sessionId:null and a zeroed token rollup. prompt-reminder is invoked through
30
+ // THIS wrapper, not gate-hook.mjs, so the id has to be forwarded here too.
31
+ if (typeof hookContext.session_id === 'string' && hookContext.session_id) {
32
+ env.HOOK_SESSION_ID = hookContext.session_id;
33
+ }
34
+
26
35
  // Run prompt-reminder via gate.cjs
27
36
  var projectDir = (env.CLAUDE_PROJECT_DIR || process.cwd()).replace(/^\/([a-z])\//i, '$1:/');
28
37
  var gateScript = resolve(projectDir, '.claude/helpers/gate.cjs');
@@ -161,11 +161,13 @@ The `check-before-pr` gate blocks `gh pr create` until `/flo-simplify` has run s
161
161
  git add <specific files>
162
162
  git commit -m "type(scope): description
163
163
 
164
- Closes #<issue-number>
165
-
166
- Co-Authored-By: moflo <noreply@cielolimitada.com>"
164
+ Closes #<issue-number>"
167
165
  ```
168
166
 
167
+ **No attribution trailer.** Do not add `Co-Authored-By:`, `Generated with …`, or any
168
+ other tool-attribution line to commits or PR bodies (#1398). This is the consumer's
169
+ repository and their git history is permanent — moflo does not sign their commits.
170
+
169
171
  ### 5.1b Verify-before-done (default; skipped only with `--no-verify`)
170
172
  **Delegate to the `/verify` skill** — `Skill({ skill: "verify" })`, passing the issue number or spec slug. That skill owns the mechanics (locate acceptance criteria → reuse Phase 4's already-green tests, no double verify → map each criterion → run only uncovered checks → record the outcome). Don't restate them here or verify in prose — *invoking* `/verify` is what records the run and satisfies the `check-before-done` gate.
171
173
 
package/bin/gate.cjs CHANGED
@@ -132,6 +132,26 @@ function readMofloYaml() {
132
132
  }
133
133
  var MOFLO_YAML = readMofloYaml();
134
134
 
135
+ // #1394 — is the hook that transcribes /verify's verdict into workflow-state.json
136
+ // actually wired? Distinguishes "the agent skipped Step 5" from "nothing exists
137
+ // to record the verdict", which need opposite remedies: the first is fixed by
138
+ // re-running /verify, the second cannot be.
139
+ //
140
+ // Deliberately NOT hoisted to module scope like MOFLO_YAML: this is only ever
141
+ // consulted on an already-blocked check-before-done path, so reading it eagerly
142
+ // would add a syscall to every Write/Edit to answer a question almost no gate
143
+ // invocation asks. Substring match (not a JSON walk) so it holds regardless of
144
+ // which matcher block a consumer's hook lives in, or how they hand-edited it.
145
+ // Unreadable/malformed settings → true, so a parse failure falls back to the
146
+ // pre-existing generic message rather than asserting a wiring bug that may not
147
+ // exist.
148
+ function isVerifyOutcomeHookWired() {
149
+ try {
150
+ return fs.readFileSync(path.join(PROJECT_DIR, '.claude', 'settings.json'), 'utf-8')
151
+ .indexOf('record-verify-outcome') >= 0;
152
+ } catch (e) { return true; }
153
+ }
154
+
135
155
  var config = loadGateConfig();
136
156
  var sddConf = loadSddConfig();
137
157
  var mergeConf = loadMergeConfig();
@@ -724,7 +744,22 @@ var EDIT_RESET_SKIP_BOTH_RE = /\.(md|markdown|txt|rst|adoc|lock|gitignore)$|(?:^
724
744
  // pure noise. Scoped to the reset only — deliberately NOT added to EXEMPT,
725
745
  // which would also un-gate reads of `.moflo/specs/**`, and those are indexed
726
746
  // guidance that memory-first should still route through a search.
727
- var EDIT_RESET_SKIP_PATH_RE = /(?:^|[\\\/])\.github[\\\/](?:workflows|ISSUE_TEMPLATE|PULL_REQUEST_TEMPLATE)(?:[\\\/.]|$)|(?:^|[\\\/])\.moflo[\\\/]/i;
747
+ // #1395 `.claude/` CONFIG joins them, by the same "doesn't expose new runtime
748
+ // surface" reasoning: hook wiring, skills and guidance are not the code under
749
+ // verification. This is the stronger case, in fact — it is the directory a user
750
+ // edits *because a gate told them to*. Before this, fixing hook wiring on a
751
+ // gate's own instruction reset verifyRun and invalidated the verification the
752
+ // fix existed to let through, so the recovery was: fix wiring → verification
753
+ // invalidated → restart session → re-run /verify → retry (#1392 field report).
754
+ //
755
+ // Scoped to config, NOT all of `.claude/**`: `scripts/` and `helpers/` hold
756
+ // executable runtime surface (gate.cjs itself lives there), and editing
757
+ // executable code SHOULD still invalidate a verification. Listing the config
758
+ // subdirectories explicitly keeps that invariant intact.
759
+ //
760
+ // Both separators in every alternative — a bare `/` here would silently no-op
761
+ // on Windows, where TOOL_INPUT paths arrive backslashed (Rule #1).
762
+ var EDIT_RESET_SKIP_PATH_RE = /(?:^|[\\\/])\.github[\\\/](?:workflows|ISSUE_TEMPLATE|PULL_REQUEST_TEMPLATE)(?:[\\\/.]|$)|(?:^|[\\\/])\.moflo[\\\/]|(?:^|[\\\/])\.claude[\\\/](?:settings(?:\.local)?\.json$|skills[\\\/]|guidance[\\\/]|agents[\\\/])/i;
728
763
  // Test files: invalidate the testing gate (tests are stale once test code changes)
729
764
  // but NOT the simplify gate — /simplify already reviewed the production code; touching
730
765
  // a test file or fixture doesn't expose new untested surface for code review (#908).
@@ -1728,14 +1763,25 @@ switch (command) {
1728
1763
  process.stderr.write(' - /verify ran and returned ' + sd.verifyOutcome + ' — fix the failing criteria, then re-run /verify\n');
1729
1764
  process.stderr.write(' (a FAIL is a real result, not a gate error; the PR is blocked because the change did not meet its acceptance criteria)\n');
1730
1765
  } else {
1731
- // Ran, but no verdict reached the gate: /verify was invoked and never
1732
- // recorded a structured outcome (interrupted, or it stored prose only).
1733
- process.stderr.write(' - /verify ran but recorded no verdict re-run it so it stores a structured result\n');
1734
- process.stderr.write(' (Step 5 of the verify skill must pass metadata.overall to memory_store)\n');
1735
- // #1348 the trap this state sets: re-invoking /verify CLEARS any prior
1736
- // verdict by design (#1332), so the obvious recovery lands right back here
1737
- // unless Step 5 completes. Say so, rather than letting it be rediscovered.
1738
- process.stderr.write(' Re-invoking /verify clears the prior verdict, so a re-run that skips Step 5 lands here again.\n');
1766
+ // Ran, but no verdict reached the gate. TWO very different causes, and
1767
+ // #1394 exists because they used to share one message that fit only the
1768
+ // first: either /verify never recorded a structured outcome, or the hook
1769
+ // that TRANSCRIBES the outcome is not wired, in which case a perfectly
1770
+ // correct verdict was stored and nothing could carry it to the gate.
1771
+ // Blaming the agent for the second case sends the user into an unbounded
1772
+ // retry loop re-running /verify cannot fix absent wiring.
1773
+ if (!isVerifyOutcomeHookWired()) {
1774
+ process.stderr.write(' - `record-verify-outcome` is not wired in .claude/settings.json — the verdict cannot be recorded\n');
1775
+ process.stderr.write(' /verify may well have passed; nothing exists to transcribe its result, so re-running it will not help.\n');
1776
+ process.stderr.write(' Fix: run `flo doctor --fix`, restart the session (Claude Code loads hooks only at start), then re-run /verify.\n');
1777
+ } else {
1778
+ process.stderr.write(' - /verify ran but recorded no verdict — re-run it so it stores a structured result\n');
1779
+ process.stderr.write(' (Step 5 of the verify skill must pass metadata.overall to memory_store)\n');
1780
+ // #1348 — the trap this state sets: re-invoking /verify CLEARS any prior
1781
+ // verdict by design (#1332), so the obvious recovery lands right back here
1782
+ // unless Step 5 completes. Say so, rather than letting it be rediscovered.
1783
+ process.stderr.write(' Re-invoking /verify clears the prior verdict, so a re-run that skips Step 5 lands here again.\n');
1784
+ }
1739
1785
  }
1740
1786
  process.stderr.write(ORDER_HINT);
1741
1787
  process.stderr.write('Disable via moflo.yaml:\n');
@@ -47,6 +47,28 @@ const DB_PATH = memoryDbPath(projectRoot);
47
47
  // Load guidance directories from moflo.yaml, falling back to defaults
48
48
  // ============================================================================
49
49
 
50
+ /**
51
+ * Absolute path to the project's SDD specs directory (default `.moflo/specs`).
52
+ *
53
+ * Validation MUST match `specsRoot()` in `src/cli/sdd/artifacts.ts` exactly, or
54
+ * the indexer and the CLI would disagree on where specs live: reject absolute /
55
+ * drive-letter / parent-escape values and fall back to the default.
56
+ *
57
+ * Cross-platform (Rule #1): split the /-written config value and re-join with
58
+ * `path.resolve`, never hardcode a separator.
59
+ */
60
+ function resolveSpecsDir(specsDirConfig) {
61
+ const raw = specsDirConfig || '.moflo/specs';
62
+ let rel = raw.split(/[\\/]+/).filter(Boolean);
63
+ const escapes = rel.length === 0
64
+ || rel.includes('..')
65
+ || /^([a-zA-Z]:|~)$/.test(rel[0])
66
+ || raw.startsWith('/')
67
+ || raw.startsWith('\\');
68
+ if (escapes) rel = ['.moflo', 'specs'];
69
+ return resolve(projectRoot, ...rel);
70
+ }
71
+
50
72
  function loadGuidanceDirs() {
51
73
  const dirs = [];
52
74
 
@@ -115,38 +137,34 @@ function loadGuidanceDirs() {
115
137
  dirs.push({ path: bundledSkillsDir, prefix: 'skill-bundled', fileFilter: ['SKILL.md'], kind: 'skill', absolute: true });
116
138
  }
117
139
 
118
- // 6. SDD spec/plan artifacts (Epic #1269) — index <specs_dir>/<slug>/{spec,plan}.md
119
- // so prior specs/plans are searchable across sessions. kind: 'spec' keys each
120
- // file by <slug>-<spec|plan> to avoid collisions between per-slug spec.md files.
121
- // #1294 the location is configurable (default .moflo/specs). Cross-platform
122
- // (Rule #1): split the /-written value and re-join, never hardcode a separator.
123
- // Validation MUST match specsRoot() in src/cli/sdd/artifacts.ts exactly, or
124
- // the indexer and the CLI would disagree on where specs live: reject
125
- // absolute / drive-letter / parent-escape values and fall back to the default.
126
- const rawSpecs = specsDirConfig || '.moflo/specs';
127
- let specsRel = rawSpecs.split(/[\\/]+/).filter(Boolean);
128
- const specsEscapes = specsRel.length === 0
129
- || specsRel.includes('..')
130
- || /^([a-zA-Z]:|~)$/.test(specsRel[0])
131
- || rawSpecs.startsWith('/')
132
- || rawSpecs.startsWith('\\');
133
- if (specsEscapes) specsRel = ['.moflo', 'specs'];
134
- const projectSpecsDir = resolve(projectRoot, ...specsRel);
135
- // Double-index guard: if specs_dir sits inside a guidance dir, the guidance
136
- // scan (step 1) already indexes those .md files skip the 'spec' entry so
137
- // they aren't indexed twice under two prefixes.
138
- const insideGuidance = userDirs.some(d => {
139
- const gd = resolve(projectRoot, ...d.split(/[\\/]+/).filter(Boolean));
140
- return projectSpecsDir === gd || projectSpecsDir.startsWith(gd + sep);
141
- });
142
- if (existsSync(projectSpecsDir) && !insideGuidance) {
143
- dirs.push({ path: specsRel.join('/'), prefix: 'spec', fileFilter: ['spec.md', 'plan.md'], kind: 'spec' });
144
- }
145
-
146
- return dirs;
140
+ // 6. SDD spec/plan artifacts are NOT indexed.
141
+ //
142
+ // They were (Epic #1269, kind: 'spec'), into this same `guidance` namespace.
143
+ // That was wrong on both axes:
144
+ //
145
+ // - Signal: a spec is PRE-implementation intent for one unit of work, not a
146
+ // project rule. Once implemented it is stale-by-construction, and a
147
+ // superseded approach surfacing at high similarity alongside real guidance
148
+ // is worse than absent. Specs accumulate without bound, so the guidance
149
+ // namespace degraded monotonically with project age.
150
+ // - Value: the active spec's path is already known (the `flo sdd` CLI just
151
+ // returned it) — reading it beats chunked retrieval of a doc you hold the
152
+ // path to. Cross-session discovery is served by `flo sdd list`, and the
153
+ // durable post-implementation signal already lands in `learnings` /
154
+ // `verify`.
155
+ //
156
+ // So the specs directory is EXCLUDED from the walk rather than merely
157
+ // skipped. Exclusion (not just dropping the step-6 entry) is what makes this
158
+ // correct for the config `moflo-sdd.md` recommends for reviewable specs
159
+ // a tracked `specs_dir` INSIDE a guidance dir, where the step-1 scan would
160
+ // otherwise pick spec.md/plan.md up as ordinary guidance markdown and
161
+ // reintroduce the pollution under a guidance prefix.
162
+ const specsDir = resolveSpecsDir(specsDirConfig);
163
+
164
+ return { dirs, excludeRoots: [specsDir] };
147
165
  }
148
166
 
149
- const GUIDANCE_DIRS = loadGuidanceDirs();
167
+ const { dirs: GUIDANCE_DIRS, excludeRoots: EXCLUDE_ROOTS } = loadGuidanceDirs();
150
168
 
151
169
  // Chunking config - optimized for Claude's retrieval
152
170
  const MIN_CHUNK_SIZE = 50; // Lower minimum to avoid mega-chunks
@@ -612,8 +630,15 @@ function indexFile(db, filePath, keyPrefix, options = {}) {
612
630
  };
613
631
  });
614
632
 
633
+ // keyPattern is load-bearing, not belt-and-braces: `${chunkPrefix}-%` as a
634
+ // bare LIKE also matches every chunk of any sibling doc whose name extends
635
+ // this one's (indexing `flo` matches `chunk-skill-flo-simplify-0`). Those
636
+ // rows aren't in chunkRows, so the orphan sweep deleted the sibling's whole
637
+ // index whenever THIS doc changed. Anchoring on the numeric chunk suffix
638
+ // confines the sweep to the keys this file actually owns.
615
639
  const counts = applyIncrementalChunks(db, NAMESPACE, chunkRows, {
616
640
  keyPrefix: `${chunkPrefix}-`,
641
+ keyPattern: new RegExp(`^${chunkPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-\\d+$`),
617
642
  });
618
643
  if (verbose) {
619
644
  debug(` Doc ${docKey}: inserted=${counts.inserted} updated=${counts.updated} unchanged=${counts.unchanged} removed=${counts.removed}`);
@@ -628,20 +653,28 @@ function indexFile(db, filePath, keyPrefix, options = {}) {
628
653
  /**
629
654
  * Recursively collect all .md files under a directory.
630
655
  * Skips node_modules, .git, and other non-content directories.
656
+ *
657
+ * `excludeRoots` (absolute paths) prunes whole subtrees — used to keep the SDD
658
+ * specs directory out of the index even when it sits inside a guidance dir.
659
+ * Compares resolved absolute paths, never raw strings, and matches on a
660
+ * `path.sep` boundary so `docs/specs` cannot also prune `docs/specs-guide`.
631
661
  */
632
- function walkMdFiles(dir) {
662
+ function walkMdFiles(dir, excludeRoots = []) {
633
663
  const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.reports']);
634
664
  // CLAUDE.md is loaded into context by Claude automatically — skip to avoid duplicate vectors
635
665
  const SKIP_FILES = new Set(['CLAUDE.md']);
636
666
  const files = [];
637
667
 
668
+ const isExcluded = (p) => excludeRoots.some(root => p === root || p.startsWith(root + sep));
669
+
638
670
  function walk(current) {
639
671
  if (!existsSync(current)) return;
640
672
  for (const entry of readdirSync(current, { withFileTypes: true })) {
673
+ const full = resolve(current, entry.name);
641
674
  if (entry.isDirectory()) {
642
- if (!SKIP_DIRS.has(entry.name)) walk(resolve(current, entry.name));
675
+ if (!SKIP_DIRS.has(entry.name) && !isExcluded(full)) walk(full);
643
676
  } else if (entry.isFile() && entry.name.endsWith('.md') && !SKIP_FILES.has(entry.name)) {
644
- files.push(resolve(current, entry.name));
677
+ if (!isExcluded(full)) files.push(full);
645
678
  }
646
679
  }
647
680
  }
@@ -659,7 +692,7 @@ function indexDirectory(db, dirConfig) {
659
692
  return results;
660
693
  }
661
694
 
662
- const allMdFiles = walkMdFiles(dirPath);
695
+ const allMdFiles = walkMdFiles(dirPath, EXCLUDE_ROOTS);
663
696
  const filtered = dirConfig.fileFilter
664
697
  ? allMdFiles.filter(f => dirConfig.fileFilter.includes(basename(f)))
665
698
  : allMdFiles;
@@ -674,17 +707,6 @@ function indexDirectory(db, dirConfig) {
674
707
  extraMetadata: { kind: 'skill', skill_name: skillName },
675
708
  extraTags: ['skill', `skill-${skillName}`],
676
709
  };
677
- } else if (dirConfig.kind === 'spec') {
678
- // kind: 'spec' (Epic #1269) — key by <slug>-<spec|plan> so a spec.md and
679
- // plan.md under the same slug, and identically-named files across slugs,
680
- // never collide on the doc key.
681
- const slug = basename(dirname(filePath));
682
- const artifact = basename(filePath, extname(filePath)); // 'spec' | 'plan'
683
- options = {
684
- nameOverride: `${slug}-${artifact}`,
685
- extraMetadata: { kind: 'spec', spec_slug: slug, artifact },
686
- extraTags: ['spec', `spec-${slug}`, artifact],
687
- };
688
710
  }
689
711
  const result = indexFile(db, filePath, dirConfig.prefix, options);
690
712
  results.push(result);
@@ -693,37 +715,77 @@ function indexDirectory(db, dirConfig) {
693
715
  return results;
694
716
  }
695
717
 
718
+ /**
719
+ * Derive a chunk row's owning doc prefix from its key.
720
+ *
721
+ * Chunk keys are `${chunkPrefix}-${i}`, so stripping the FINAL `-<digits>`
722
+ * recovers the prefix. Only the last segment is stripped, which keeps
723
+ * docs whose filename itself ends in digits intact:
724
+ * `chunk-guidance-issue-1402-0` → `chunk-guidance-issue-1402`, not
725
+ * `chunk-guidance-issue`.
726
+ *
727
+ * Returns null for a key with no numeric suffix — those are not chunk rows
728
+ * this indexer wrote, and the caller leaves them alone rather than guessing.
729
+ */
730
+ function chunkPrefixOf(key) {
731
+ const m = key.match(/^(.*)-\d+$/);
732
+ return m ? m[1] : null;
733
+ }
734
+
696
735
  /**
697
736
  * Remove stale entries for files that no longer exist on disk.
698
- * Uses the set of docKeys seen during the current indexing run to determine
699
- * which entries are stale, rather than reconstructing file paths from keys
700
- * (which breaks for files in subdirectories).
737
+ *
738
+ * Keyed on the chunk prefixes seen during the current run, NOT on `doc-*` rows.
739
+ * The original implementation enumerated `key LIKE 'doc-%'` and treated any doc
740
+ * key absent from the run as a deleted file — but #1053 S4 retired doc rows
741
+ * (the chunker stopped writing them and `purge-doc-entries` deleted the rest),
742
+ * so on any current install that query returns zero rows and the sweep was a
743
+ * silent no-op. Deleting a guidance file left its chunks — embeddings and all —
744
+ * in the namespace permanently, with nothing downstream to detect it. Specs made
745
+ * that visible because they accumulate fastest, but it stranded chunks for every
746
+ * deleted guidance file, skill, and doc.
747
+ *
748
+ * Safety: an empty live set means the run indexed nothing (I/O error, config
749
+ * pointing at a missing tree). Sweeping then would wipe the namespace, so bail
750
+ * and leave the rows for a later healthy run to reconcile.
701
751
  */
702
- function cleanStaleEntries(db, currentDocKeys) {
703
- const docsStmt = db.prepare(
704
- `SELECT DISTINCT key FROM memory_entries WHERE namespace = ? AND key LIKE 'doc-%'`
752
+ function cleanStaleEntries(db, currentChunkPrefixes) {
753
+ if (currentChunkPrefixes.size === 0) {
754
+ log(' Skipped: this run indexed no files (refusing to sweep on an empty live set)');
755
+ return 0;
756
+ }
757
+
758
+ const chunkStmt = db.prepare(
759
+ `SELECT DISTINCT key FROM memory_entries WHERE namespace = ? AND key LIKE 'chunk-%'`
705
760
  );
706
- docsStmt.bind([NAMESPACE]);
707
- const docs = [];
708
- while (docsStmt.step()) docs.push(docsStmt.getAsObject());
709
- docsStmt.free();
761
+ chunkStmt.bind([NAMESPACE]);
762
+ const chunkKeys = [];
763
+ while (chunkStmt.step()) chunkKeys.push(chunkStmt.getAsObject().key);
764
+ chunkStmt.free();
765
+
766
+ // Group stale chunk keys by prefix so the log reports one line per deleted
767
+ // file rather than one per chunk.
768
+ const stalePrefixes = new Map();
769
+ for (const key of chunkKeys) {
770
+ const prefix = chunkPrefixOf(key);
771
+ if (!prefix || currentChunkPrefixes.has(prefix)) continue;
772
+ stalePrefixes.set(prefix, (stalePrefixes.get(prefix) ?? 0) + 1);
773
+ }
710
774
 
711
775
  let staleCount = 0;
712
-
713
- for (const { key } of docs) {
714
- // If this doc key was seen during the current indexing run, it's not stale
715
- if (currentDocKeys.has(key)) continue;
716
-
717
- const chunkPrefix = key.replace('doc-', 'chunk-');
718
- const countBefore = db.exec(`SELECT COUNT(*) as cnt FROM memory_entries WHERE namespace = '${NAMESPACE}'`)[0]?.values[0][0] || 0;
719
- db.run(`DELETE FROM memory_entries WHERE namespace = ? AND key LIKE ?`, [NAMESPACE, `${chunkPrefix}%`]);
720
- db.run(`DELETE FROM memory_entries WHERE namespace = ? AND key = ?`, [NAMESPACE, key]);
721
- const countAfter = db.exec(`SELECT COUNT(*) as cnt FROM memory_entries WHERE namespace = '${NAMESPACE}'`)[0]?.values[0][0] || 0;
722
- const removed = countBefore - countAfter;
723
- if (removed > 0) {
724
- log(` Removed ${removed} stale entries for deleted file: ${key}`);
725
- staleCount += removed;
776
+ const del = db.prepare(`DELETE FROM memory_entries WHERE namespace = ? AND key = ?`);
777
+ try {
778
+ for (const key of chunkKeys) {
779
+ const prefix = chunkPrefixOf(key);
780
+ if (!prefix || currentChunkPrefixes.has(prefix)) continue;
781
+ del.run([NAMESPACE, key]);
782
+ staleCount++;
726
783
  }
784
+ } finally {
785
+ del.free();
786
+ }
787
+ for (const [prefix, count] of stalePrefixes) {
788
+ log(` Removed ${count} stale entries for deleted file: ${prefix}`);
727
789
  }
728
790
 
729
791
  // Also clean any orphaned entries not matching doc-/chunk- patterns
@@ -740,6 +802,22 @@ function cleanStaleEntries(db, currentDocKeys) {
740
802
  log(` Removed orphan entry: ${key}`);
741
803
  }
742
804
 
805
+ // Legacy `doc-*` rows from a pre-#1053-S4 install that never ran the
806
+ // purge-doc-entries migration. Unconditional — the chunker has not written
807
+ // one since S4, so any survivor is stale by definition.
808
+ const docStmt = db.prepare(
809
+ `SELECT key FROM memory_entries WHERE namespace = ? AND key LIKE 'doc-%'`
810
+ );
811
+ docStmt.bind([NAMESPACE]);
812
+ const legacyDocs = [];
813
+ while (docStmt.step()) legacyDocs.push(docStmt.getAsObject().key);
814
+ docStmt.free();
815
+ for (const key of legacyDocs) {
816
+ db.run(`DELETE FROM memory_entries WHERE namespace = ? AND key = ?`, [NAMESPACE, key]);
817
+ staleCount++;
818
+ log(` Removed legacy doc entry: ${key}`);
819
+ }
820
+
743
821
  return staleCount;
744
822
  }
745
823
 
@@ -760,7 +838,10 @@ let docsIndexed = 0;
760
838
  let chunksIndexed = 0;
761
839
  let unchanged = 0;
762
840
  let errors = 0;
763
- const currentDocKeys = new Set();
841
+ // Chunk prefixes written by this run — the live set the stale sweep diffs
842
+ // against. Populated from every file that indexed OR was skipped as unchanged;
843
+ // an unchanged file is very much still on disk.
844
+ const currentChunkPrefixes = new Set();
764
845
 
765
846
  if (specificFile) {
766
847
  // Index single file
@@ -794,7 +875,7 @@ if (specificFile) {
794
875
 
795
876
  for (const result of results) {
796
877
  if (result.status === 'indexed' || result.status === 'unchanged') {
797
- currentDocKeys.add(result.docKey);
878
+ currentChunkPrefixes.add(result.docKey.replace(/^doc-/, 'chunk-'));
798
879
  }
799
880
  if (result.status === 'indexed') {
800
881
  log(` ✅ ${result.docKey} (${result.chunks} chunks)`);
@@ -814,7 +895,7 @@ if (specificFile) {
814
895
  let staleRemoved = 0;
815
896
  if (!specificFile) {
816
897
  log('Cleaning stale entries for deleted files...');
817
- staleRemoved = cleanStaleEntries(db, currentDocKeys);
898
+ staleRemoved = cleanStaleEntries(db, currentChunkPrefixes);
818
899
  if (staleRemoved === 0) {
819
900
  log(' No stale entries found');
820
901
  }
@@ -98,9 +98,12 @@ export function schemeTaggedContentHash(files, schemeVersion) {
98
98
  * @param {string} namespace
99
99
  * @param {string} [keyPrefix] — when set, restricts the scan to `key LIKE '<prefix>%'`.
100
100
  * The same prefix scopes the orphan sweep in {@link applyIncrementalChunks}.
101
+ * @param {RegExp} [keyPattern] — optional second filter applied in JS after the
102
+ * SQL `LIKE`. Required whenever one caller's `keyPrefix` can be a string
103
+ * prefix of another's — see {@link applyIncrementalChunks}.
101
104
  * @returns {Map<string,string>}
102
105
  */
103
- export function loadExistingContent(db, namespace, keyPrefix) {
106
+ export function loadExistingContent(db, namespace, keyPrefix, keyPattern) {
104
107
  const stmt = keyPrefix
105
108
  ? db.prepare(
106
109
  `SELECT key, content FROM memory_entries WHERE namespace = ? AND key LIKE ? AND status = 'active'`,
@@ -116,7 +119,9 @@ export function loadExistingContent(db, namespace, keyPrefix) {
116
119
  const map = new Map();
117
120
  while (stmt.step()) {
118
121
  const row = stmt.getAsObject();
119
- map.set(String(row.key), String(row.content ?? ''));
122
+ const key = String(row.key);
123
+ if (keyPattern && !keyPattern.test(key)) continue;
124
+ map.set(key, String(row.content ?? ''));
120
125
  }
121
126
  stmt.free();
122
127
  return map;
@@ -137,12 +142,21 @@ export function loadExistingContent(db, namespace, keyPrefix) {
137
142
  * when processing a single file's chunks at a time (e.g. index-guidance.mjs
138
143
  * iterates files independently) — without it the sweep would delete every
139
144
  * chunk from every OTHER file as an orphan on each call.
145
+ * @param {RegExp} [opts.keyPattern] — narrows `keyPrefix` beyond what SQL `LIKE`
146
+ * can express. REQUIRED when one file's prefix can be a string prefix of
147
+ * another's, which is exactly the case for per-file chunk keys: indexing
148
+ * `flo` scopes to `chunk-skill-flo-%`, and that LIKE also matches every chunk
149
+ * of `flo-simplify`. Those rows are absent from the caller's `chunks`, so the
150
+ * orphan sweep deleted a sibling document's entire index — it reappeared only
151
+ * on the next run, re-inserted with a NULL embedding and re-vectorised from
152
+ * scratch. Passing `/^chunk-skill-flo-\d+$/` confines the sweep to the chunk
153
+ * keys the caller actually owns.
140
154
  * @returns {{inserted:number, updated:number, unchanged:number, removed:number}}
141
155
  */
142
156
  export function applyIncrementalChunks(db, namespace, chunks, opts = {}) {
143
157
  const serialize = opts.serialize !== false;
144
158
  const keyPrefix = opts.keyPrefix;
145
- const existing = loadExistingContent(db, namespace, keyPrefix);
159
+ const existing = loadExistingContent(db, namespace, keyPrefix, opts.keyPattern);
146
160
  const newKeys = new Set();
147
161
  let inserted = 0;
148
162
  let updated = 0;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Migration: hard-delete SDD spec/plan chunks from the `guidance` namespace.
3
+ *
4
+ * `bin/index-guidance.mjs` used to index `<specs_dir>/<slug>/{spec,plan}.md`
5
+ * into `guidance` alongside real project guidance (Epic #1269, `kind: 'spec'`).
6
+ * A spec is pre-implementation intent for one unit of work, not a project rule:
7
+ * once implemented it is stale-by-construction, and specs accumulate without
8
+ * bound, so the namespace degraded monotonically with project age. The indexer
9
+ * now excludes the specs directory outright.
10
+ *
11
+ * This clears what earlier versions already wrote. Two shapes existed:
12
+ *
13
+ * 1. `chunk-spec-*` keys with `metadata.kind === 'spec'` — the dedicated
14
+ * step-6 path, used when `specs_dir` sat OUTSIDE every guidance directory.
15
+ * 2. Ordinary guidance chunks under a guidance prefix — produced when
16
+ * `specs_dir` sat INSIDE a guidance dir (the config `moflo-sdd.md`
17
+ * recommends for reviewable specs). These carry no spec marker at all and
18
+ * are indistinguishable from real guidance by key or metadata.
19
+ *
20
+ * Only shape 1 is purged here, because it is the only one that can be
21
+ * identified without guessing. Shape 2 is handled by the repaired stale sweep
22
+ * in `bin/index-guidance.mjs`: the specs directory is now pruned from the walk,
23
+ * so those chunk prefixes fall out of the live set on the next index run and
24
+ * are swept as deleted files. Deleting them here by path-matching would risk
25
+ * taking real guidance with them.
26
+ *
27
+ * Idempotent: re-runs find no matching rows.
28
+ *
29
+ * @module bin/migrations/purge-spec-chunks
30
+ */
31
+
32
+ import { existsSync } from 'fs';
33
+ import { memoryDbPath } from '../lib/moflo-paths.mjs';
34
+ import { openBackend } from '../lib/get-backend.mjs';
35
+
36
+ export const name = 'purge-spec-chunks';
37
+ // After purge-doc-entries (0) and strip-context-preambles (20) so this operates
38
+ // on an already-normalised chunk table.
39
+ export const order = 30;
40
+
41
+ /**
42
+ * @param {string} projectRoot
43
+ * @returns {Promise<{purged:number}>}
44
+ */
45
+ export async function run(projectRoot) {
46
+ const dbPath = memoryDbPath(projectRoot);
47
+ if (!existsSync(dbPath)) return { purged: 0 };
48
+
49
+ const db = await openBackend(projectRoot, { create: false });
50
+
51
+ // Two independent markers, OR'd, because they were written by the same code
52
+ // path and either alone would leave rows behind on a partial index:
53
+ // - key prefix `chunk-spec-` (dirConfig.prefix === 'spec')
54
+ // - metadata.kind === 'spec' (survives even if the prefix ever changed)
55
+ // Scoped to `guidance` — the only namespace bin/index-guidance.mjs writes —
56
+ // so a user-stored entry elsewhere that happens to match is never touched.
57
+ const WHERE = `namespace = 'guidance'
58
+ AND (key LIKE 'chunk-spec-%' OR metadata LIKE '%"kind":"spec"%')`;
59
+
60
+ const countStmt = db.prepare(`SELECT COUNT(*) AS cnt FROM memory_entries WHERE ${WHERE}`);
61
+ countStmt.step();
62
+ const beforeCount = Number(countStmt.getAsObject().cnt ?? 0);
63
+ countStmt.free();
64
+
65
+ if (beforeCount === 0) {
66
+ db.close();
67
+ return { purged: 0 };
68
+ }
69
+
70
+ db.run(`DELETE FROM memory_entries WHERE ${WHERE}`);
71
+ const purged = db.getRowsModified?.() ?? beforeCount;
72
+
73
+ // No explicit HNSW invalidation needed: the delete moves the DB/WAL mtime,
74
+ // which is exactly what the `hnsw-rebuild` step gates on, so the sidecar
75
+ // reconciles on the next session-start indexer pass.
76
+ if (purged > 0) db.save();
77
+ db.close();
78
+ return { purged };
79
+ }
@@ -23,6 +23,15 @@ try { if (stdinData.trim()) hookContext = JSON.parse(stdinData); } catch (e) {}
23
23
  var userPrompt = hookContext.user_prompt || hookContext.prompt || '';
24
24
  var env = Object.assign({}, process.env, { CLAUDE_USER_PROMPT: userPrompt });
25
25
 
26
+ // #1397 — forward Claude Code's session_id, same contract as gate-hook.mjs:33.
27
+ // `prompt-reminder` stamps it onto workflow-state.json (gate.cjs), which is the
28
+ // only place `flo runs start` can read it from; without it every run record got
29
+ // sessionId:null and a zeroed token rollup. prompt-reminder is invoked through
30
+ // THIS wrapper, not gate-hook.mjs, so the id has to be forwarded here too.
31
+ if (typeof hookContext.session_id === 'string' && hookContext.session_id) {
32
+ env.HOOK_SESSION_ID = hookContext.session_id;
33
+ }
34
+
26
35
  // Run prompt-reminder via gate.cjs
27
36
  var projectDir = (env.CLAUDE_PROJECT_DIR || process.cwd()).replace(/^\/([a-z])\//i, '$1:/');
28
37
  var gateScript = resolve(projectDir, '.claude/helpers/gate.cjs');
@@ -1591,6 +1591,18 @@ try {
1591
1591
  settingsChanges.push(`repaired ${plural(repaired.length, 'hook wiring')}`);
1592
1592
  }
1593
1593
  }
1594
+ // #1398 — strip the legacy attribution block. Claude Code reads this key
1595
+ // and injects it into the agent's instructions, so an upgraded consumer
1596
+ // keeps stamping moflo's identity into their commits until it is removed;
1597
+ // dropping it from the generator alone only fixes fresh installs.
1598
+ // `typeof` guard so an older installed moflo without the export is a
1599
+ // silent no-op rather than a session-start crash.
1600
+ if (typeof mod.removeLegacyAttribution === 'function') {
1601
+ if (mod.removeLegacyAttribution(settings)) {
1602
+ dirty = true;
1603
+ settingsChanges.push('removed legacy attribution block');
1604
+ }
1605
+ }
1594
1606
  }
1595
1607
  } catch (err) {
1596
1608
  emitWarning(`hook-wiring repair skipped (${errMessage(err)})`);
@@ -313,6 +313,18 @@ function readMofloYaml() {
313
313
  }
314
314
  var MOFLO_YAML = readMofloYaml();
315
315
 
316
+ // #1394 — is the hook that transcribes /verify's verdict wired at all?
317
+ // Distinguishes "agent skipped Step 5" from "nothing can record the verdict";
318
+ // only the first is fixable by re-running /verify. Lazy (blocked path only);
319
+ // unreadable settings → true so a parse failure keeps the generic message.
320
+ // SYNC: mirrors bin/gate.cjs isVerifyOutcomeHookWired.
321
+ function isVerifyOutcomeHookWired() {
322
+ try {
323
+ return fs.readFileSync(path.join(PROJECT_DIR, '.claude', 'settings.json'), 'utf-8')
324
+ .indexOf('record-verify-outcome') >= 0;
325
+ } catch (e) { return true; }
326
+ }
327
+
316
328
  var config = loadGateConfig();
317
329
  var sddConf = loadSddConfig();
318
330
  var mergeConf = loadMergeConfig();
@@ -670,7 +682,10 @@ var EDIT_RESET_SKIP_BOTH_RE = /\\.(md|markdown|txt|rst|adoc|lock|gitignore)$|(?:
670
682
  // #1297 — path-inert dirs (.github/workflows etc.); SYNC: mirrors bin/gate.cjs EDIT_RESET_SKIP_PATH_RE.
671
683
  // #1348 — plus \`.moflo/\`, moflo's own gitignored state dir: nothing written
672
684
  // there can reach the branch diff, so it must not invalidate a gate.
673
- var EDIT_RESET_SKIP_PATH_RE = /(?:^|[\\\\\\/])\\.github[\\\\\\/](?:workflows|ISSUE_TEMPLATE|PULL_REQUEST_TEMPLATE)(?:[\\\\\\/.]|$)|(?:^|[\\\\\\/])\\.moflo[\\\\\\/]/i;
685
+ // #1395 \`.claude/\` CONFIG (settings/skills/guidance/agents) joins them: it is
686
+ // not the code under verification, and it is the directory a user edits because
687
+ // a gate told them to. \`scripts/\`/\`helpers/\` stay OUT — they are executable.
688
+ var EDIT_RESET_SKIP_PATH_RE = /(?:^|[\\\\\\/])\\.github[\\\\\\/](?:workflows|ISSUE_TEMPLATE|PULL_REQUEST_TEMPLATE)(?:[\\\\\\/.]|$)|(?:^|[\\\\\\/])\\.moflo[\\\\\\/]|(?:^|[\\\\\\/])\\.claude[\\\\\\/](?:settings(?:\\.local)?\\.json$|skills[\\\\\\/]|guidance[\\\\\\/]|agents[\\\\\\/])/i;
674
689
  // Test files: invalidate testsRun but preserve simplifyRun (#908) — /simplify
675
690
  // already reviewed the production code, touching tests/fixtures doesn't expose
676
691
  // new untested surface for code review.
@@ -1003,6 +1018,12 @@ switch (command) {
1003
1018
  process.stderr.write(' - the change has not been verified since the last code edit (run /verify)\\n');
1004
1019
  } else if (s.verifyOutcome === 'FAIL' || s.verifyOutcome === 'UNVERIFIED') {
1005
1020
  process.stderr.write(' - /verify ran and returned ' + s.verifyOutcome + ' — fix the failing criteria, then re-run /verify\\n');
1021
+ // #1394 — two causes, opposite remedies. Re-running /verify cannot fix
1022
+ // absent wiring, so never prescribe it when the transcriber is missing.
1023
+ } else if (!isVerifyOutcomeHookWired()) {
1024
+ process.stderr.write(' - \`record-verify-outcome\` is not wired in .claude/settings.json — the verdict cannot be recorded\\n');
1025
+ process.stderr.write(' /verify may well have passed; nothing exists to transcribe its result, so re-running it will not help.\\n');
1026
+ process.stderr.write(' Fix: run \`flo doctor --fix\`, restart the session, then re-run /verify.\\n');
1006
1027
  } else {
1007
1028
  process.stderr.write(' - /verify ran but recorded no verdict — re-run it so it stores a structured result\\n');
1008
1029
  // #1348 — re-invoking /verify clears the prior verdict by design (#1332),
@@ -1276,6 +1297,12 @@ try { if (stdinData.trim()) hookContext = JSON.parse(stdinData); } catch (e) {}
1276
1297
  var userPrompt = hookContext.user_prompt || hookContext.prompt || '';
1277
1298
  var env = Object.assign({}, process.env, { CLAUDE_USER_PROMPT: userPrompt });
1278
1299
 
1300
+ // #1397 — forward session_id so prompt-reminder can stamp it onto
1301
+ // workflow-state.json; \`flo runs start\` has no other source for it.
1302
+ if (typeof hookContext.session_id === 'string' && hookContext.session_id) {
1303
+ env.HOOK_SESSION_ID = hookContext.session_id;
1304
+ }
1305
+
1279
1306
  // Run prompt-reminder via gate.cjs
1280
1307
  var projectDir = (env.CLAUDE_PROJECT_DIR || process.cwd()).replace(/^\\/([a-z])\\//i, '$1:/');
1281
1308
  var gateScript = resolve(projectDir, '.claude/helpers/gate.cjs');
@@ -21,7 +21,7 @@ import { loadShippedScripts } from './shipped-scripts.js';
21
21
  import { DEFAULT_INIT_OPTIONS } from './types.js';
22
22
  import { generateSettings } from './settings-generator.js';
23
23
  import { applyWholesaleRegeneration, computeHookBlockDrift, isHookBlockLocked, } from '../services/hook-block-hash.js';
24
- import { rewriteIncorrectHookWiring } from '../services/hook-wiring.js';
24
+ import { rewriteIncorrectHookWiring, removeLegacyAttribution } from '../services/hook-wiring.js';
25
25
  export { discoverTestDirs };
26
26
  // ============================================================================
27
27
  // Init
@@ -253,11 +253,18 @@ function generateHooks(root, force, _answers) {
253
253
  // grafted back into the fresh tree.
254
254
  preserved = extraCount - removed;
255
255
  }
256
- // Ensure statusLine + permissions/env/attribution scaffold is present
257
- // mirrors the existing moflo-init.ts UX but no longer overwrites user
258
- // values that are already set.
256
+ // #1398 strip the legacy attribution block on this path too, so `flo init`
257
+ // on an existing project and `doctor --fix` heal it as well as session start.
258
+ // NOT inert: Claude Code reads `settings.attribution` and injects it into the
259
+ // agent's instructions, so leaving it keeps moflo's trailer on the consumer's
260
+ // commits regardless of what the generator now emits.
261
+ const strippedAttribution = removeLegacyAttribution(existing);
262
+ // Ensure statusLine + permissions/env scaffold is present — mirrors the
263
+ // existing moflo-init.ts UX but no longer overwrites user values that are
264
+ // already set. `attribution` is deliberately absent from the scaffold list:
265
+ // the generator no longer emits it, so it could only ever copy `undefined`.
259
266
  const canonical = generateSettings({ ...DEFAULT_INIT_OPTIONS, targetDir: root, force: true });
260
- const scaffoldKeys = ['statusLine', 'permissions', 'env', 'attribution'];
267
+ const scaffoldKeys = ['statusLine', 'permissions', 'env'];
261
268
  const scaffoldAdded = [];
262
269
  for (const key of scaffoldKeys) {
263
270
  if (existing[key] == null && canonical[key] != null) {
@@ -265,7 +272,8 @@ function generateHooks(root, force, _answers) {
265
272
  scaffoldAdded.push(key);
266
273
  }
267
274
  }
268
- const dirty = rewroteCommands > 0 || added > 0 || removed > 0 || scaffoldAdded.length > 0;
275
+ const dirty = rewroteCommands > 0 || added > 0 || removed > 0 || scaffoldAdded.length > 0
276
+ || strippedAttribution;
269
277
  if (!dirty) {
270
278
  return { name: '.claude/settings.json', status: 'skipped', detail: 'already at canonical reference' };
271
279
  }
@@ -283,6 +291,8 @@ function generateHooks(root, force, _answers) {
283
291
  parts.push(`↻${rewroteCommands} rewrites`);
284
292
  if (scaffoldAdded.length > 0)
285
293
  parts.push(`+scaffold (${scaffoldAdded.join(',')})`);
294
+ if (strippedAttribution)
295
+ parts.push('-attribution (#1398)');
286
296
  return { name: '.claude/settings.json', status: 'updated', detail: parts.join(', ') };
287
297
  }
288
298
  // ============================================================================
@@ -37,11 +37,11 @@ export function generateSettings(options) {
37
37
  'Read(./.env.*)',
38
38
  ],
39
39
  };
40
- // Add claude-flow attribution for git commits and PRs
41
- settings.attribution = {
42
- commit: 'Co-Authored-By: moflo <noreply@cielolimitada.com>',
43
- pr: '🤖 Generated with [moflo](https://github.com/eric-cielo/moflo)',
44
- };
40
+ // #1398 no attribution block. moflo used to write a `Co-Authored-By` trailer
41
+ // and a "Generated with moflo" PR banner into every consumer's settings.json.
42
+ // Nothing ever read the key (the /flo skill hardcoded the literal instead), and
43
+ // stamping a tool's identity into someone else's permanent git history is not
44
+ // moflo's call to make. Do not reintroduce — see issue #1398.
45
45
  // Note: Claude Code expects 'model' to be a string, not an object
46
46
  // Model preferences are stored in moflo settings instead
47
47
  // settings.model = 'sonnet'; // Uncomment if you want to set a default model
@@ -30,6 +30,35 @@ function makeEntryCacheKey(namespace, key) {
30
30
  const safeKey = String(key).replace(/:/g, '_');
31
31
  return `entry:${safeNs}:${safeKey}`;
32
32
  }
33
+ /**
34
+ * Minimum gap between `access_count` writes for a single key (#1402).
35
+ *
36
+ * #1396 made a cache hit bump `access_count`, which is correct — the counter
37
+ * feeds `sortBy('accessCount')` and stats, so a row read repeatedly inside the
38
+ * cache TTL must not look untouched. But `bridgeGetEntry` is called in fan-out
39
+ * loops, not once per user retrieve: the dashboard's `/api/schedules` and
40
+ * `/api/spells` handlers issue up to 300 `getEntry` calls between them, and the
41
+ * browser polls both every 5s — ~60 writes/sec against the same hot keys, where
42
+ * before the fix it was zero I/O.
43
+ *
44
+ * A global debounce timer would need a flush-on-exit hook, and moflo's
45
+ * short-lived CLI processes would silently drop counts on exit — the same
46
+ * "observability that quietly lies" failure #1396 existed to remove. Throttling
47
+ * per key needs no timer and no exit hook: the delta rides on the cached record
48
+ * itself, which every hit already touches.
49
+ *
50
+ * KNOWN RESIDUAL — not lossless, and deliberately so. If the cache evicts a
51
+ * record (5-minute TTL, or LRU at 10k entries) while its delta is unflushed,
52
+ * those accesses are gone; likewise a process crash mid-interval. Draining on
53
+ * eviction would mean issuing async DB work from `CacheManager.evictLRU`, a
54
+ * synchronous path in a different module — real blast radius on the memory
55
+ * chokepoint to recover at most one interval's counts for a key that, by virtue
56
+ * of being evicted, is not in a hot read loop. The keys this throttle exists
57
+ * for flush every interval, well inside the TTL. `access_count` feeds ordering
58
+ * and stats, not accounting, so a bounded undercount on cold keys is the right
59
+ * trade; a systematic undercount of HOT keys — the #1396 defect — is not.
60
+ */
61
+ const ACCESS_FLUSH_INTERVAL_MS = 30_000;
33
62
  /** Normalise `metadata` for the `metadata` TEXT column; `undefined` → `'{}'` (#1064). */
34
63
  export function serialiseMetadata(metadata) {
35
64
  if (metadata == null)
@@ -81,6 +110,7 @@ async function cacheGet(registry, cacheKey) {
81
110
  return null;
82
111
  return (await cache.get(cacheKey)) ?? null;
83
112
  }
113
+ /** Typed on purpose (#1396) — a partial cache value is a compile error, not a silent read-side data loss. */
84
114
  async function cacheSet(registry, cacheKey, value) {
85
115
  const cache = registry.get('tieredCache');
86
116
  if (!cache)
@@ -265,9 +295,18 @@ export async function bridgeStoreEntry(options) {
265
295
  // Without this, chunk-row producers writing through the chokepoint would
266
296
  // get `{}` back from cache and the full metadata from disk — exactly the
267
297
  // divergence the cache is supposed to mask.
298
+ //
299
+ // #1396 — and the same argument applies to every other column the reader
300
+ // returns. Warm the FULL CachedEntry shape, not just the embedding and
301
+ // metadata, or a retrieve inside the cache TTL reports empty tags, a zero
302
+ // access count, and a storedAt of "now".
268
303
  await cacheSet(registry, cacheKey, {
269
304
  id, key, namespace, content: value,
270
- embedding: embeddingJson,
305
+ accessCount: 0,
306
+ createdAt: now,
307
+ updatedAt: now,
308
+ hasEmbedding: !!embeddingJson,
309
+ tags,
271
310
  metadata: metadataJson,
272
311
  });
273
312
  }
@@ -386,10 +425,14 @@ export async function bridgeStoreEntries(items, dbPath) {
386
425
  anyEmbedded = true;
387
426
  deferredBookkeeping.push({
388
427
  cacheKey: makeEntryCacheKey(namespace, key),
389
- // #1064 — keep cache shape in sync with disk (see single-store path).
428
+ // #1064 / #1396 — keep cache shape in sync with disk (see single-store path).
390
429
  cacheValue: {
391
430
  id, key, namespace, content: value,
392
- embedding: embeddingJson,
431
+ accessCount: 0,
432
+ createdAt: now,
433
+ updatedAt: now,
434
+ hasEmbedding: !!embeddingJson,
435
+ tags,
393
436
  metadata: metadataJson,
394
437
  },
395
438
  entryId: id,
@@ -594,24 +637,98 @@ export async function bridgeGetEntry(options) {
594
637
  const { key, namespace = 'default' } = options;
595
638
  const cacheKey = makeEntryCacheKey(namespace, key);
596
639
  const cached = await cacheGet(registry, cacheKey);
597
- if (cached && cached.content) {
598
- return {
599
- success: true,
600
- found: true,
601
- cacheHit: true,
602
- entry: {
603
- id: String(cached.id || ''),
604
- key: cached.key || key,
605
- namespace: cached.namespace || namespace,
606
- content: cached.content || '',
607
- accessCount: cached.accessCount ?? 0,
608
- createdAt: cached.createdAt || new Date().toISOString(),
609
- updatedAt: cached.updatedAt || new Date().toISOString(),
610
- hasEmbedding: !!cached.embedding,
611
- tags: cached.tags || [],
612
- metadata: cached.metadata || undefined,
613
- },
640
+ // A value written by a pre-#1396 build carries only
641
+ // `{id,key,namespace,content,embedding,metadata}`. Serving it means
642
+ // fabricating the absent columns — which IS the bug — so treat a partial
643
+ // value as a miss and fall through to the disk read, which returns the true
644
+ // row and re-caches the full shape. Self-heals on the first read after an
645
+ // in-place upgrade, and since the L1 cache is in-memory only, nothing
646
+ // outlives the process anyway.
647
+ // `content !== undefined` rather than a truthy test: a row whose content is
648
+ // legitimately `''` would otherwise fail this check on every read, be
649
+ // re-fetched from disk, re-cached as `''`, and fail again — a permanent
650
+ // cache bypass for that key. Pre-existing, but this is the condition it
651
+ // lives in.
652
+ const usableCache = cached
653
+ && cached.content !== undefined
654
+ && cached.createdAt !== undefined
655
+ && cached.hasEmbedding !== undefined
656
+ && Array.isArray(cached.tags);
657
+ if (usableCache) {
658
+ // #1396 — a cache hit is still an access. The access_count bump used to
659
+ // live only on the disk path below, so a row read repeatedly inside the
660
+ // cache TTL reported the same count forever — and `accessCount` is an
661
+ // orderable field (query-builder's `sortBy('accessCount')`) plus a stats
662
+ // input, so the hottest rows ranked as the coldest.
663
+ //
664
+ // #1402 — the write is THROTTLED per key. Every hit still increments the
665
+ // count the caller sees; the DB write is coalesced to at most one per
666
+ // ACCESS_FLUSH_INTERVAL_MS, because this function runs inside fan-out
667
+ // loops (see the constant's docstring) where a write-per-hit is ~60
668
+ // writes/sec against the same handful of keys.
669
+ //
670
+ // The UPDATE adds the accumulated delta and is evaluated by SQLite, so
671
+ // the stored counter stays correct under concurrency and never depends on
672
+ // a client-computed absolute. No persist call here on purpose — the disk
673
+ // path below has never had one either, because #1058 removed the
674
+ // read-side `db.export()` writeback that clobbered concurrent writers.
675
+ // Under node:sqlite the UPDATE is durable regardless.
676
+ const now = Date.now();
677
+ // MUTATE THE CACHED RECORD IN PLACE — do not rebuild it.
678
+ //
679
+ // The guarantee this rests on is OBJECT IDENTITY, not synchrony:
680
+ // `TieredCacheManager.get` is async, but it hands back the stored object
681
+ // unchanged from `CacheManager.get`, which returns `node.value.data` with
682
+ // no clone. So every concurrent reader of this key holds the SAME object,
683
+ // and the two lines below are a read-modify-write with no `await` between
684
+ // the read and the write — atomic under Node's single thread.
685
+ //
686
+ // Building a replacement record and writing it back with `cacheSet`
687
+ // instead reintroduces a lost update: both callers read the same delta
688
+ // across the await boundary and the second write clobbers the first,
689
+ // permanently dropping an access. That interleaving is reachable on the
690
+ // very workload this throttle is for — the neighbour fan-out fetches
691
+ // adjacent chunk keys in parallel and two hits can share a neighbour.
692
+ //
693
+ // If the cache ever starts cloning on read, the delta stops accumulating
694
+ // and this silently under-persists. `loses no counts when the same key is
695
+ // read concurrently` is the test that would catch it.
696
+ cached.accessCount = (cached.accessCount ?? 0) + 1;
697
+ cached.pendingAccessDelta = (cached.pendingAccessDelta ?? 0) + 1;
698
+ // A record with no flush stamp (pre-#1402 shape, or one this process has
699
+ // never flushed) flushes immediately rather than waiting out the interval.
700
+ const lastFlushAt = cached.lastAccessFlushAt ?? 0;
701
+ if (now - lastFlushAt >= ACCESS_FLUSH_INTERVAL_MS) {
702
+ try {
703
+ ctx.db.prepare(`UPDATE memory_entries SET access_count = access_count + ?, last_accessed_at = ? WHERE id = ?`).run([cached.pendingAccessDelta, now, String(cached.id || '')]);
704
+ // Clear ONLY after the write lands. Clearing on a throw would discard
705
+ // the accumulated hits outright — the throttle defers writes, it does
706
+ // not drop them.
707
+ cached.pendingAccessDelta = 0;
708
+ cached.lastAccessFlushAt = now;
709
+ }
710
+ catch {
711
+ // Non-fatal — the delta rides along to the next attempt.
712
+ }
713
+ }
714
+ // Built field-by-field from the cached record rather than spread from it,
715
+ // so the throttle bookkeeping can never surface in an MCP response.
716
+ const entry = {
717
+ id: String(cached.id || ''),
718
+ key: cached.key || key,
719
+ namespace: cached.namespace || namespace,
720
+ content: cached.content,
721
+ accessCount: cached.accessCount,
722
+ createdAt: cached.createdAt,
723
+ updatedAt: cached.updatedAt,
724
+ hasEmbedding: cached.hasEmbedding,
725
+ tags: cached.tags,
726
+ metadata: cached.metadata || undefined,
614
727
  };
728
+ // No `cacheSet` here: the record above IS the cached object and was
729
+ // updated in place, so writing a copy back would be redundant work on the
730
+ // hot path — and would reopen the lost-update window this branch closes.
731
+ return { success: true, found: true, cacheHit: true, entry };
615
732
  }
616
733
  let row;
617
734
  try {
@@ -636,8 +753,13 @@ export async function bridgeGetEntry(options) {
636
753
  }
637
754
  if (!row)
638
755
  return { success: true, found: false };
756
+ // The disk path writes its own +1 immediately (it is by definition not a
757
+ // repeat read), so it doubles as this key's flush point: stamp `now` below
758
+ // and start the delta at 0 so the next cache hit begins a fresh interval
759
+ // rather than re-counting this access (#1402).
760
+ const diskReadAt = Date.now();
639
761
  try {
640
- ctx.db.prepare(`UPDATE memory_entries SET access_count = access_count + 1, last_accessed_at = ? WHERE id = ?`).run([Date.now(), row.id]);
762
+ ctx.db.prepare(`UPDATE memory_entries SET access_count = access_count + 1, last_accessed_at = ? WHERE id = ?`).run([diskReadAt, row.id]);
641
763
  }
642
764
  catch {
643
765
  // Non-fatal
@@ -661,7 +783,11 @@ export async function bridgeGetEntry(options) {
661
783
  tags,
662
784
  metadata: row.metadata != null ? String(row.metadata) : undefined,
663
785
  };
664
- await cacheSet(registry, cacheKey, entry);
786
+ await cacheSet(registry, cacheKey, {
787
+ ...entry,
788
+ pendingAccessDelta: 0,
789
+ lastAccessFlushAt: diskReadAt,
790
+ });
665
791
  return { success: true, found: true, cacheHit: false, entry };
666
792
  });
667
793
  }
@@ -9,6 +9,52 @@
9
9
  * session-start-launcher.mjs in consumer projects, where transitive
10
10
  * dependencies (project-root.js, etc.) may not resolve.
11
11
  */
12
+ /**
13
+ * #1398 — remove the legacy `attribution` block from a consumer's settings.json.
14
+ *
15
+ * This is NOT cosmetic cleanup. `settings.attribution` is read by Claude Code
16
+ * itself, which injects `attribution.commit` / `attribution.pr` into the agent's
17
+ * instructions — which is how the `Co-Authored-By: moflo …` trailer and the
18
+ * "Generated with moflo" PR banner actually reached commits. No moflo code reads
19
+ * the key, so it looks inert from inside this repo; it is not.
20
+ *
21
+ * Consequence: dropping the block from `settings-generator.ts` fixes FRESH
22
+ * installs only. Every project that upgrades keeps the key it was given at init
23
+ * and keeps stamping moflo's identity into its own permanent git history. The
24
+ * generator edit without this one is precisely the fresh-install-only change
25
+ * `internal/upgrade-contract.md` § "Design for the upgrade path first" warns about.
26
+ *
27
+ * Deletes only the exact keys moflo wrote. A consumer who set their own
28
+ * `attribution` deliberately keeps it — we remove ours, not theirs.
29
+ *
30
+ * @returns true when the settings object was modified.
31
+ */
32
+ export function removeLegacyAttribution(settings) {
33
+ const attribution = settings.attribution;
34
+ // Array.isArray guard: `typeof [] === 'object'`, so a bogus array value would
35
+ // otherwise fall through to the empty-object check below and be reported as a
36
+ // change we didn't make — a spurious settings.json write on every session start.
37
+ if (!attribution || typeof attribution !== 'object' || Array.isArray(attribution))
38
+ return false;
39
+ const MOFLO_COMMIT = 'Co-Authored-By: moflo <noreply@cielolimitada.com>';
40
+ const MOFLO_PR = '\u{1F916} Generated with [moflo](https://github.com/eric-cielo/moflo)';
41
+ let changed = false;
42
+ if (attribution.commit === MOFLO_COMMIT) {
43
+ delete attribution.commit;
44
+ changed = true;
45
+ }
46
+ if (attribution.pr === MOFLO_PR) {
47
+ delete attribution.pr;
48
+ changed = true;
49
+ }
50
+ // Drop the now-empty container so the key doesn't linger as a puzzle for the
51
+ // next reader. A block still holding consumer-set values is left alone.
52
+ if (Object.keys(attribution).length === 0) {
53
+ delete settings.attribution;
54
+ changed = true;
55
+ }
56
+ return changed;
57
+ }
12
58
  /**
13
59
  * Required hook matchers that must exist in settings.json for gate enforcement.
14
60
  * This is the single source of truth — doctor-checks-deep re-exports it.
@@ -38,9 +84,30 @@ export const REQUIRED_HOOK_WIRING = [
38
84
  // start; without it, only consumers who re-run `flo init` would get the
39
85
  // escape, and the deadlock would persist everywhere else.
40
86
  { event: 'PostToolUse', pattern: 'record-bash-swarm-init' },
87
+ // #1393 (found by the generator-parity guard) — the MCP halves that #1338
88
+ // left behind. check-before-agent hard-blocks every Agent spawn under
89
+ // `/fl -s` until `swarmInitialized` (gate.cjs:1201) and under `/fl -h` until
90
+ // `hiveInitialized` (gate.cjs:1209); these are the hooks that set them.
91
+ // #1338 added only the CLI half above, so on an UPGRADED consumer the
92
+ // primary path — the skill calling mcp__moflo__swarm_init — credited
93
+ // nothing and swarm/hive runs deadlocked exactly like #1392's verify gate.
94
+ // Routed via gate.cjs (not gate-hook.mjs) to match getReferenceHookBlock()
95
+ // in hook-block-hash.ts:164-165 — if the two disagree the drift detector
96
+ // fights the repair path every session start.
97
+ { event: 'PostToolUse', pattern: 'record-swarm-init' },
98
+ { event: 'PostToolUse', pattern: 'record-hive-init' },
41
99
  { event: 'PostToolUse', pattern: 'record-skill-run' },
42
100
  // Story #1274 — record the native /verify skill run so check-before-done is satisfied.
43
101
  { event: 'PostToolUse', pattern: 'record-verify-run' },
102
+ // #1393 — the transcriber that turns /verify's stored verdict into
103
+ // `verifyOutcome` on workflow-state.json. #1332 tightened check-before-done to
104
+ // require verifyOutcome === 'PASS' but wired this hook in settings-generator
105
+ // ONLY, so every project that UPGRADED (rather than re-running `flo init`) got
106
+ // a verify-before-done gate that could not be satisfied — /verify stored a
107
+ // correct PASS, nothing transcribed it, and the sole escape was
108
+ // `gates: verify_before_done: false`. Listed here so repairHookWiring grafts
109
+ // it in on next session start.
110
+ { event: 'PostToolUse', pattern: 'record-verify-outcome' },
44
111
  { event: 'PostToolUse', pattern: 'reset-edit-gates' },
45
112
  // First UserPromptSubmit hook (prompt-hook.mjs internally calls
46
113
  // `gate.cjs prompt-reminder`). Substring check tolerates either the
@@ -95,8 +162,21 @@ export const HOOK_ENTRY_MAP = {
95
162
  // #1338 follow-up — same Bash/PowerShell PostToolUse block as record-test-run.
96
163
  'record-bash-swarm-init': { event: 'PostToolUse', matcher: '^(Bash|PowerShell)$', hook: { type: 'command', command: 'node "$CLAUDE_PROJECT_DIR/.claude/helpers/gate-hook.mjs" record-bash-swarm-init', timeout: 2000 } },
97
164
  'record-skill-run': { event: 'PostToolUse', matcher: '^Skill$', hook: { type: 'command', command: 'node "$CLAUDE_PROJECT_DIR/.claude/helpers/gate-hook.mjs" record-skill-run', timeout: 2000 } },
165
+ // #1393 — MCP halves of the swarm/hive init recorders. gate.cjs (not
166
+ // gate-hook.mjs) and these exact matchers mirror hook-block-hash.ts:164-165
167
+ // and settings-generator.ts; all four copies must agree or the drift
168
+ // detector and repairHookWiring undo each other on every session start.
169
+ 'record-swarm-init': { event: 'PostToolUse', matcher: '^mcp__moflo__swarm_init$', hook: { type: 'command', command: 'node "$CLAUDE_PROJECT_DIR/.claude/helpers/gate.cjs" record-swarm-init', timeout: 2000 } },
170
+ 'record-hive-init': { event: 'PostToolUse', matcher: '^mcp__moflo__hive-mind_init$', hook: { type: 'command', command: 'node "$CLAUDE_PROJECT_DIR/.claude/helpers/gate.cjs" record-hive-init', timeout: 2000 } },
98
171
  // Story #1274 — record the native /verify skill run (same ^Skill$ matcher as record-skill-run).
99
172
  'record-verify-run': { event: 'PostToolUse', matcher: '^Skill$', hook: { type: 'command', command: 'node "$CLAUDE_PROJECT_DIR/.claude/helpers/gate-hook.mjs" record-verify-run', timeout: 2000 } },
173
+ // #1393 — shares the ^mcp__moflo__memory_store$ block with record-learnings-stored;
174
+ // repairHookWiring appends it there rather than creating a second block.
175
+ // MUST route through gate-hook.mjs, NOT gate.cjs: the TOOL_INPUT_* env vars
176
+ // gate.cjs reads are built by gate-hook.mjs from the hook's stdin payload, so a
177
+ // direct gate.cjs invocation would see neither the key nor the `metadata` object
178
+ // carrying the verdict — the hook would fire and record nothing.
179
+ 'record-verify-outcome': { event: 'PostToolUse', matcher: '^mcp__moflo__memory_store$', hook: { type: 'command', command: 'node "$CLAUDE_PROJECT_DIR/.claude/helpers/gate-hook.mjs" record-verify-outcome', timeout: 2000 } },
100
180
  'reset-edit-gates': { event: 'PostToolUse', matcher: '^(Write|Edit|MultiEdit)$', hook: { type: 'command', command: 'node "$CLAUDE_PROJECT_DIR/.claude/helpers/gate-hook.mjs" reset-edit-gates', timeout: 2000 } },
101
181
  // #931 — Agent-time advisory; never blocks. Pulled the TaskCreate REMINDER
102
182
  // and namespace hint out of prompt-reminder so they fire only when Claude is
@@ -93,9 +93,13 @@ const DEFAULT_CONFIG = {
93
93
  maxSubjectLength: 72,
94
94
  maxBodyLength: 100,
95
95
  requireConventional: true,
96
- addCoAuthor: true,
96
+ // #1398 — both default OFF. This hook used to append a "Generated with …"
97
+ // line and a Co-Authored-By trailer to every commit it processed. Tool
98
+ // attribution in a consumer's git history is permanent and not moflo's to
99
+ // add; callers who genuinely want it must opt in explicitly.
100
+ addCoAuthor: false,
97
101
  coAuthor: DEFAULT_CO_AUTHOR,
98
- addClaudeReference: true,
102
+ addClaudeReference: false,
99
103
  };
100
104
  /**
101
105
  * Git Commit Hook Manager
@@ -2,5 +2,5 @@
2
2
  * Auto-generated by build. Do not edit manually.
3
3
  * Source of truth: root package.json → scripts/sync-version.mjs
4
4
  */
5
- export const VERSION = '4.12.4-rc.8';
5
+ export const VERSION = '4.12.4';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.12.4-rc.8",
3
+ "version": "4.12.4",
4
4
  "description": "MoFlo — AI agent orchestration for Claude Code. A standalone, opinionated toolkit with semantic memory, learned routing, gates, spells, and the /flo issue-execution skill.",
5
5
  "main": "dist/src/cli/index.js",
6
6
  "type": "module",
@@ -98,7 +98,7 @@
98
98
  "@typescript-eslint/parser": "^8.65.0",
99
99
  "eslint": "^10.8.0",
100
100
  "glob": "^11.1.0",
101
- "moflo": "^4.12.4-rc.7",
101
+ "moflo": "^4.12.4-rc.10",
102
102
  "tsx": "^4.21.0",
103
103
  "typescript": "^5.9.3",
104
104
  "vitest": "^4.0.0"