moflo 4.12.5 → 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/commands/doctor-fixes.js +119 -20
- 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/services/project-root.js +17 -1
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -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++) {
|
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
* shell-out where possible). Falls back to running the check's `fix` string
|
|
6
6
|
* if it looks like an `npx`/`npm`/`claude` command.
|
|
7
7
|
*/
|
|
8
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmdirSync, unlinkSync, writeFileSync, readdirSync } from 'fs';
|
|
9
|
-
import { join } from 'path';
|
|
8
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmdirSync, unlinkSync, writeFileSync, readdirSync } from 'fs';
|
|
9
|
+
import { basename, dirname, isAbsolute, join, parse, relative, resolve } from 'path';
|
|
10
10
|
import { output } from '../output.js';
|
|
11
11
|
import { errorDetail } from '../shared/utils/error-detail.js';
|
|
12
12
|
import { atomicWriteFileSync } from '../shared/utils/atomic-file-write.js';
|
|
13
13
|
import { repairHookWiring } from '../services/hook-wiring.js';
|
|
14
14
|
import { findProjectDaemonPids, getDaemonLockHolder } from '../services/daemon-lock.js';
|
|
15
|
-
import { findProjectRoot, resolveStateRoot } from '../services/project-root.js';
|
|
15
|
+
import { findProjectRoot, hasMofloStateMarker, resolveStateRoot } from '../services/project-root.js';
|
|
16
16
|
import { legacyMemoryDbPath, legacyMemoryDbBakPath, memoryDbPath, mofloDir } from '../services/moflo-paths.js';
|
|
17
17
|
import { findZombieProcesses } from './doctor-zombies.js';
|
|
18
18
|
import { loadToolArrays, getTool } from './doctor-checks-functional-shared.js';
|
|
@@ -29,6 +29,86 @@ async function runFixCommand(cmd) {
|
|
|
29
29
|
return false;
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
+
/** `realpathSync` if the path exists, else the absolutized string. */
|
|
33
|
+
function canonical(p) {
|
|
34
|
+
const abs = resolve(p);
|
|
35
|
+
try {
|
|
36
|
+
return realpathSync(abs);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return abs;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Nearest moflo root at or above `from` (inclusive), or null.
|
|
44
|
+
*
|
|
45
|
+
* Shares `hasMofloStateMarker` with `findProjectRoot`'s Pass A rather than
|
|
46
|
+
* restating the marker set: a guard that tests a narrower signal than the
|
|
47
|
+
* resolver it guards is blind exactly where it matters, which is #1431's
|
|
48
|
+
* fourth defect. `findAncestorMofloRoot` is deliberately not reused — it tests
|
|
49
|
+
* only `.moflo/moflo.db`, and it is exclusive of its starting directory.
|
|
50
|
+
*/
|
|
51
|
+
function nearestMofloRoot(from) {
|
|
52
|
+
const start = resolve(from);
|
|
53
|
+
const fsRoot = parse(start).root;
|
|
54
|
+
let dir = start;
|
|
55
|
+
while (dir !== fsRoot) {
|
|
56
|
+
// Same skip as Pass A — a marker inside `node_modules` is a vendored
|
|
57
|
+
// package's state, not a project root.
|
|
58
|
+
if (basename(dir) === 'node_modules') {
|
|
59
|
+
dir = dirname(dir);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (hasMofloStateMarker(dir))
|
|
63
|
+
return dir;
|
|
64
|
+
const parent = dirname(dir);
|
|
65
|
+
if (parent === dir)
|
|
66
|
+
break;
|
|
67
|
+
dir = parent;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Resolve the project root the daemon auto-fixes may act on, or null to refuse.
|
|
73
|
+
*
|
|
74
|
+
* The fixes SIGTERM processes and unlink lock files, so an over-broad root is
|
|
75
|
+
* not a cosmetic error. `findProjectRoot()` Pass A deliberately returns the
|
|
76
|
+
* TOPMOST ancestor carrying `.moflo/moflo.db` (#1174, so a monorepo's root
|
|
77
|
+
* daemon is canonical). For a checkout nested inside an unrelated project that
|
|
78
|
+
* walk climbs past our own root and lands on the parent's — whose daemons are
|
|
79
|
+
* not ours to kill. These handlers were previously rooted at `process.cwd()`,
|
|
80
|
+
* where that case degraded to a harmless no-op (`pids` came back empty), so
|
|
81
|
+
* aligning them with `checkDaemonOrphan` must not convert a no-op into a kill.
|
|
82
|
+
*
|
|
83
|
+
* Refuse whenever a NEARER moflo root sits at or above the cwd and differs
|
|
84
|
+
* from the resolved root: that is the nested-checkout signature. Both sides are
|
|
85
|
+
* realpath'd before comparison (Rule #1 §2) — `findProjectRoot` may hand back a
|
|
86
|
+
* raw `CLAUDE_PROJECT_DIR` while the walk yields a resolved path, and on macOS
|
|
87
|
+
* `/var/folders` vs `/private/var/folders` would otherwise compare unequal.
|
|
88
|
+
*/
|
|
89
|
+
function daemonFixRoot() {
|
|
90
|
+
const root = findProjectRoot();
|
|
91
|
+
const rootC = canonical(root);
|
|
92
|
+
const cwdC = canonical(process.cwd());
|
|
93
|
+
// Containment first: the cwd must live inside the root we are about to reap
|
|
94
|
+
// in. `findProjectRoot` returns `CLAUDE_PROJECT_DIR` verbatim when set, and
|
|
95
|
+
// that variable is inherited by hooks, test runners, and spawned tools whose
|
|
96
|
+
// cwd may be somewhere else entirely — a temp fixture, `/`, another repo.
|
|
97
|
+
// Without this check the handler happily reaps the daemons of whatever
|
|
98
|
+
// project the environment names, from a cwd with no relationship to it.
|
|
99
|
+
// `relative` + `isAbsolute` rather than string prefixing: on Windows two
|
|
100
|
+
// different drive letters yield an absolute result, which must count as
|
|
101
|
+
// "outside" (Rule #1).
|
|
102
|
+
const rel = relative(rootC, cwdC);
|
|
103
|
+
if (rel !== '' && (rel.startsWith('..') || isAbsolute(rel)))
|
|
104
|
+
return null;
|
|
105
|
+
const nearest = nearestMofloRoot(cwdC);
|
|
106
|
+
// No moflo state anywhere at or above cwd: nothing to disambiguate, and the
|
|
107
|
+
// scan will find no same-project daemons anyway.
|
|
108
|
+
if (nearest === null)
|
|
109
|
+
return root;
|
|
110
|
+
return canonical(nearest) === rootC ? root : null;
|
|
111
|
+
}
|
|
32
112
|
/**
|
|
33
113
|
* Fix the `Config File` check by creating the project's JSON config.
|
|
34
114
|
*
|
|
@@ -601,9 +681,13 @@ export async function autoFixCheck(check) {
|
|
|
601
681
|
// Also reaps any same-project orphans whose PIDs aren't recorded in the
|
|
602
682
|
// lock — those are the daemons that survived prior buggy fixes.
|
|
603
683
|
'Daemon Status': async () => {
|
|
604
|
-
|
|
684
|
+
// #1431 — same subdirectory-mismatch class as `Daemon Orphan`; this one
|
|
685
|
+
// also reaps and unlinks, so it takes the same guarded root.
|
|
686
|
+
const root = daemonFixRoot();
|
|
687
|
+
if (root === null)
|
|
688
|
+
return false;
|
|
605
689
|
const { getDaemonLockPayload, reapSameProjectOrphans } = await import('../services/daemon-lock.js');
|
|
606
|
-
const payload = getDaemonLockPayload(
|
|
690
|
+
const payload = getDaemonLockPayload(root);
|
|
607
691
|
if (payload?.pid && payload.pid > 0) {
|
|
608
692
|
try {
|
|
609
693
|
process.kill(payload.pid, 'SIGTERM');
|
|
@@ -611,9 +695,9 @@ export async function autoFixCheck(check) {
|
|
|
611
695
|
catch { /* already dead */ }
|
|
612
696
|
}
|
|
613
697
|
// Wipe other same-project daemons that the lock doesn't account for.
|
|
614
|
-
reapSameProjectOrphans(
|
|
615
|
-
const lockFile = join(
|
|
616
|
-
const pidFile = join(
|
|
698
|
+
reapSameProjectOrphans(root);
|
|
699
|
+
const lockFile = join(root, '.moflo', 'daemon.lock');
|
|
700
|
+
const pidFile = join(root, '.moflo', 'daemon.pid');
|
|
617
701
|
try {
|
|
618
702
|
if (existsSync(lockFile))
|
|
619
703
|
unlinkSync(lockFile);
|
|
@@ -629,16 +713,19 @@ export async function autoFixCheck(check) {
|
|
|
629
713
|
// bin/session-start-launcher.mjs so the auto-fix matches the launcher's
|
|
630
714
|
// behavior exactly.
|
|
631
715
|
'Daemon Version Skew': async () => {
|
|
632
|
-
|
|
716
|
+
// #1431 — same subdirectory-mismatch class as `Daemon Orphan`.
|
|
717
|
+
const root = daemonFixRoot();
|
|
718
|
+
if (root === null)
|
|
719
|
+
return false;
|
|
633
720
|
const { getDaemonLockPayload } = await import('../services/daemon-lock.js');
|
|
634
|
-
const payload = getDaemonLockPayload(
|
|
721
|
+
const payload = getDaemonLockPayload(root);
|
|
635
722
|
if (payload?.pid && payload.pid > 0) {
|
|
636
723
|
try {
|
|
637
724
|
process.kill(payload.pid, 'SIGTERM');
|
|
638
725
|
}
|
|
639
726
|
catch { /* already dead */ }
|
|
640
727
|
}
|
|
641
|
-
const lockFile = join(
|
|
728
|
+
const lockFile = join(root, '.moflo', 'daemon.lock');
|
|
642
729
|
try {
|
|
643
730
|
if (existsSync(lockFile))
|
|
644
731
|
unlinkSync(lockFile);
|
|
@@ -653,20 +740,27 @@ export async function autoFixCheck(check) {
|
|
|
653
740
|
// threaded into `reapSameProjectOrphans` so we don't re-run the
|
|
654
741
|
// OS process scan inside it.
|
|
655
742
|
'Daemon Orphan': async () => {
|
|
656
|
-
|
|
743
|
+
// #1431 — resolve the root the way `checkDaemonOrphan` does, not with
|
|
744
|
+
// `process.cwd()`. The check walks up to the project root; the fix used
|
|
745
|
+
// the raw cwd, so `flo doctor --fix` from a subdirectory scanned a root
|
|
746
|
+
// with no daemons, fell through `pids.length <= 1`, and reported
|
|
747
|
+
// "Fixed: Daemon Orphan" without reaping anything.
|
|
748
|
+
const root = daemonFixRoot();
|
|
749
|
+
if (root === null)
|
|
750
|
+
return false;
|
|
657
751
|
const { findProjectDaemonPids, getDaemonLockHolder, reapSameProjectOrphans } = await import('../services/daemon-lock.js');
|
|
658
|
-
const pids = findProjectDaemonPids(
|
|
752
|
+
const pids = findProjectDaemonPids(root);
|
|
659
753
|
if (pids.length <= 1)
|
|
660
754
|
return true; // already healthy
|
|
661
|
-
const lockHolder = getDaemonLockHolder(
|
|
755
|
+
const lockHolder = getDaemonLockHolder(root);
|
|
662
756
|
if (lockHolder != null && pids.includes(lockHolder)) {
|
|
663
|
-
const { survived } = reapSameProjectOrphans(
|
|
757
|
+
const { survived } = reapSameProjectOrphans(root, process.pid, lockHolder, pids);
|
|
664
758
|
return survived.length === 0;
|
|
665
759
|
}
|
|
666
760
|
// No identifiable canonical daemon — kill them all, clear the lock,
|
|
667
761
|
// respawn fresh.
|
|
668
|
-
const { survived } = reapSameProjectOrphans(
|
|
669
|
-
const lockFile = join(
|
|
762
|
+
const { survived } = reapSameProjectOrphans(root, process.pid, undefined, pids);
|
|
763
|
+
const lockFile = join(root, '.moflo', 'daemon.lock');
|
|
670
764
|
try {
|
|
671
765
|
if (existsSync(lockFile))
|
|
672
766
|
unlinkSync(lockFile);
|
|
@@ -682,16 +776,21 @@ export async function autoFixCheck(check) {
|
|
|
682
776
|
// daemon binds the per-project deterministic port and stamps it into
|
|
683
777
|
// the lock — clients can discover it without guessing.
|
|
684
778
|
'Daemon Identity Match': async () => {
|
|
685
|
-
|
|
779
|
+
// #1431 — same resolver as `checkDaemonIdentityMatch`; see the note on
|
|
780
|
+
// the `Daemon Orphan` handler above. Reading the lock from a raw cwd
|
|
781
|
+
// would SIGTERM nothing and unlink a path the daemon never wrote.
|
|
782
|
+
const root = daemonFixRoot();
|
|
783
|
+
if (root === null)
|
|
784
|
+
return false;
|
|
686
785
|
const { getDaemonLockPayload } = await import('../services/daemon-lock.js');
|
|
687
|
-
const payload = getDaemonLockPayload(
|
|
786
|
+
const payload = getDaemonLockPayload(root);
|
|
688
787
|
if (payload?.pid && payload.pid > 0) {
|
|
689
788
|
try {
|
|
690
789
|
process.kill(payload.pid, 'SIGTERM');
|
|
691
790
|
}
|
|
692
791
|
catch { /* already dead */ }
|
|
693
792
|
}
|
|
694
|
-
const lockFile = join(
|
|
793
|
+
const lockFile = join(root, '.moflo', 'daemon.lock');
|
|
695
794
|
try {
|
|
696
795
|
if (existsSync(lockFile))
|
|
697
796
|
unlinkSync(lockFile);
|