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.
- package/.claude/guidance/shipped/moflo-sdd.md +20 -5
- package/.claude/helpers/gate.cjs +55 -9
- package/.claude/helpers/prompt-hook.mjs +9 -0
- package/.claude/skills/fl/phases.md +5 -3
- package/bin/gate.cjs +55 -9
- package/bin/index-guidance.mjs +153 -72
- package/bin/lib/incremental-write.mjs +17 -3
- package/bin/migrations/purge-spec-chunks.mjs +79 -0
- package/bin/prompt-hook.mjs +9 -0
- package/bin/session-start-launcher.mjs +12 -0
- package/dist/src/cli/init/helpers-generator.js +28 -1
- package/dist/src/cli/init/moflo-init.js +16 -6
- package/dist/src/cli/init/settings-generator.js +5 -5
- package/dist/src/cli/memory/bridge-entries.js +148 -22
- package/dist/src/cli/services/hook-wiring.js +80 -0
- package/dist/src/cli/shared/hooks/safety/git-commit.js +6 -2
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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) |
|
|
37
|
-
| `docs/specs`, `.specs`, … (tracked) | Yes | You want
|
|
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
|
|
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
|
|
package/.claude/helpers/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
|
-
|
|
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
|
|
1732
|
-
//
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
//
|
|
1736
|
-
//
|
|
1737
|
-
//
|
|
1738
|
-
|
|
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
|
-
|
|
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
|
|
1732
|
-
//
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
//
|
|
1736
|
-
//
|
|
1737
|
-
//
|
|
1738
|
-
|
|
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');
|
package/bin/index-guidance.mjs
CHANGED
|
@@ -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
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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(
|
|
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(
|
|
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
|
-
*
|
|
699
|
-
*
|
|
700
|
-
*
|
|
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,
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
707
|
-
const
|
|
708
|
-
while (
|
|
709
|
-
|
|
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
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
+
}
|
package/bin/prompt-hook.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
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'
|
|
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
|
-
//
|
|
41
|
-
settings.
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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([
|
|
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,
|
|
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
|
-
|
|
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:
|
|
102
|
+
addClaudeReference: false,
|
|
99
103
|
};
|
|
100
104
|
/**
|
|
101
105
|
* Git Commit Hook Manager
|
package/dist/src/cli/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moflo",
|
|
3
|
-
"version": "4.12.4
|
|
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.
|
|
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"
|