moflo 4.12.6 → 4.12.7
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-agent-rules.md +15 -0
- package/.claude/guidance/shipped/moflo-claude-swarm-cohesion.md +10 -2
- package/.claude/helpers/gate-hook.mjs +29 -1
- package/.claude/helpers/gate.cjs +143 -18
- package/.claude/skills/fl/SKILL.md +6 -4
- package/.claude/skills/fl/phases.md +59 -7
- package/README.md +4 -1
- package/bin/gate-hook.mjs +29 -1
- package/bin/gate.cjs +143 -18
- package/dist/src/cli/init/helpers-generator.js +186 -5
- package/dist/src/cli/init/moflo-yaml-template.js +1 -0
- package/dist/src/cli/services/hook-wiring.js +6 -2
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
|
@@ -170,8 +170,23 @@ npx flo memory store --namespace patterns --key "brief-descriptive-key" --value
|
|
|
170
170
|
| `patterns` | Solutions to tricky bugs, patterns that worked, gotchas, workarounds |
|
|
171
171
|
| `learnings` | Architectural choices, user-stated decisions, post-mortem insights (`knowledge` is a deprecated alias — writes auto-redirect) |
|
|
172
172
|
|
|
173
|
+
**Every entry must clear one bar: would it help a future session working on a *different* task?**
|
|
174
|
+
If not, it does not go in memory. `memory_search` returns a **bounded** result set, so a non-durable
|
|
175
|
+
entry does not merely waste space — it permanently displaces a reusable lesson from every future
|
|
176
|
+
search.
|
|
177
|
+
|
|
178
|
+
**Never store a summary of the run that just happened.** What you changed, which files you touched,
|
|
179
|
+
which tests you ran, and what you decided for this one ticket are git history and belong in the
|
|
180
|
+
commit or PR body. That content is applicable exactly once, so it can only ever crowd out something
|
|
181
|
+
that is applicable repeatedly.
|
|
182
|
+
|
|
173
183
|
**Skip** generic summaries of retrieved guidance, restated rules, and trivial file-location notes — those waste retrieval bandwidth on every future search.
|
|
174
184
|
|
|
185
|
+
**A gate never justifies a write.** When a gate demands a memory write and the run produced nothing
|
|
186
|
+
durable, declare that instead of inventing something — a mandatory write with nothing to say
|
|
187
|
+
produces filler by construction. `/flo`'s learnings gate takes
|
|
188
|
+
`node .claude/helpers/gate.cjs record-no-durable-lesson` for exactly this case.
|
|
189
|
+
|
|
175
190
|
---
|
|
176
191
|
|
|
177
192
|
## See Also
|
|
@@ -119,7 +119,15 @@ TaskList() // Shows what's now unblocked
|
|
|
119
119
|
TaskUpdate({ taskId: "2", status: "in_progress" }) // Next agent starts
|
|
120
120
|
```
|
|
121
121
|
|
|
122
|
-
Close every task you open
|
|
122
|
+
**Close every task you open — `gh pr create` is blocked until you do.** moflo's PR gate reads the session transcript, counts the tasks this session opened against the ones it closed, and stops the PR while any remain open. A stale list reports work as not-started that is about to merge.
|
|
123
|
+
|
|
124
|
+
| Situation | Action |
|
|
125
|
+
|-----------|--------|
|
|
126
|
+
| The work is done | `TaskUpdate({ taskId: "1", status: "completed" })` |
|
|
127
|
+
| The task no longer applies | `TaskUpdate({ taskId: "1", status: "deleted" })` — closes the loop exactly like `completed` |
|
|
128
|
+
| The work is deliberately deferred past this PR | Run the acknowledgement command the block message prints — it credits the gate and leaves the tasks open |
|
|
129
|
+
|
|
130
|
+
Never mark a task `completed` to clear the gate. The acknowledgement path exists so the honest outcome costs one command; a false `completed` costs the user their only accurate view of what shipped. Set `gates: task_status_gate: warn` in `moflo.yaml` to report instead of block, or `off` to silence it.
|
|
123
131
|
|
|
124
132
|
---
|
|
125
133
|
|
|
@@ -132,7 +140,7 @@ Close every task you open. moflo's PR gate reads the session transcript on `gh p
|
|
|
132
140
|
| 3 | Mark `completed` when results return |
|
|
133
141
|
| 4 | Use `TaskList` to monitor what's unblocked |
|
|
134
142
|
| 5 | Synthesize all agent outputs before proceeding |
|
|
135
|
-
| 6 | Store
|
|
143
|
+
| 6 | Store a learning after completion **only if** it would help a different future task — a run summary belongs in the PR body, not memory |
|
|
136
144
|
|
|
137
145
|
---
|
|
138
146
|
|
|
@@ -114,6 +114,34 @@ if (hookContext.tool_response && typeof hookContext.tool_response === 'object')
|
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
// #1435 — deliver a PASSING gate's advisory to Claude, not only to the transcript.
|
|
118
|
+
//
|
|
119
|
+
// Claude Code shows a PreToolUse/PostToolUse hook's stdout to the user in
|
|
120
|
+
// transcript mode and stops there; the model never sees it. So every advisory
|
|
121
|
+
// the gates emit on the exit-0 path was invisible on exactly the runs it was
|
|
122
|
+
// written for: #1374's open-task count, the pre-Agent TaskCreate reminder, the
|
|
123
|
+
// namespace hint, the docs-only and simplify-auto-pass notes. They surfaced only
|
|
124
|
+
// when some OTHER gate blocked, because the catch arm below re-routes err.stdout
|
|
125
|
+
// to stderr — i.e. only once the PR had already been stopped for another reason.
|
|
126
|
+
// A consumer shipped a PR over four untouched tasks with that reminder "working".
|
|
127
|
+
//
|
|
128
|
+
// `hookSpecificOutput.additionalContext` is the documented channel from a passing
|
|
129
|
+
// tool hook into the model's context. Wrap there and nowhere else: SessionStart
|
|
130
|
+
// and UserPromptSubmit already inject their stdout as context, so wrapping those
|
|
131
|
+
// would rewrite a working path for nothing. An unknown or absent hook_event_name
|
|
132
|
+
// falls back to raw stdout — byte-identical to the previous behaviour.
|
|
133
|
+
var ADVISORY_EVENTS = { PreToolUse: true, PostToolUse: true };
|
|
134
|
+
function emitAdvisory(text) {
|
|
135
|
+
var event = hookContext.hook_event_name;
|
|
136
|
+
if (!ADVISORY_EVENTS[event]) {
|
|
137
|
+
process.stdout.write(text);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
process.stdout.write(JSON.stringify({
|
|
141
|
+
hookSpecificOutput: { hookEventName: event, additionalContext: text },
|
|
142
|
+
}) + '\n');
|
|
143
|
+
}
|
|
144
|
+
|
|
117
145
|
// Run gate.cjs with the enriched environment
|
|
118
146
|
var projectDir = (env.CLAUDE_PROJECT_DIR || process.cwd()).replace(/^\/([a-z])\//i, '$1:/');
|
|
119
147
|
var gateScript = resolve(projectDir, '.claude/helpers/gate.cjs');
|
|
@@ -121,7 +149,7 @@ try {
|
|
|
121
149
|
var output = execFileSync('node', [gateScript, command], {
|
|
122
150
|
env: env, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true
|
|
123
151
|
});
|
|
124
|
-
if (output.trim())
|
|
152
|
+
if (output.trim()) emitAdvisory(output);
|
|
125
153
|
process.exit(0);
|
|
126
154
|
} catch (err) {
|
|
127
155
|
// gate.cjs exit(2) = block, exit(1) = also block attempt — translate both to exit(2)
|
package/.claude/helpers/gate.cjs
CHANGED
|
@@ -13,7 +13,7 @@ var STATE_FILE = path.join(PROJECT_DIR, '.claude', 'workflow-state.json');
|
|
|
13
13
|
// the code it describes, so a change made outside Write/Edit/MultiEdit (a Bash
|
|
14
14
|
// write, a branch switch, the next issue in the same session) invalidates it.
|
|
15
15
|
// See creditFingerprint() for why the boolean flags alone cannot.
|
|
16
|
-
var STATE_DEFAULTS = { tasksCreated: false, taskCount: 0, memorySearched: false, memorySearchedBy: {}, memoryRequired: true, learningsStored: false, testsRun: false, testsFingerprint: null, simplifyRun: false, simplifySnapshotSha: null, simplifyFingerprint: null, verifyRun: false, verifyOutcome: null, verifyFingerprint: null, interactionCount: 0, sessionStart: null, lastBlockedAt: null, lastNamespaceHint: '', lastNamespaceHintEmittedBy: {}, flMode: null, swarmInitialized: false, hiveInitialized: false, sddMode: false, activeSddSlug: null };
|
|
16
|
+
var STATE_DEFAULTS = { tasksCreated: false, taskCount: 0, tasksAcknowledged: false, memorySearched: false, memorySearchedBy: {}, memoryRequired: true, learningsStored: false, testsRun: false, testsFingerprint: null, simplifyRun: false, simplifySnapshotSha: null, simplifyFingerprint: null, verifyRun: false, verifyOutcome: null, verifyFingerprint: null, interactionCount: 0, sessionStart: null, lastBlockedAt: null, lastNamespaceHint: '', lastNamespaceHintEmittedBy: {}, flMode: null, swarmInitialized: false, hiveInitialized: false, sddMode: false, activeSddSlug: null };
|
|
17
17
|
|
|
18
18
|
// Per-actor memory-search tracking (#838). The legacy `memorySearched` boolean
|
|
19
19
|
// is session-wide, so once the parent searches memory, every spawned subagent
|
|
@@ -70,9 +70,24 @@ function loadGateConfig() {
|
|
|
70
70
|
// ships a real /verify skill and has /flo delegate to it, so leaving it off by
|
|
71
71
|
// default would make the default /flo run silently skip the acceptance check.
|
|
72
72
|
// Disable per-project with `verify_before_done: false` or per-run `--no-verify`.
|
|
73
|
-
|
|
73
|
+
// task_status_gate is a MODE, not a boolean: 'block' | 'warn' | 'off' (#1435).
|
|
74
|
+
// #1374 shipped the open-task count as a warn-only stdout line, and a consumer
|
|
75
|
+
// still shipped a PR over four untouched tasks — a reminder that survived ten
|
|
76
|
+
// consecutive ignores in one session is not a control. Blocking is the default
|
|
77
|
+
// because the honest "these stay open on purpose" outcome is one command away
|
|
78
|
+
// (record-tasks-acknowledged), so nothing here can deadlock a run.
|
|
79
|
+
var defaults = { memory_first: true, task_create_first: true, context_tracking: true, testing_gate: true, simplify_gate: true, learnings_gate: true, swarm_invocation_gate: true, verify_before_done: true, sdd_gate: true, task_status_gate: 'block' };
|
|
74
80
|
var content = MOFLO_YAML;
|
|
75
81
|
if (content) {
|
|
82
|
+
// Boolean forms are accepted so this key reads like every other gate in the
|
|
83
|
+
// block: `false` is the same opt-out `testing_gate: false` is, `true` means
|
|
84
|
+
// enforce. Anything unrecognised falls through to the default rather than
|
|
85
|
+
// silently disabling the gate — a typo must not be a stealth opt-out.
|
|
86
|
+
var tsg = /task_status_gate:\s*['"]?(block|warn|off|false|true)['"]?/i.exec(content);
|
|
87
|
+
if (tsg) {
|
|
88
|
+
var mode = tsg[1].toLowerCase();
|
|
89
|
+
defaults.task_status_gate = mode === 'false' ? 'off' : mode === 'true' ? 'block' : mode;
|
|
90
|
+
}
|
|
76
91
|
if (/memory_first:\s*false/i.test(content)) defaults.memory_first = false;
|
|
77
92
|
if (/task_create_first:\s*false/i.test(content)) defaults.task_create_first = false;
|
|
78
93
|
if (/context_tracking:\s*false/i.test(content)) defaults.context_tracking = false;
|
|
@@ -172,6 +187,25 @@ var GATE_ORIGIN_NOTE = 'This is a moflo hook, not a Claude Code permission rule
|
|
|
172
187
|
// /verify's Step 5 memory_store carries the verdict AND stamps learnings, which
|
|
173
188
|
// is why learnings has no separate step here.
|
|
174
189
|
var ORDER_HINT = 'Order that satisfies all of them: tests green -> /flo-simplify (re-run tests if it edits) -> /verify -> its memory_store verdict -> gh pr create\n';
|
|
190
|
+
// #1434 — the old text ('learnings have not been stored (call memory_store)')
|
|
191
|
+
// named the mechanism but no quality bar, so the cheapest way past it was a
|
|
192
|
+
// summary of the run — audit exhaust that displaces reusable lessons from every
|
|
193
|
+
// future bounded search. Name the bar AND the no-write path here: an escape
|
|
194
|
+
// hatch nobody can find is not an escape hatch (see #1332's gate deadlock).
|
|
195
|
+
//
|
|
196
|
+
// The escape command is built from __filename, not written as a relative path.
|
|
197
|
+
// The caller is the model typing into a Bash tool, NOT a hook: $CLAUDE_PROJECT_DIR
|
|
198
|
+
// is unset there (so the settings.json form would expand to "/.claude/..."), and a
|
|
199
|
+
// bare `.claude/helpers/gate.cjs` breaks from any cwd but the project root. The
|
|
200
|
+
// running script's own absolute path is correct on every OS and from any cwd;
|
|
201
|
+
// double quotes carry Windows separators and spaces through the shell.
|
|
202
|
+
var LEARNINGS_MISSING =
|
|
203
|
+
'no durable lesson recorded. A lesson qualifies only if it would help a future session ' +
|
|
204
|
+
'working on a DIFFERENT task — a reusable pattern, a trap, a decision + rationale. ' +
|
|
205
|
+
'Store one with mcp__moflo__memory_store (namespace "learnings"; use "patterns" for a ' +
|
|
206
|
+
'reusable code shape). What THIS run changed is git history — it belongs in the PR body, ' +
|
|
207
|
+
'not in memory. If this run taught nothing new, say so instead of inventing one: ' +
|
|
208
|
+
'node "' + __filename + '" record-no-durable-lesson';
|
|
175
209
|
var GATE_DISABLE_NOTE = 'Disable per-gate via moflo.yaml: gates: memory_first: false';
|
|
176
210
|
// #1338 — Claude Code spawns stdio MCP servers once at session start and never
|
|
177
211
|
// respawns them, so a session can outlive its moflo MCP connection. Naming only
|
|
@@ -1355,6 +1389,41 @@ switch (command) {
|
|
|
1355
1389
|
writeState(s);
|
|
1356
1390
|
break;
|
|
1357
1391
|
}
|
|
1392
|
+
// #1435 — the escape from the task-status gate, for work deliberately left
|
|
1393
|
+
// open. Session-scoped like `learningsStored`: it lives in STATE_DEFAULTS, so
|
|
1394
|
+
// session-reset clears it, and neither applyPromptStateReset nor
|
|
1395
|
+
// reset-edit-gates touches it — a decision the user made about the task list
|
|
1396
|
+
// is not invalidated by the next prompt or the next source edit.
|
|
1397
|
+
//
|
|
1398
|
+
// A plain flag, not a count. The command is typed by the model into a Bash
|
|
1399
|
+
// tool, where HOOK_TRANSCRIPT_PATH is unset (it is forwarded by gate-hook.mjs
|
|
1400
|
+
// from the hook payload and exists only inside a hook), so this process cannot
|
|
1401
|
+
// read the ledger to record WHICH tasks were acknowledged even if it wanted to.
|
|
1402
|
+
case 'record-tasks-acknowledged': {
|
|
1403
|
+
var s = readState();
|
|
1404
|
+
if (!s.tasksAcknowledged) {
|
|
1405
|
+
s.tasksAcknowledged = true;
|
|
1406
|
+
writeState(s);
|
|
1407
|
+
}
|
|
1408
|
+
// writeState swallows its own errors by design — a gate must never crash the
|
|
1409
|
+
// hook it runs in. That was harmless while every recorder was advisory. This
|
|
1410
|
+
// one is the ONLY escape from a BLOCKING gate, so a lost write would report
|
|
1411
|
+
// "satisfied" and then block the very next `gh pr create` with nothing said
|
|
1412
|
+
// about why: #1332's deadlock shape exactly. Confirm it landed before
|
|
1413
|
+
// claiming it did, and name the file and the way out when it did not.
|
|
1414
|
+
if (!readState().tasksAcknowledged) {
|
|
1415
|
+
process.stderr.write(
|
|
1416
|
+
'Task-status gate NOT satisfied: the acknowledgement could not be persisted to\n' +
|
|
1417
|
+
STATE_FILE + '\n' +
|
|
1418
|
+
'Check that the file and its directory are writable, then run this again.\n' +
|
|
1419
|
+
'To proceed without it: set gates: task_status_gate: off in moflo.yaml.\n');
|
|
1420
|
+
process.exit(1);
|
|
1421
|
+
}
|
|
1422
|
+
process.stdout.write(
|
|
1423
|
+
'Task-status gate satisfied: open tasks acknowledged as deliberately deferred.\n' +
|
|
1424
|
+
'They stay visible in the task list — this records the decision, it does not close them.\n');
|
|
1425
|
+
break;
|
|
1426
|
+
}
|
|
1358
1427
|
case 'record-memory-searched': {
|
|
1359
1428
|
var s = readState();
|
|
1360
1429
|
if (markMemorySearched(s)) writeState(s);
|
|
@@ -1417,12 +1486,48 @@ switch (command) {
|
|
|
1417
1486
|
// Why it does nothing: see applyPromptStateReset().
|
|
1418
1487
|
break;
|
|
1419
1488
|
}
|
|
1420
|
-
|
|
1489
|
+
// #1434 — the gate demanded one memory_store per run whether or not the run
|
|
1490
|
+
// produced a reusable lesson, and a mandatory write with nothing to say
|
|
1491
|
+
// produces filler: a summary of this ticket, this commit, applicable never
|
|
1492
|
+
// again. memory_search returns a bounded set, so each of those displaces a
|
|
1493
|
+
// real lesson from every future search — the cost is retrieval quality, not
|
|
1494
|
+
// disk. Declaring "nothing durable here" is the honest outcome of a run that
|
|
1495
|
+
// learned nothing new, so it has to be reachable without a write; otherwise
|
|
1496
|
+
// the cheapest way past the gate stays the filler write.
|
|
1497
|
+
//
|
|
1498
|
+
// Both credits set the same flag; they differ only in whether the run has
|
|
1499
|
+
// something to say. Sharing the case body keeps that single write in one
|
|
1500
|
+
// place across all three copies of this file.
|
|
1501
|
+
case 'record-learnings-stored':
|
|
1502
|
+
case 'record-no-durable-lesson': {
|
|
1421
1503
|
var s = readState();
|
|
1422
1504
|
if (!s.learningsStored) {
|
|
1423
1505
|
s.learningsStored = true;
|
|
1424
1506
|
writeState(s);
|
|
1425
1507
|
}
|
|
1508
|
+
if (command === 'record-no-durable-lesson') {
|
|
1509
|
+
// Same reasoning as record-tasks-acknowledged above: writeState swallows
|
|
1510
|
+
// its own errors so a gate never crashes the hook it runs in, and this is
|
|
1511
|
+
// the ONLY escape from the BLOCKING learnings gate that does not require a
|
|
1512
|
+
// memory_store. A lost write here would print "satisfied" and then block
|
|
1513
|
+
// the next `gh pr create` with nothing said about why — #1332's deadlock.
|
|
1514
|
+
// Verified only on this arm: record-learnings-stored is fired
|
|
1515
|
+
// automatically by the PostToolUse hook on every memory_store, where a
|
|
1516
|
+
// failed write leaves the gate closed but the run still has the ordinary
|
|
1517
|
+
// way through, and a diagnostic on every store would be noise.
|
|
1518
|
+
if (!readState().learningsStored) {
|
|
1519
|
+
process.stderr.write(
|
|
1520
|
+
'Learnings gate NOT satisfied: the declaration could not be persisted to\n' +
|
|
1521
|
+
STATE_FILE + '\n' +
|
|
1522
|
+
'Check that the file and its directory are writable, then run this again.\n' +
|
|
1523
|
+
'To proceed without it: set gates: learnings_gate: false in moflo.yaml.\n');
|
|
1524
|
+
process.exit(1);
|
|
1525
|
+
}
|
|
1526
|
+
process.stdout.write(
|
|
1527
|
+
'Learnings gate satisfied: no durable lesson declared for this run.\n' +
|
|
1528
|
+
'What this run did belongs in the PR body, not in memory.\n',
|
|
1529
|
+
);
|
|
1530
|
+
}
|
|
1426
1531
|
break;
|
|
1427
1532
|
}
|
|
1428
1533
|
case 'record-test-run': {
|
|
@@ -1645,25 +1750,46 @@ switch (command) {
|
|
|
1645
1750
|
// chained, piped, parenthesised, and multi-line shapes (#1410).
|
|
1646
1751
|
var cmd = process.env.TOOL_INPUT_command || '';
|
|
1647
1752
|
if (!isPrCreateCommand(cmd)) break;
|
|
1648
|
-
// #1374
|
|
1649
|
-
//
|
|
1650
|
-
// block when it blocks (#1326). An open task list is a reporting failure,
|
|
1651
|
-
// not a quality failure — blocking the PR on it would be a new deadlock.
|
|
1753
|
+
// #1374 opened this loop; #1435 closes it. The count itself is unchanged —
|
|
1754
|
+
// what changed is that it now has teeth and, in warn mode, a delivery path.
|
|
1652
1755
|
//
|
|
1653
1756
|
// Deliberately ABOVE the no-source exemption below: a docs-only PR can
|
|
1654
1757
|
// abandon a list exactly like a source PR can, and the exemption is about
|
|
1655
1758
|
// testing/simplify/learnings, not about whether the run told the user what
|
|
1656
|
-
// it did.
|
|
1657
|
-
//
|
|
1658
|
-
//
|
|
1659
|
-
|
|
1759
|
+
// it did. It also exits on its own rather than joining `missing` below, for
|
|
1760
|
+
// the same reason — `missing` is unreachable on an exempt diff.
|
|
1761
|
+
//
|
|
1762
|
+
// Gated on the same `task_create_first` flag as the reminder itself so the
|
|
1763
|
+
// two halves are always consistent: a project that turned the nag off is
|
|
1764
|
+
// not then blocked about the other end of it.
|
|
1765
|
+
//
|
|
1766
|
+
// Fail-open is load-bearing. readTaskLedger() returns null on a missing,
|
|
1767
|
+
// oversized, or unreadable transcript and on a session with no TaskCreate at
|
|
1768
|
+
// all, and null must never block — a gate that stops PRs because it could
|
|
1769
|
+
// not read a file is worse than the reporting gap it is closing.
|
|
1770
|
+
//
|
|
1771
|
+
// State is read ONCE for the whole case, here — the pre-PR gate logic below
|
|
1772
|
+
// reuses it and nothing writes in between. Reading it before the ledger also
|
|
1773
|
+
// means an already-acknowledged run never pays for the transcript scan.
|
|
1774
|
+
var s = readState();
|
|
1775
|
+
if (config.task_create_first && config.task_status_gate !== 'off' && !s.tasksAcknowledged) {
|
|
1660
1776
|
var ledger = readTaskLedger();
|
|
1661
1777
|
if (ledger && ledger.open > 0) {
|
|
1662
|
-
|
|
1663
|
-
'
|
|
1664
|
-
|
|
1665
|
-
'
|
|
1666
|
-
|
|
1778
|
+
var tally = ledger.created + ' task' + (ledger.created === 1 ? '' : 's') +
|
|
1779
|
+
' created this session, ' + ledger.open + ' still open.';
|
|
1780
|
+
var closeIt = 'Close them with TaskUpdate (status: completed), or delete the ones ' +
|
|
1781
|
+
'that no longer apply, so the run does not report done over an unfinished list.\n';
|
|
1782
|
+
if (config.task_status_gate === 'warn') {
|
|
1783
|
+
process.stdout.write('REMINDER: ' + tally + ' ' + closeIt);
|
|
1784
|
+
} else {
|
|
1785
|
+
process.stderr.write(
|
|
1786
|
+
'BLOCKED: ' + tally + '\n' + closeIt +
|
|
1787
|
+
'Deferring them on purpose is a legitimate outcome — declare it instead of\n' +
|
|
1788
|
+
'closing tasks that are not done: node "' + __filename + '" record-tasks-acknowledged\n' +
|
|
1789
|
+
GATE_ORIGIN_NOTE + '\n' +
|
|
1790
|
+
'Report instead of blocking via moflo.yaml: gates: task_status_gate: warn (or: off)\n');
|
|
1791
|
+
process.exit(2);
|
|
1792
|
+
}
|
|
1667
1793
|
}
|
|
1668
1794
|
}
|
|
1669
1795
|
// No-source-files exemption (#1176, supersedes the original docs-only path).
|
|
@@ -1688,7 +1814,6 @@ switch (command) {
|
|
|
1688
1814
|
break;
|
|
1689
1815
|
}
|
|
1690
1816
|
}
|
|
1691
|
-
var s = readState();
|
|
1692
1817
|
// Expire any credit whose fingerprint no longer matches the code before
|
|
1693
1818
|
// reading the flags. This is what catches the mutations reset-edit-gates
|
|
1694
1819
|
// structurally cannot see — Bash writes, git checkout/pull/merge, and the
|
|
@@ -1721,7 +1846,7 @@ switch (command) {
|
|
|
1721
1846
|
var missing = [];
|
|
1722
1847
|
if (config.testing_gate && !s.testsRun) missing.push('tests have not run green since the last code edit (run npm test, vitest, jest, pytest, or similar — a run whose output reports failures does not count)');
|
|
1723
1848
|
if (config.simplify_gate && !s.simplifyRun) missing.push('/flo-simplify (or /distill) has not run since the last code edit');
|
|
1724
|
-
if (config.learnings_gate && !s.learningsStored) missing.push(
|
|
1849
|
+
if (config.learnings_gate && !s.learningsStored) missing.push(LEARNINGS_MISSING);
|
|
1725
1850
|
if (missing.length === 0) break;
|
|
1726
1851
|
process.stderr.write('BLOCKED: gh pr create requires the following before opening a PR:\n');
|
|
1727
1852
|
for (var i = 0; i < missing.length; i++) {
|
|
@@ -106,10 +106,12 @@ research → ticket → execute → tests → simplify → learnings → pr
|
|
|
106
106
|
| Execute | Assign issue, create branch, implement |
|
|
107
107
|
| Tests | Run unit + integration + E2E |
|
|
108
108
|
| Simplify | Run `/flo-simplify` on changed code |
|
|
109
|
-
| Learnings |
|
|
110
|
-
| PR |
|
|
109
|
+
| Learnings | Store a **durable** lesson — one that helps a *different* future task — or declare there is none. A run summary belongs in the PR body, never in memory |
|
|
110
|
+
| PR | Close every task this run opened, then open the PR and update issue status |
|
|
111
111
|
|
|
112
|
-
The tests, simplify, and
|
|
112
|
+
The tests, simplify, learnings, and task-closure steps are enforced by hooks. `gh pr create` is blocked by `check-before-pr` until each has run in the current session. Skill text describes the flow; the gates handle compliance.
|
|
113
|
+
|
|
114
|
+
**Close the task list before the PR, and close it honestly.** `TaskUpdate` each task `completed`, or `deleted` if it no longer applies. Work deliberately left open is a legitimate outcome — the block message prints a one-command acknowledgement for it. Never mark a task `completed` just to clear the gate: the list is the user's only view of what actually shipped.
|
|
113
115
|
|
|
114
116
|
## Companion files
|
|
115
117
|
|
|
@@ -270,6 +272,6 @@ Full mode runs end-to-end without further prompts.
|
|
|
270
272
|
6. Run `/flo-simplify` on changed code; rerun tests if it edits — `./phases.md` Phase 4.5
|
|
271
273
|
7. Commit — `./phases.md` Phase 5.1
|
|
272
274
|
8. **Verify — default, unless `--no-verify`** (`verifyMode`, always on under `sddMode`): delegate to the `/verify` skill — `Skill({ skill: "verify" })`. It checks the change against the acceptance criteria, reusing Phase 4's tests (no double verify) and recording its own outcome; invoking it satisfies the verify-before-done gate. Mechanics live in `.claude/skills/verify/SKILL.md`; trigger/flow in `./phases.md` Phase 5.1b.
|
|
273
|
-
9.
|
|
275
|
+
9. Record a durable lesson via `mcp__moflo__memory_store`, or declare there is none — `./phases.md` Phase 5.2
|
|
274
276
|
10. Open PR, update issue status — `./phases.md` Phases 5.3–5.4
|
|
275
277
|
11. **If `mergeMode`:** await the PR's merge preconditions and merge it (native `--auto` preferred, else poll-then-merge) — `./phases.md` Phase 5.3b
|
|
@@ -173,21 +173,73 @@ repository and their git history is permanent — moflo does not sign their comm
|
|
|
173
173
|
|
|
174
174
|
**When it runs:** by default (`verify_before_done` now defaults true) and always under `--sdd`; `--no-verify` skips it for one run. See `./sdd.md` for triggers and `.claude/skills/verify/SKILL.md` for how verification is performed.
|
|
175
175
|
|
|
176
|
-
### 5.2
|
|
177
|
-
|
|
176
|
+
### 5.2 Record a durable lesson — or declare there isn't one
|
|
177
|
+
|
|
178
|
+
**A run summary is not a learning.** What this run changed — files touched, the fix applied,
|
|
179
|
+
the decision taken for this ticket — is git history and belongs in the PR body. Writing it to
|
|
180
|
+
memory instead is audit exhaust: one ticket, one commit, applicable never again. `memory_search`
|
|
181
|
+
returns a **bounded** result set, so every such entry permanently displaces a reusable lesson
|
|
182
|
+
from every future search. That cost is retrieval quality, not disk.
|
|
183
|
+
|
|
184
|
+
Apply the **durability bar** — the same one `/meditate` uses:
|
|
185
|
+
|
|
186
|
+
> *Would this help a future session working on a **different** task?*
|
|
187
|
+
|
|
188
|
+
| Store it | Where | Skip it — it is not a lesson |
|
|
189
|
+
|----------|-------|------------------------------|
|
|
190
|
+
| A reusable pattern: "for X, do Y because Z" | `learnings` | "Fixed #<n> by editing `<file>`" → PR body |
|
|
191
|
+
| A trap: "W silently fails when V" | `learnings` | "Added tests for Z" → the test records itself |
|
|
192
|
+
| A decision + rationale future work must honor | `learnings` | "Ran tests, they passed" → the run records itself |
|
|
193
|
+
| A reusable code shape this repo should copy | `patterns` | Restating an existing CLAUDE.md / guidance rule |
|
|
194
|
+
|
|
195
|
+
Key the entry on the **symptom or rule**, not the ticket. A future session hits the symptom
|
|
196
|
+
without knowing the issue number, so a ticket-shaped key is unfindable exactly when it is needed.
|
|
197
|
+
(An incident narrative may carry the issue as provenance — `1145-daemon-port-collision-fix` — but
|
|
198
|
+
the searchable words must still be the symptom.) A real entry from this repo, abridged:
|
|
178
199
|
|
|
179
200
|
```
|
|
180
201
|
mcp__moflo__memory_store:
|
|
181
|
-
key: "
|
|
182
|
-
namespace: "
|
|
183
|
-
value: "
|
|
184
|
-
|
|
202
|
+
key: "wal-defeats-db-mtime-as-change-signal"
|
|
203
|
+
namespace: "learnings"
|
|
204
|
+
value: "In WAL mode SQLite writes land in the -wal sidecar, so the .db file's mtime
|
|
205
|
+
stops advancing on write. Any cache or staleness check keyed on db mtime
|
|
206
|
+
silently never invalidates. Hash the schema+row count, or stat the -wal too."
|
|
207
|
+
tags: ["sqlite", "caching", "gotcha"]
|
|
185
208
|
```
|
|
186
209
|
|
|
187
|
-
|
|
210
|
+
Note what makes it durable: it states a rule that holds beyond the ticket that discovered it,
|
|
211
|
+
and a future session hitting a stale-cache symptom finds it without knowing the issue existed.
|
|
212
|
+
|
|
213
|
+
**If this run taught nothing new, do not invent something.** Declare it and move on — the
|
|
214
|
+
`check-before-pr` gate accepts the declaration in place of a write:
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
node .claude/helpers/gate.cjs record-no-durable-lesson # from the project root
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
(When the gate has already blocked, it prints this command with an absolute path — use that
|
|
221
|
+
verbatim and the cwd stops mattering.)
|
|
222
|
+
|
|
223
|
+
Most runs land here, and that is the expected outcome: a routine fix in a well-understood area
|
|
224
|
+
produces no transferable lesson. One real lesson beats five manufactured ones.
|
|
225
|
+
|
|
226
|
+
Either path satisfies the gate, and either way it happens before `gh pr create`, not after.
|
|
227
|
+
Note that when `/verify` ran (5.1b, the default), its verdict write has **already** satisfied
|
|
228
|
+
this gate — so reach for `memory_store` here only when you genuinely have a lesson, never to
|
|
229
|
+
unblock the PR.
|
|
188
230
|
|
|
189
231
|
### 5.3 Create the PR
|
|
190
232
|
|
|
233
|
+
**Close the task list first.** `TaskUpdate` every task this run opened to `completed`, or to `deleted` where it no longer applies; `check-before-pr` blocks `gh pr create` while any stay open, because a list that still reads *pending* over merged work is the user's only view of what shipped.
|
|
234
|
+
|
|
235
|
+
| Task state at PR time | Action |
|
|
236
|
+
|-----------------------|--------|
|
|
237
|
+
| Work finished | `TaskUpdate({ taskId: "<id>", status: "completed" })` |
|
|
238
|
+
| No longer applies | `TaskUpdate({ taskId: "<id>", status: "deleted" })` |
|
|
239
|
+
| Deliberately deferred past this PR | Run the acknowledgement command printed in the block message — it credits the gate and leaves the tasks open |
|
|
240
|
+
|
|
241
|
+
Never mark a task `completed` to clear the gate. Declaring the deferral takes one command and keeps the list true.
|
|
242
|
+
|
|
191
243
|
In epic mode (`--epic-branch` set): skip the PR. The commit from 5.1 (with `Closes #<issue-number>`) is enough; the epic orchestrator handles the consolidated PR and final push. Skip pushing too.
|
|
192
244
|
|
|
193
245
|
Otherwise:
|
package/README.md
CHANGED
|
@@ -291,6 +291,7 @@ MoFlo installs Claude Code hooks that run on every tool call. Together, these ga
|
|
|
291
291
|
|------|-----------------|------------------|----------------|
|
|
292
292
|
| **Memory-first** | Claude must search the memory database before using Glob, Grep, or Read on guidance files | Before every Glob/Grep call, and before Read calls targeting `.claude/guidance/` | Prevents the AI from re-exploring files it (or a previous session) already indexed. Forces it to check what it knows first, saving tokens and context window. |
|
|
293
293
|
| **TaskCreate-first** | Claude must call TaskCreate before spawning sub-agents via the Task tool | Before every Task (agent spawn) call | Ensures every piece of delegated work is tracked. Prevents runaway agent proliferation where Claude spawns agents without a clear plan. |
|
|
294
|
+
| **Task-status** *(blocks by default)* | The task list Claude opened must reflect reality before the PR — every task closed, deleted, or explicitly acknowledged as deferred | Before `gh pr create`, whenever the session opened tasks and left some open | A stale task list is worse than none: it reports as not-started work that is about to merge. Every other step here is gated on an artifact; this is the one that gates what the *user is shown*. Deliberate deferrals stay legitimate — the block prints a one-command acknowledgement. Set `task_status_gate: warn` to report without blocking, or `off`; `task_create_first: false` disables both halves. |
|
|
294
295
|
| **Context tracking** | Tracks conversation length and warns about context depletion | On every user prompt (UserPromptSubmit hook) | As conversations grow, AI quality degrades. MoFlo tracks interaction count and assigns a bracket (FRESH → MODERATE → DEPLETED → CRITICAL), advising Claude to checkpoint progress or start a fresh session before quality drops. |
|
|
295
296
|
| **Routing** | Analyzes each prompt and recommends the optimal agent type and model tier | On every user prompt (UserPromptSubmit hook) | Saves cost by suggesting haiku for simple tasks, sonnet for moderate ones, opus for complex reasoning — without you having to think about model selection. |
|
|
296
297
|
| **Verify-before-done** *(on by default)* | Claude must verify the change end-to-end (the `/verify` skill) before `gh pr create` | Before `gh pr create` (docs-only diffs exempt) | Enforces "prove it works before done." On by default since #1294 (`/flo` delegates to `/verify` and reuses its test run — no double verify). Opt out with `verify_before_done: false` or `--no-verify`. Pairs with the [Spec-Driven Development](#spec-driven-development-sdd) cycle, which gives verification its acceptance criteria. |
|
|
@@ -321,7 +322,8 @@ All gates are configurable in `moflo.yaml`:
|
|
|
321
322
|
```yaml
|
|
322
323
|
gates:
|
|
323
324
|
memory_first: true # Set to false to disable memory-first enforcement
|
|
324
|
-
task_create_first: true # Set to false to disable TaskCreate enforcement
|
|
325
|
+
task_create_first: true # Set to false to disable TaskCreate enforcement (disables task_status_gate too)
|
|
326
|
+
task_status_gate: block # Open tasks block `gh pr create`; warn → report only; off → silent
|
|
325
327
|
context_tracking: true # Set to false to disable context bracket warnings
|
|
326
328
|
verify_before_done: true # On by default (#1294); set false to skip /verify before `gh pr create`
|
|
327
329
|
```
|
|
@@ -916,6 +918,7 @@ tests:
|
|
|
916
918
|
gates:
|
|
917
919
|
memory_first: true # Must search memory before file exploration
|
|
918
920
|
task_create_first: true # Must TaskCreate before Agent tool
|
|
921
|
+
task_status_gate: block # Open tasks block `gh pr create`; warn → report only; off → silent
|
|
919
922
|
context_tracking: true # Track context window depletion
|
|
920
923
|
verify_before_done: true # On by default (#1294); /verify before `gh pr create` (unless --no-verify). false to disable
|
|
921
924
|
|
package/bin/gate-hook.mjs
CHANGED
|
@@ -114,6 +114,34 @@ if (hookContext.tool_response && typeof hookContext.tool_response === 'object')
|
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
// #1435 — deliver a PASSING gate's advisory to Claude, not only to the transcript.
|
|
118
|
+
//
|
|
119
|
+
// Claude Code shows a PreToolUse/PostToolUse hook's stdout to the user in
|
|
120
|
+
// transcript mode and stops there; the model never sees it. So every advisory
|
|
121
|
+
// the gates emit on the exit-0 path was invisible on exactly the runs it was
|
|
122
|
+
// written for: #1374's open-task count, the pre-Agent TaskCreate reminder, the
|
|
123
|
+
// namespace hint, the docs-only and simplify-auto-pass notes. They surfaced only
|
|
124
|
+
// when some OTHER gate blocked, because the catch arm below re-routes err.stdout
|
|
125
|
+
// to stderr — i.e. only once the PR had already been stopped for another reason.
|
|
126
|
+
// A consumer shipped a PR over four untouched tasks with that reminder "working".
|
|
127
|
+
//
|
|
128
|
+
// `hookSpecificOutput.additionalContext` is the documented channel from a passing
|
|
129
|
+
// tool hook into the model's context. Wrap there and nowhere else: SessionStart
|
|
130
|
+
// and UserPromptSubmit already inject their stdout as context, so wrapping those
|
|
131
|
+
// would rewrite a working path for nothing. An unknown or absent hook_event_name
|
|
132
|
+
// falls back to raw stdout — byte-identical to the previous behaviour.
|
|
133
|
+
var ADVISORY_EVENTS = { PreToolUse: true, PostToolUse: true };
|
|
134
|
+
function emitAdvisory(text) {
|
|
135
|
+
var event = hookContext.hook_event_name;
|
|
136
|
+
if (!ADVISORY_EVENTS[event]) {
|
|
137
|
+
process.stdout.write(text);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
process.stdout.write(JSON.stringify({
|
|
141
|
+
hookSpecificOutput: { hookEventName: event, additionalContext: text },
|
|
142
|
+
}) + '\n');
|
|
143
|
+
}
|
|
144
|
+
|
|
117
145
|
// Run gate.cjs with the enriched environment
|
|
118
146
|
var projectDir = (env.CLAUDE_PROJECT_DIR || process.cwd()).replace(/^\/([a-z])\//i, '$1:/');
|
|
119
147
|
var gateScript = resolve(projectDir, '.claude/helpers/gate.cjs');
|
|
@@ -121,7 +149,7 @@ try {
|
|
|
121
149
|
var output = execFileSync('node', [gateScript, command], {
|
|
122
150
|
env: env, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true
|
|
123
151
|
});
|
|
124
|
-
if (output.trim())
|
|
152
|
+
if (output.trim()) emitAdvisory(output);
|
|
125
153
|
process.exit(0);
|
|
126
154
|
} catch (err) {
|
|
127
155
|
// gate.cjs exit(2) = block, exit(1) = also block attempt — translate both to exit(2)
|
package/bin/gate.cjs
CHANGED
|
@@ -13,7 +13,7 @@ var STATE_FILE = path.join(PROJECT_DIR, '.claude', 'workflow-state.json');
|
|
|
13
13
|
// the code it describes, so a change made outside Write/Edit/MultiEdit (a Bash
|
|
14
14
|
// write, a branch switch, the next issue in the same session) invalidates it.
|
|
15
15
|
// See creditFingerprint() for why the boolean flags alone cannot.
|
|
16
|
-
var STATE_DEFAULTS = { tasksCreated: false, taskCount: 0, memorySearched: false, memorySearchedBy: {}, memoryRequired: true, learningsStored: false, testsRun: false, testsFingerprint: null, simplifyRun: false, simplifySnapshotSha: null, simplifyFingerprint: null, verifyRun: false, verifyOutcome: null, verifyFingerprint: null, interactionCount: 0, sessionStart: null, lastBlockedAt: null, lastNamespaceHint: '', lastNamespaceHintEmittedBy: {}, flMode: null, swarmInitialized: false, hiveInitialized: false, sddMode: false, activeSddSlug: null };
|
|
16
|
+
var STATE_DEFAULTS = { tasksCreated: false, taskCount: 0, tasksAcknowledged: false, memorySearched: false, memorySearchedBy: {}, memoryRequired: true, learningsStored: false, testsRun: false, testsFingerprint: null, simplifyRun: false, simplifySnapshotSha: null, simplifyFingerprint: null, verifyRun: false, verifyOutcome: null, verifyFingerprint: null, interactionCount: 0, sessionStart: null, lastBlockedAt: null, lastNamespaceHint: '', lastNamespaceHintEmittedBy: {}, flMode: null, swarmInitialized: false, hiveInitialized: false, sddMode: false, activeSddSlug: null };
|
|
17
17
|
|
|
18
18
|
// Per-actor memory-search tracking (#838). The legacy `memorySearched` boolean
|
|
19
19
|
// is session-wide, so once the parent searches memory, every spawned subagent
|
|
@@ -70,9 +70,24 @@ function loadGateConfig() {
|
|
|
70
70
|
// ships a real /verify skill and has /flo delegate to it, so leaving it off by
|
|
71
71
|
// default would make the default /flo run silently skip the acceptance check.
|
|
72
72
|
// Disable per-project with `verify_before_done: false` or per-run `--no-verify`.
|
|
73
|
-
|
|
73
|
+
// task_status_gate is a MODE, not a boolean: 'block' | 'warn' | 'off' (#1435).
|
|
74
|
+
// #1374 shipped the open-task count as a warn-only stdout line, and a consumer
|
|
75
|
+
// still shipped a PR over four untouched tasks — a reminder that survived ten
|
|
76
|
+
// consecutive ignores in one session is not a control. Blocking is the default
|
|
77
|
+
// because the honest "these stay open on purpose" outcome is one command away
|
|
78
|
+
// (record-tasks-acknowledged), so nothing here can deadlock a run.
|
|
79
|
+
var defaults = { memory_first: true, task_create_first: true, context_tracking: true, testing_gate: true, simplify_gate: true, learnings_gate: true, swarm_invocation_gate: true, verify_before_done: true, sdd_gate: true, task_status_gate: 'block' };
|
|
74
80
|
var content = MOFLO_YAML;
|
|
75
81
|
if (content) {
|
|
82
|
+
// Boolean forms are accepted so this key reads like every other gate in the
|
|
83
|
+
// block: `false` is the same opt-out `testing_gate: false` is, `true` means
|
|
84
|
+
// enforce. Anything unrecognised falls through to the default rather than
|
|
85
|
+
// silently disabling the gate — a typo must not be a stealth opt-out.
|
|
86
|
+
var tsg = /task_status_gate:\s*['"]?(block|warn|off|false|true)['"]?/i.exec(content);
|
|
87
|
+
if (tsg) {
|
|
88
|
+
var mode = tsg[1].toLowerCase();
|
|
89
|
+
defaults.task_status_gate = mode === 'false' ? 'off' : mode === 'true' ? 'block' : mode;
|
|
90
|
+
}
|
|
76
91
|
if (/memory_first:\s*false/i.test(content)) defaults.memory_first = false;
|
|
77
92
|
if (/task_create_first:\s*false/i.test(content)) defaults.task_create_first = false;
|
|
78
93
|
if (/context_tracking:\s*false/i.test(content)) defaults.context_tracking = false;
|
|
@@ -172,6 +187,25 @@ var GATE_ORIGIN_NOTE = 'This is a moflo hook, not a Claude Code permission rule
|
|
|
172
187
|
// /verify's Step 5 memory_store carries the verdict AND stamps learnings, which
|
|
173
188
|
// is why learnings has no separate step here.
|
|
174
189
|
var ORDER_HINT = 'Order that satisfies all of them: tests green -> /flo-simplify (re-run tests if it edits) -> /verify -> its memory_store verdict -> gh pr create\n';
|
|
190
|
+
// #1434 — the old text ('learnings have not been stored (call memory_store)')
|
|
191
|
+
// named the mechanism but no quality bar, so the cheapest way past it was a
|
|
192
|
+
// summary of the run — audit exhaust that displaces reusable lessons from every
|
|
193
|
+
// future bounded search. Name the bar AND the no-write path here: an escape
|
|
194
|
+
// hatch nobody can find is not an escape hatch (see #1332's gate deadlock).
|
|
195
|
+
//
|
|
196
|
+
// The escape command is built from __filename, not written as a relative path.
|
|
197
|
+
// The caller is the model typing into a Bash tool, NOT a hook: $CLAUDE_PROJECT_DIR
|
|
198
|
+
// is unset there (so the settings.json form would expand to "/.claude/..."), and a
|
|
199
|
+
// bare `.claude/helpers/gate.cjs` breaks from any cwd but the project root. The
|
|
200
|
+
// running script's own absolute path is correct on every OS and from any cwd;
|
|
201
|
+
// double quotes carry Windows separators and spaces through the shell.
|
|
202
|
+
var LEARNINGS_MISSING =
|
|
203
|
+
'no durable lesson recorded. A lesson qualifies only if it would help a future session ' +
|
|
204
|
+
'working on a DIFFERENT task — a reusable pattern, a trap, a decision + rationale. ' +
|
|
205
|
+
'Store one with mcp__moflo__memory_store (namespace "learnings"; use "patterns" for a ' +
|
|
206
|
+
'reusable code shape). What THIS run changed is git history — it belongs in the PR body, ' +
|
|
207
|
+
'not in memory. If this run taught nothing new, say so instead of inventing one: ' +
|
|
208
|
+
'node "' + __filename + '" record-no-durable-lesson';
|
|
175
209
|
var GATE_DISABLE_NOTE = 'Disable per-gate via moflo.yaml: gates: memory_first: false';
|
|
176
210
|
// #1338 — Claude Code spawns stdio MCP servers once at session start and never
|
|
177
211
|
// respawns them, so a session can outlive its moflo MCP connection. Naming only
|
|
@@ -1355,6 +1389,41 @@ switch (command) {
|
|
|
1355
1389
|
writeState(s);
|
|
1356
1390
|
break;
|
|
1357
1391
|
}
|
|
1392
|
+
// #1435 — the escape from the task-status gate, for work deliberately left
|
|
1393
|
+
// open. Session-scoped like `learningsStored`: it lives in STATE_DEFAULTS, so
|
|
1394
|
+
// session-reset clears it, and neither applyPromptStateReset nor
|
|
1395
|
+
// reset-edit-gates touches it — a decision the user made about the task list
|
|
1396
|
+
// is not invalidated by the next prompt or the next source edit.
|
|
1397
|
+
//
|
|
1398
|
+
// A plain flag, not a count. The command is typed by the model into a Bash
|
|
1399
|
+
// tool, where HOOK_TRANSCRIPT_PATH is unset (it is forwarded by gate-hook.mjs
|
|
1400
|
+
// from the hook payload and exists only inside a hook), so this process cannot
|
|
1401
|
+
// read the ledger to record WHICH tasks were acknowledged even if it wanted to.
|
|
1402
|
+
case 'record-tasks-acknowledged': {
|
|
1403
|
+
var s = readState();
|
|
1404
|
+
if (!s.tasksAcknowledged) {
|
|
1405
|
+
s.tasksAcknowledged = true;
|
|
1406
|
+
writeState(s);
|
|
1407
|
+
}
|
|
1408
|
+
// writeState swallows its own errors by design — a gate must never crash the
|
|
1409
|
+
// hook it runs in. That was harmless while every recorder was advisory. This
|
|
1410
|
+
// one is the ONLY escape from a BLOCKING gate, so a lost write would report
|
|
1411
|
+
// "satisfied" and then block the very next `gh pr create` with nothing said
|
|
1412
|
+
// about why: #1332's deadlock shape exactly. Confirm it landed before
|
|
1413
|
+
// claiming it did, and name the file and the way out when it did not.
|
|
1414
|
+
if (!readState().tasksAcknowledged) {
|
|
1415
|
+
process.stderr.write(
|
|
1416
|
+
'Task-status gate NOT satisfied: the acknowledgement could not be persisted to\n' +
|
|
1417
|
+
STATE_FILE + '\n' +
|
|
1418
|
+
'Check that the file and its directory are writable, then run this again.\n' +
|
|
1419
|
+
'To proceed without it: set gates: task_status_gate: off in moflo.yaml.\n');
|
|
1420
|
+
process.exit(1);
|
|
1421
|
+
}
|
|
1422
|
+
process.stdout.write(
|
|
1423
|
+
'Task-status gate satisfied: open tasks acknowledged as deliberately deferred.\n' +
|
|
1424
|
+
'They stay visible in the task list — this records the decision, it does not close them.\n');
|
|
1425
|
+
break;
|
|
1426
|
+
}
|
|
1358
1427
|
case 'record-memory-searched': {
|
|
1359
1428
|
var s = readState();
|
|
1360
1429
|
if (markMemorySearched(s)) writeState(s);
|
|
@@ -1417,12 +1486,48 @@ switch (command) {
|
|
|
1417
1486
|
// Why it does nothing: see applyPromptStateReset().
|
|
1418
1487
|
break;
|
|
1419
1488
|
}
|
|
1420
|
-
|
|
1489
|
+
// #1434 — the gate demanded one memory_store per run whether or not the run
|
|
1490
|
+
// produced a reusable lesson, and a mandatory write with nothing to say
|
|
1491
|
+
// produces filler: a summary of this ticket, this commit, applicable never
|
|
1492
|
+
// again. memory_search returns a bounded set, so each of those displaces a
|
|
1493
|
+
// real lesson from every future search — the cost is retrieval quality, not
|
|
1494
|
+
// disk. Declaring "nothing durable here" is the honest outcome of a run that
|
|
1495
|
+
// learned nothing new, so it has to be reachable without a write; otherwise
|
|
1496
|
+
// the cheapest way past the gate stays the filler write.
|
|
1497
|
+
//
|
|
1498
|
+
// Both credits set the same flag; they differ only in whether the run has
|
|
1499
|
+
// something to say. Sharing the case body keeps that single write in one
|
|
1500
|
+
// place across all three copies of this file.
|
|
1501
|
+
case 'record-learnings-stored':
|
|
1502
|
+
case 'record-no-durable-lesson': {
|
|
1421
1503
|
var s = readState();
|
|
1422
1504
|
if (!s.learningsStored) {
|
|
1423
1505
|
s.learningsStored = true;
|
|
1424
1506
|
writeState(s);
|
|
1425
1507
|
}
|
|
1508
|
+
if (command === 'record-no-durable-lesson') {
|
|
1509
|
+
// Same reasoning as record-tasks-acknowledged above: writeState swallows
|
|
1510
|
+
// its own errors so a gate never crashes the hook it runs in, and this is
|
|
1511
|
+
// the ONLY escape from the BLOCKING learnings gate that does not require a
|
|
1512
|
+
// memory_store. A lost write here would print "satisfied" and then block
|
|
1513
|
+
// the next `gh pr create` with nothing said about why — #1332's deadlock.
|
|
1514
|
+
// Verified only on this arm: record-learnings-stored is fired
|
|
1515
|
+
// automatically by the PostToolUse hook on every memory_store, where a
|
|
1516
|
+
// failed write leaves the gate closed but the run still has the ordinary
|
|
1517
|
+
// way through, and a diagnostic on every store would be noise.
|
|
1518
|
+
if (!readState().learningsStored) {
|
|
1519
|
+
process.stderr.write(
|
|
1520
|
+
'Learnings gate NOT satisfied: the declaration could not be persisted to\n' +
|
|
1521
|
+
STATE_FILE + '\n' +
|
|
1522
|
+
'Check that the file and its directory are writable, then run this again.\n' +
|
|
1523
|
+
'To proceed without it: set gates: learnings_gate: false in moflo.yaml.\n');
|
|
1524
|
+
process.exit(1);
|
|
1525
|
+
}
|
|
1526
|
+
process.stdout.write(
|
|
1527
|
+
'Learnings gate satisfied: no durable lesson declared for this run.\n' +
|
|
1528
|
+
'What this run did belongs in the PR body, not in memory.\n',
|
|
1529
|
+
);
|
|
1530
|
+
}
|
|
1426
1531
|
break;
|
|
1427
1532
|
}
|
|
1428
1533
|
case 'record-test-run': {
|
|
@@ -1645,25 +1750,46 @@ switch (command) {
|
|
|
1645
1750
|
// chained, piped, parenthesised, and multi-line shapes (#1410).
|
|
1646
1751
|
var cmd = process.env.TOOL_INPUT_command || '';
|
|
1647
1752
|
if (!isPrCreateCommand(cmd)) break;
|
|
1648
|
-
// #1374
|
|
1649
|
-
//
|
|
1650
|
-
// block when it blocks (#1326). An open task list is a reporting failure,
|
|
1651
|
-
// not a quality failure — blocking the PR on it would be a new deadlock.
|
|
1753
|
+
// #1374 opened this loop; #1435 closes it. The count itself is unchanged —
|
|
1754
|
+
// what changed is that it now has teeth and, in warn mode, a delivery path.
|
|
1652
1755
|
//
|
|
1653
1756
|
// Deliberately ABOVE the no-source exemption below: a docs-only PR can
|
|
1654
1757
|
// abandon a list exactly like a source PR can, and the exemption is about
|
|
1655
1758
|
// testing/simplify/learnings, not about whether the run told the user what
|
|
1656
|
-
// it did.
|
|
1657
|
-
//
|
|
1658
|
-
//
|
|
1659
|
-
|
|
1759
|
+
// it did. It also exits on its own rather than joining `missing` below, for
|
|
1760
|
+
// the same reason — `missing` is unreachable on an exempt diff.
|
|
1761
|
+
//
|
|
1762
|
+
// Gated on the same `task_create_first` flag as the reminder itself so the
|
|
1763
|
+
// two halves are always consistent: a project that turned the nag off is
|
|
1764
|
+
// not then blocked about the other end of it.
|
|
1765
|
+
//
|
|
1766
|
+
// Fail-open is load-bearing. readTaskLedger() returns null on a missing,
|
|
1767
|
+
// oversized, or unreadable transcript and on a session with no TaskCreate at
|
|
1768
|
+
// all, and null must never block — a gate that stops PRs because it could
|
|
1769
|
+
// not read a file is worse than the reporting gap it is closing.
|
|
1770
|
+
//
|
|
1771
|
+
// State is read ONCE for the whole case, here — the pre-PR gate logic below
|
|
1772
|
+
// reuses it and nothing writes in between. Reading it before the ledger also
|
|
1773
|
+
// means an already-acknowledged run never pays for the transcript scan.
|
|
1774
|
+
var s = readState();
|
|
1775
|
+
if (config.task_create_first && config.task_status_gate !== 'off' && !s.tasksAcknowledged) {
|
|
1660
1776
|
var ledger = readTaskLedger();
|
|
1661
1777
|
if (ledger && ledger.open > 0) {
|
|
1662
|
-
|
|
1663
|
-
'
|
|
1664
|
-
|
|
1665
|
-
'
|
|
1666
|
-
|
|
1778
|
+
var tally = ledger.created + ' task' + (ledger.created === 1 ? '' : 's') +
|
|
1779
|
+
' created this session, ' + ledger.open + ' still open.';
|
|
1780
|
+
var closeIt = 'Close them with TaskUpdate (status: completed), or delete the ones ' +
|
|
1781
|
+
'that no longer apply, so the run does not report done over an unfinished list.\n';
|
|
1782
|
+
if (config.task_status_gate === 'warn') {
|
|
1783
|
+
process.stdout.write('REMINDER: ' + tally + ' ' + closeIt);
|
|
1784
|
+
} else {
|
|
1785
|
+
process.stderr.write(
|
|
1786
|
+
'BLOCKED: ' + tally + '\n' + closeIt +
|
|
1787
|
+
'Deferring them on purpose is a legitimate outcome — declare it instead of\n' +
|
|
1788
|
+
'closing tasks that are not done: node "' + __filename + '" record-tasks-acknowledged\n' +
|
|
1789
|
+
GATE_ORIGIN_NOTE + '\n' +
|
|
1790
|
+
'Report instead of blocking via moflo.yaml: gates: task_status_gate: warn (or: off)\n');
|
|
1791
|
+
process.exit(2);
|
|
1792
|
+
}
|
|
1667
1793
|
}
|
|
1668
1794
|
}
|
|
1669
1795
|
// No-source-files exemption (#1176, supersedes the original docs-only path).
|
|
@@ -1688,7 +1814,6 @@ switch (command) {
|
|
|
1688
1814
|
break;
|
|
1689
1815
|
}
|
|
1690
1816
|
}
|
|
1691
|
-
var s = readState();
|
|
1692
1817
|
// Expire any credit whose fingerprint no longer matches the code before
|
|
1693
1818
|
// reading the flags. This is what catches the mutations reset-edit-gates
|
|
1694
1819
|
// structurally cannot see — Bash writes, git checkout/pull/merge, and the
|
|
@@ -1721,7 +1846,7 @@ switch (command) {
|
|
|
1721
1846
|
var missing = [];
|
|
1722
1847
|
if (config.testing_gate && !s.testsRun) missing.push('tests have not run green since the last code edit (run npm test, vitest, jest, pytest, or similar — a run whose output reports failures does not count)');
|
|
1723
1848
|
if (config.simplify_gate && !s.simplifyRun) missing.push('/flo-simplify (or /distill) has not run since the last code edit');
|
|
1724
|
-
if (config.learnings_gate && !s.learningsStored) missing.push(
|
|
1849
|
+
if (config.learnings_gate && !s.learningsStored) missing.push(LEARNINGS_MISSING);
|
|
1725
1850
|
if (missing.length === 0) break;
|
|
1726
1851
|
process.stderr.write('BLOCKED: gh pr create requires the following before opening a PR:\n');
|
|
1727
1852
|
for (var i = 0; i < missing.length; i++) {
|
|
@@ -208,7 +208,7 @@ var os = require('os');
|
|
|
208
208
|
var PROJECT_DIR = (process.env.CLAUDE_PROJECT_DIR || process.cwd()).replace(/^\\/([a-z])\\//i, '$1:/');
|
|
209
209
|
var STATE_FILE = path.join(PROJECT_DIR, '.claude', 'workflow-state.json');
|
|
210
210
|
|
|
211
|
-
var STATE_DEFAULTS = { tasksCreated: false, taskCount: 0, memorySearched: false, memorySearchedBy: {}, memoryRequired: true, learningsStored: false, testsRun: false, simplifyRun: false, verifyRun: false, verifyOutcome: null, interactionCount: 0, sessionStart: null, lastBlockedAt: null, lastNamespaceHint: '', lastNamespaceHintEmittedBy: {}, flMode: null, swarmInitialized: false, hiveInitialized: false, sddMode: false, activeSddSlug: null };
|
|
211
|
+
var STATE_DEFAULTS = { tasksCreated: false, taskCount: 0, tasksAcknowledged: false, memorySearched: false, memorySearchedBy: {}, memoryRequired: true, learningsStored: false, testsRun: false, simplifyRun: false, verifyRun: false, verifyOutcome: null, interactionCount: 0, sessionStart: null, lastBlockedAt: null, lastNamespaceHint: '', lastNamespaceHintEmittedBy: {}, flMode: null, swarmInitialized: false, hiveInitialized: false, sddMode: false, activeSddSlug: null };
|
|
212
212
|
|
|
213
213
|
function readState() {
|
|
214
214
|
try {
|
|
@@ -256,9 +256,17 @@ function writeState(s) {
|
|
|
256
256
|
|
|
257
257
|
// Load moflo.yaml gate config (defaults: all enabled)
|
|
258
258
|
function loadGateConfig() {
|
|
259
|
-
|
|
259
|
+
// #1435 — task_status_gate is a MODE ('block' | 'warn' | 'off'), not a boolean;
|
|
260
|
+
// boolean forms are accepted so it reads like its neighbours. Unrecognised
|
|
261
|
+
// values keep the default: a typo must not become a stealth opt-out.
|
|
262
|
+
var defaults = { memory_first: true, task_create_first: true, context_tracking: true, testing_gate: true, simplify_gate: true, learnings_gate: true, swarm_invocation_gate: true, verify_before_done: true, sdd_gate: true, task_status_gate: 'block' };
|
|
260
263
|
var content = MOFLO_YAML;
|
|
261
264
|
if (content) {
|
|
265
|
+
var tsg = /task_status_gate:\\s*['"]?(block|warn|off|false|true)['"]?/i.exec(content);
|
|
266
|
+
if (tsg) {
|
|
267
|
+
var tsgMode = tsg[1].toLowerCase();
|
|
268
|
+
defaults.task_status_gate = tsgMode === 'false' ? 'off' : tsgMode === 'true' ? 'block' : tsgMode;
|
|
269
|
+
}
|
|
262
270
|
if (/memory_first:\\s*false/i.test(content)) defaults.memory_first = false;
|
|
263
271
|
if (/task_create_first:\\s*false/i.test(content)) defaults.task_create_first = false;
|
|
264
272
|
if (/context_tracking:\\s*false/i.test(content)) defaults.context_tracking = false;
|
|
@@ -345,6 +353,18 @@ var COORD_FALLBACK_NOTE = 'If mcp__moflo__* tools are unavailable this session (
|
|
|
345
353
|
// #1348 — the pre-PR gates are order-dependent; naming only the missing one left
|
|
346
354
|
// callers to rediscover the sequence by trial. SYNC: mirrors bin/gate.cjs.
|
|
347
355
|
var ORDER_HINT = 'Order that satisfies all of them: tests green -> /flo-simplify (re-run tests if it edits) -> /verify -> its memory_store verdict -> gh pr create\\n';
|
|
356
|
+
// #1434 — the old text named the mechanism but no quality bar, so the cheapest
|
|
357
|
+
// way past the gate was a summary of the run. The escape command is built from
|
|
358
|
+
// __filename: the caller is the model typing into Bash, where $CLAUDE_PROJECT_DIR
|
|
359
|
+
// is unset and a relative path breaks from any cwd but the project root.
|
|
360
|
+
// SYNC: mirrors bin/gate.cjs.
|
|
361
|
+
var LEARNINGS_MISSING =
|
|
362
|
+
'no durable lesson recorded. A lesson qualifies only if it would help a future session ' +
|
|
363
|
+
'working on a DIFFERENT task — a reusable pattern, a trap, a decision + rationale. ' +
|
|
364
|
+
'Store one with mcp__moflo__memory_store (namespace "learnings"; use "patterns" for a ' +
|
|
365
|
+
'reusable code shape). What THIS run changed is git history — it belongs in the PR body, ' +
|
|
366
|
+
'not in memory. If this run taught nothing new, say so instead of inventing one: ' +
|
|
367
|
+
'node "' + __filename + '" record-no-durable-lesson';
|
|
348
368
|
// #1294 Finding 3 — exempt ephemeral reads/scans under the OS temp dir
|
|
349
369
|
// (background-task output, scratchpads) from the memory-first gate. Mirrors
|
|
350
370
|
// bin/gate.cjs isEphemeralPath. Cross-platform via os.tmpdir(); normalizes a
|
|
@@ -691,6 +711,71 @@ var EDIT_RESET_SKIP_PATH_RE = /(?:^|[\\\\\\/])\\.github[\\\\\\/](?:workflows|ISS
|
|
|
691
711
|
// new untested surface for code review.
|
|
692
712
|
var EDIT_RESET_SKIP_SIMPLIFY_ONLY_RE = /(?:^|[\\\\\\/])(__tests__|__mocks__|tests?|spec|specs|cypress|e2e|fixtures?)[\\\\\\/]|\\.(test|spec)\\.[mc]?[jt]sx?$|\\.fixture\\.[mc]?[jt]sx?$/i;
|
|
693
713
|
|
|
714
|
+
// #1374/#1435 — count TaskCreate calls against terminal TaskUpdate calls in the
|
|
715
|
+
// session transcript. SYNC: mirrors bin/gate.cjs readTaskLedger (see there for
|
|
716
|
+
// why the transcript, and not a TaskUpdate observer or Claude Code's task store).
|
|
717
|
+
//
|
|
718
|
+
// This template variant omits RELAXATIONS the synced bin/gate.cjs carries (the
|
|
719
|
+
// docs-only exemption, fingerprint expiry) — omitting those only makes the
|
|
720
|
+
// fallback stricter. An ENFORCEMENT gate is the opposite: leaving it out would
|
|
721
|
+
// make the fallback silently permissive, which is the exact failure #1435 is
|
|
722
|
+
// about. So it is mirrored in full.
|
|
723
|
+
var TRANSCRIPT_MAX_BYTES = 16 * 1024 * 1024;
|
|
724
|
+
function readTaskLedger() {
|
|
725
|
+
var tp = process.env.HOOK_TRANSCRIPT_PATH || '';
|
|
726
|
+
if (!tp) return null;
|
|
727
|
+
var raw;
|
|
728
|
+
try {
|
|
729
|
+
var tst = fs.statSync(tp);
|
|
730
|
+
if (!tst.isFile() || tst.size > TRANSCRIPT_MAX_BYTES) return null;
|
|
731
|
+
raw = fs.readFileSync(tp, 'utf-8');
|
|
732
|
+
} catch (e) { return null; }
|
|
733
|
+
var created = 0, createdIdCount = 0;
|
|
734
|
+
var pendingCreates = {}, createdIds = {}, latest = {};
|
|
735
|
+
var pos = 0;
|
|
736
|
+
while (pos <= raw.length) {
|
|
737
|
+
var nl = raw.indexOf('\\n', pos);
|
|
738
|
+
var line = nl < 0 ? raw.slice(pos) : raw.slice(pos, nl);
|
|
739
|
+
pos = nl < 0 ? raw.length + 1 : nl + 1;
|
|
740
|
+
if (line.indexOf('TaskCreate') < 0 && line.indexOf('TaskUpdate') < 0
|
|
741
|
+
&& line.indexOf('created successfully') < 0) continue;
|
|
742
|
+
var entry;
|
|
743
|
+
try { entry = JSON.parse(line); } catch (e) { continue; }
|
|
744
|
+
var content = entry && entry.message && entry.message.content;
|
|
745
|
+
if (!Array.isArray(content)) continue;
|
|
746
|
+
for (var ci = 0; ci < content.length; ci++) {
|
|
747
|
+
var block = content[ci];
|
|
748
|
+
if (!block) continue;
|
|
749
|
+
if (block.type === 'tool_result') {
|
|
750
|
+
if (!pendingCreates[block.tool_use_id]) continue;
|
|
751
|
+
delete pendingCreates[block.tool_use_id];
|
|
752
|
+
var text = typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
|
|
753
|
+
var m = /Task #(\\S+) created successfully/.exec(text || '');
|
|
754
|
+
if (m && !createdIds[m[1]]) { createdIds[m[1]] = true; createdIdCount++; }
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
if (block.type !== 'tool_use') continue;
|
|
758
|
+
if (block.name === 'TaskCreate') {
|
|
759
|
+
created++;
|
|
760
|
+
if (block.id) pendingCreates[block.id] = true;
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
if (block.name !== 'TaskUpdate') continue;
|
|
764
|
+
var tinput = block.input || {};
|
|
765
|
+
var tid = tinput.taskId != null ? tinput.taskId : tinput.task_id;
|
|
766
|
+
if (tid == null || typeof tinput.status !== 'string' || !tinput.status) continue;
|
|
767
|
+
latest[String(tid)] = tinput.status;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
if (created === 0) return null;
|
|
771
|
+
var open = created - createdIdCount;
|
|
772
|
+
Object.keys(createdIds).forEach(function(id) {
|
|
773
|
+
if (latest[id] !== 'completed' && latest[id] !== 'deleted') open++;
|
|
774
|
+
});
|
|
775
|
+
if (open > created) open = created;
|
|
776
|
+
return { created: created, closed: created - open, open: open };
|
|
777
|
+
}
|
|
778
|
+
|
|
694
779
|
switch (command) {
|
|
695
780
|
case 'check-before-agent': {
|
|
696
781
|
// Mostly advisory. The TaskCreate + memory reminders below go to stdout and
|
|
@@ -807,6 +892,28 @@ switch (command) {
|
|
|
807
892
|
writeState(s);
|
|
808
893
|
break;
|
|
809
894
|
}
|
|
895
|
+
// #1435 — the escape from the task-status gate, for work deliberately left
|
|
896
|
+
// open. Session-scoped via STATE_DEFAULTS; no prompt or edit reset touches it.
|
|
897
|
+
case 'record-tasks-acknowledged': {
|
|
898
|
+
var s = readState();
|
|
899
|
+
if (!s.tasksAcknowledged) {
|
|
900
|
+
s.tasksAcknowledged = true;
|
|
901
|
+
writeState(s);
|
|
902
|
+
}
|
|
903
|
+
// writeState swallows its own errors so a gate never crashes its hook. This
|
|
904
|
+
// is the ONLY escape from a BLOCKING gate, so an unconfirmed write would
|
|
905
|
+
// report "satisfied" and block the next 'gh pr create' anyway — confirm it.
|
|
906
|
+
if (!readState().tasksAcknowledged) {
|
|
907
|
+
process.stderr.write('Task-status gate NOT satisfied: the acknowledgement could not be persisted to\\n' +
|
|
908
|
+
STATE_FILE + '\\n' +
|
|
909
|
+
'Check that the file and its directory are writable, then run this again.\\n' +
|
|
910
|
+
'To proceed without it: set gates: task_status_gate: off in moflo.yaml.\\n');
|
|
911
|
+
process.exit(1);
|
|
912
|
+
}
|
|
913
|
+
process.stdout.write('Task-status gate satisfied: open tasks acknowledged as deliberately deferred.\\n' +
|
|
914
|
+
'They stay visible in the task list — this records the decision, it does not close them.\\n');
|
|
915
|
+
break;
|
|
916
|
+
}
|
|
810
917
|
case 'record-memory-searched': {
|
|
811
918
|
var s = readState();
|
|
812
919
|
if (markMemorySearched(s)) writeState(s);
|
|
@@ -847,12 +954,35 @@ switch (command) {
|
|
|
847
954
|
// rather than per-task-transition.
|
|
848
955
|
break;
|
|
849
956
|
}
|
|
850
|
-
|
|
957
|
+
// #1434 — a mandatory write with nothing to say produces filler that displaces
|
|
958
|
+
// reusable lessons from every future bounded search. Both credits set the same
|
|
959
|
+
// flag and differ only in whether the run has something to say, so they share
|
|
960
|
+
// one case body. SYNC: mirrors bin/gate.cjs.
|
|
961
|
+
case 'record-learnings-stored':
|
|
962
|
+
case 'record-no-durable-lesson': {
|
|
851
963
|
var s = readState();
|
|
852
964
|
if (!s.learningsStored) {
|
|
853
965
|
s.learningsStored = true;
|
|
854
966
|
writeState(s);
|
|
855
967
|
}
|
|
968
|
+
if (command === 'record-no-durable-lesson') {
|
|
969
|
+
// Same reasoning as record-tasks-acknowledged: this is the ONLY escape
|
|
970
|
+
// from the BLOCKING learnings gate that needs no memory_store, so an
|
|
971
|
+
// unconfirmed write would report "satisfied" and block anyway. Verified
|
|
972
|
+
// only on this arm — record-learnings-stored fires automatically on every
|
|
973
|
+
// memory_store, where a lost write still leaves the ordinary way through.
|
|
974
|
+
if (!readState().learningsStored) {
|
|
975
|
+
process.stderr.write('Learnings gate NOT satisfied: the declaration could not be persisted to\\n' +
|
|
976
|
+
STATE_FILE + '\\n' +
|
|
977
|
+
'Check that the file and its directory are writable, then run this again.\\n' +
|
|
978
|
+
'To proceed without it: set gates: learnings_gate: false in moflo.yaml.\\n');
|
|
979
|
+
process.exit(1);
|
|
980
|
+
}
|
|
981
|
+
process.stdout.write(
|
|
982
|
+
'Learnings gate satisfied: no durable lesson declared for this run.\\n' +
|
|
983
|
+
'What this run did belongs in the PR body, not in memory.\\n',
|
|
984
|
+
);
|
|
985
|
+
}
|
|
856
986
|
break;
|
|
857
987
|
}
|
|
858
988
|
case 'record-test-run': {
|
|
@@ -981,11 +1111,34 @@ switch (command) {
|
|
|
981
1111
|
case 'check-before-pr': {
|
|
982
1112
|
var cmd = process.env.TOOL_INPUT_command || '';
|
|
983
1113
|
if (!/(?:^|&&\\s*|\\|\\|\\s*|;\\s*)\\s*(?:[A-Z_][A-Z0-9_]*=\\S+\\s+)*gh\\s+pr\\s+create\\b/.test(cmd)) break;
|
|
1114
|
+
// #1435 — task-status gate. Subordinate to task_create_first so both halves
|
|
1115
|
+
// of the task nag are on or off together; fail-open when the ledger is null.
|
|
1116
|
+
// State is read once for the whole case and reused below; reading it before
|
|
1117
|
+
// the ledger keeps an acknowledged run off the transcript scan entirely.
|
|
984
1118
|
var s = readState();
|
|
1119
|
+
if (config.task_create_first && config.task_status_gate !== 'off' && !s.tasksAcknowledged) {
|
|
1120
|
+
var ledger = readTaskLedger();
|
|
1121
|
+
if (ledger && ledger.open > 0) {
|
|
1122
|
+
var tally = ledger.created + ' task' + (ledger.created === 1 ? '' : 's') +
|
|
1123
|
+
' created this session, ' + ledger.open + ' still open.';
|
|
1124
|
+
var closeIt = 'Close them with TaskUpdate (status: completed), or delete the ones ' +
|
|
1125
|
+
'that no longer apply, so the run does not report done over an unfinished list.\\n';
|
|
1126
|
+
if (config.task_status_gate === 'warn') {
|
|
1127
|
+
process.stdout.write('REMINDER: ' + tally + ' ' + closeIt);
|
|
1128
|
+
} else {
|
|
1129
|
+
process.stderr.write('BLOCKED: ' + tally + '\\n' + closeIt +
|
|
1130
|
+
'Deferring them on purpose is a legitimate outcome — declare it instead of\\n' +
|
|
1131
|
+
'closing tasks that are not done: node "' + __filename + '" record-tasks-acknowledged\\n' +
|
|
1132
|
+
GATE_ORIGIN_NOTE + '\\n' +
|
|
1133
|
+
'Report instead of blocking via moflo.yaml: gates: task_status_gate: warn (or: off)\\n');
|
|
1134
|
+
process.exit(2);
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
985
1138
|
var missing = [];
|
|
986
1139
|
if (config.testing_gate && !s.testsRun) missing.push('tests have not run green since the last code edit (run npm test, vitest, jest, pytest, or similar — a run whose output reports failures does not count)');
|
|
987
1140
|
if (config.simplify_gate && !s.simplifyRun) missing.push('/flo-simplify (or /distill) has not run since the last code edit');
|
|
988
|
-
if (config.learnings_gate && !s.learningsStored) missing.push(
|
|
1141
|
+
if (config.learnings_gate && !s.learningsStored) missing.push(LEARNINGS_MISSING);
|
|
989
1142
|
if (missing.length === 0) break;
|
|
990
1143
|
process.stderr.write('BLOCKED: gh pr create requires the following before opening a PR:\\n');
|
|
991
1144
|
for (var i = 0; i < missing.length; i++) {
|
|
@@ -1251,6 +1404,34 @@ if (hookContext.tool_response && typeof hookContext.tool_response === 'object')
|
|
|
1251
1404
|
}
|
|
1252
1405
|
}
|
|
1253
1406
|
|
|
1407
|
+
// #1435 — deliver a PASSING gate's advisory to Claude, not only to the transcript.
|
|
1408
|
+
//
|
|
1409
|
+
// Claude Code shows a PreToolUse/PostToolUse hook's stdout to the user in
|
|
1410
|
+
// transcript mode and stops there; the model never sees it. So every advisory
|
|
1411
|
+
// the gates emit on the exit-0 path was invisible on exactly the runs it was
|
|
1412
|
+
// written for: #1374's open-task count, the pre-Agent TaskCreate reminder, the
|
|
1413
|
+
// namespace hint, the docs-only and simplify-auto-pass notes. They surfaced only
|
|
1414
|
+
// when some OTHER gate blocked, because the catch arm below re-routes err.stdout
|
|
1415
|
+
// to stderr — i.e. only once the PR had already been stopped for another reason.
|
|
1416
|
+
// A consumer shipped a PR over four untouched tasks with that reminder "working".
|
|
1417
|
+
//
|
|
1418
|
+
// \`hookSpecificOutput.additionalContext\` is the documented channel from a passing
|
|
1419
|
+
// tool hook into the model's context. Wrap there and nowhere else: SessionStart
|
|
1420
|
+
// and UserPromptSubmit already inject their stdout as context, so wrapping those
|
|
1421
|
+
// would rewrite a working path for nothing. An unknown or absent hook_event_name
|
|
1422
|
+
// falls back to raw stdout — byte-identical to the previous behaviour.
|
|
1423
|
+
var ADVISORY_EVENTS = { PreToolUse: true, PostToolUse: true };
|
|
1424
|
+
function emitAdvisory(text) {
|
|
1425
|
+
var event = hookContext.hook_event_name;
|
|
1426
|
+
if (!ADVISORY_EVENTS[event]) {
|
|
1427
|
+
process.stdout.write(text);
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
process.stdout.write(JSON.stringify({
|
|
1431
|
+
hookSpecificOutput: { hookEventName: event, additionalContext: text },
|
|
1432
|
+
}) + '\\n');
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1254
1435
|
// Run gate.cjs with the enriched environment
|
|
1255
1436
|
var projectDir = (env.CLAUDE_PROJECT_DIR || process.cwd()).replace(/^\\/([a-z])\\//i, '$1:/');
|
|
1256
1437
|
var gateScript = resolve(projectDir, '.claude/helpers/gate.cjs');
|
|
@@ -1258,7 +1439,7 @@ try {
|
|
|
1258
1439
|
var output = execFileSync('node', [gateScript, command], {
|
|
1259
1440
|
env: env, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true
|
|
1260
1441
|
});
|
|
1261
|
-
if (output.trim())
|
|
1442
|
+
if (output.trim()) emitAdvisory(output);
|
|
1262
1443
|
process.exit(0);
|
|
1263
1444
|
} catch (err) {
|
|
1264
1445
|
// gate.cjs exit(2) = block, exit(1) = also block attempt — translate both to exit(2)
|
|
@@ -215,6 +215,7 @@ gates:
|
|
|
215
215
|
task_create_first: ${gates}
|
|
216
216
|
context_tracking: ${gates}
|
|
217
217
|
verify_before_done: true # Run /verify before 'gh pr create'. On by default; opt out with false or per-run --no-verify
|
|
218
|
+
task_status_gate: block # Open tasks stop 'gh pr create'. warn → report only; off → silent
|
|
218
219
|
|
|
219
220
|
# Auto-index on session start
|
|
220
221
|
auto_index:
|
|
@@ -77,7 +77,9 @@ export const REQUIRED_HOOK_WIRING = [
|
|
|
77
77
|
// listing it here made repairHookWiring() graft the hook back into every
|
|
78
78
|
// consumer on session start, undoing the removal.
|
|
79
79
|
{ event: 'PostToolUse', pattern: 'record-learnings-stored' },
|
|
80
|
-
|
|
80
|
+
// PreToolUse, not PostToolUse: #1132 moved it so its process.exit(2) actually
|
|
81
|
+
// prevents the read instead of reporting one that already happened.
|
|
82
|
+
{ event: 'PreToolUse', pattern: 'check-bash-memory' },
|
|
81
83
|
{ event: 'PostToolUse', pattern: 'record-test-run' },
|
|
82
84
|
// #1338 follow-up — CLI half of the #952 swarm/hive init recorders. Listed
|
|
83
85
|
// here so an existing consumer picks it up via repairHookWiring on session
|
|
@@ -157,7 +159,9 @@ export const HOOK_ENTRY_MAP = {
|
|
|
157
159
|
'record-learnings-stored': { event: 'PostToolUse', matcher: '^mcp__moflo__memory_store$', hook: { type: 'command', command: 'node "$CLAUDE_PROJECT_DIR/.claude/helpers/gate.cjs" record-learnings-stored', timeout: 2000 } },
|
|
158
160
|
// #1171 — widened to ^(Bash|PowerShell)$ so PS reads / PS-invoked tests credit
|
|
159
161
|
// the same gates as Bash. Name kept as `check-bash-memory` for backwards compat.
|
|
160
|
-
|
|
162
|
+
// PreToolUse since #1132: grafting it under PostToolUse — as this entry did —
|
|
163
|
+
// would repair a consumer into a gate that reports a read it can no longer stop.
|
|
164
|
+
'check-bash-memory': { event: 'PreToolUse', matcher: '^(Bash|PowerShell)$', hook: { type: 'command', command: 'node "$CLAUDE_PROJECT_DIR/.claude/helpers/gate-hook.mjs" check-bash-memory', timeout: 2000 } },
|
|
161
165
|
'record-test-run': { event: 'PostToolUse', matcher: '^(Bash|PowerShell)$', hook: { type: 'command', command: 'node "$CLAUDE_PROJECT_DIR/.claude/helpers/gate-hook.mjs" record-test-run', timeout: 2000 } },
|
|
162
166
|
// #1338 follow-up — same Bash/PowerShell PostToolUse block as record-test-run.
|
|
163
167
|
'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 } },
|
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.
|
|
3
|
+
"version": "4.12.7",
|
|
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.
|
|
101
|
+
"moflo": "^4.12.6",
|
|
102
102
|
"tsx": "^4.21.0",
|
|
103
103
|
"typescript": "^5.9.3",
|
|
104
104
|
"vitest": "^4.0.0"
|