moflo 4.9.24 → 4.9.26
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/skills/healer/SKILL.md +51 -0
- package/.claude/skills/publish/SKILL.md +4 -8
- package/bin/lib/file-sync.mjs +200 -0
- package/bin/session-start-launcher.mjs +107 -74
- package/dist/src/cli/commands/doctor-checks-runtime.js +36 -4
- package/dist/src/cli/commands/doctor-fixes.js +17 -0
- package/dist/src/cli/commands/doctor-version.js +6 -2
- package/dist/src/cli/init/executor.js +1 -0
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
- package/scripts/post-install-bootstrap.mjs +38 -62
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: healer
|
|
3
|
+
description: Run moflo's Healer (`flo healer`, alias for `flo doctor`) from inside the Claude session. Audit-only by default; pass `--fix` to apply auto-repairs, `-c <component>` for a single check. Use when something feels off (missing moflo.yaml, daemon dead, statusline empty, hooks not firing) or as a periodic health check. Distinct from Claude Code's built-in `/doctor`, which diagnoses Claude Code itself, not moflo.
|
|
4
|
+
arguments: "[options]"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# /healer — moflo Installation Healer
|
|
8
|
+
|
|
9
|
+
Thin wrapper around the `flo healer` CLI. All check + fix logic lives in the CLI; this skill just shells out, surfaces results in-thread, and gives one-line follow-up nudges.
|
|
10
|
+
|
|
11
|
+
**Arguments:** $ARGUMENTS
|
|
12
|
+
|
|
13
|
+
## Procedure
|
|
14
|
+
|
|
15
|
+
1. **Memory first** (gate requirement):
|
|
16
|
+
```
|
|
17
|
+
mcp__moflo__memory_search { query: "doctor healer fix moflo.yaml gate hook wiring", namespace: "guidance" }
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
2. **Run the CLI** with the user's arguments passed through:
|
|
21
|
+
```bash
|
|
22
|
+
npx moflo healer --json $ARGUMENTS
|
|
23
|
+
```
|
|
24
|
+
- No args → audit-only.
|
|
25
|
+
- `--fix` → CLI runs auto-repairs after the audit.
|
|
26
|
+
- `-c <component>` → restricts to one check.
|
|
27
|
+
- Always include `--json` so output is machine-parseable.
|
|
28
|
+
|
|
29
|
+
3. **Surface the JSON in-thread**. Group by status:
|
|
30
|
+
- `✓ N passing` (count only)
|
|
31
|
+
- `⚠ warnings` — list `name: message`; flag with `[auto-fixable]` when the result has a `fix` field
|
|
32
|
+
- `✗ failures` — same
|
|
33
|
+
- If `--fix` mode, also list which fixes were applied vs which need manual action.
|
|
34
|
+
|
|
35
|
+
4. **Nudge based on what changed.** Only mention next steps for state that *actually* changed:
|
|
36
|
+
- Daemon restarted → `Statusline should refresh within ~5s.`
|
|
37
|
+
- `moflo.yaml` created → `Review the new defaults at the project root before your next deep run.`
|
|
38
|
+
- Hook wiring repaired → `Restart Claude Code so the new SessionStart hook fires next launch.`
|
|
39
|
+
- In audit-only mode with auto-fixable issues → `Run /healer --fix to repair.`
|
|
40
|
+
|
|
41
|
+
## Rules
|
|
42
|
+
|
|
43
|
+
- **Don't** re-document checks or fixes here. The CLI's `--help` and `src/cli/commands/doctor-*` are the source of truth.
|
|
44
|
+
- **Don't** call `flo doctor` directly — use the `healer` alias for thematic consistency. They're equivalent CLI-side.
|
|
45
|
+
- **Don't** swallow non-zero exit codes silently — surface them in the summary.
|
|
46
|
+
- **Note for users:** Claude Code has its own built-in `/doctor` command that diagnoses Claude Code itself. This skill (`/healer`) diagnoses **moflo**, not Claude Code. The two are complementary, not duplicates. The healer rolls in the user-actionable parts of `claude doctor` (Claude Code version freshness vs npm latest) into its own `Claude Code CLI` check; the rest of `claude doctor` is a TUI on current releases and must be run interactively if you need its full report.
|
|
47
|
+
|
|
48
|
+
## See Also
|
|
49
|
+
|
|
50
|
+
- `flo doctor --help` — full flag/component list
|
|
51
|
+
- `/eldar` — broader project-setup audit; consults the Healer as one input
|
|
@@ -93,19 +93,15 @@ Skipped by default — `ci.yml` runs the full test suite on every PR. Run when `
|
|
|
93
93
|
|
|
94
94
|
**Must have 0 test file failures.** If any test files fail, retest them individually to distinguish real failures from flaky ones (per broken window theory). Fix all real failures before proceeding.
|
|
95
95
|
|
|
96
|
-
### Step 5: Doctor (always)
|
|
96
|
+
### Step 5: Doctor (always — strict, no repair)
|
|
97
97
|
|
|
98
|
-
Default mode:
|
|
99
|
-
```bash
|
|
100
|
-
npx moflo doctor --fix
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
Check mode (`CHECK_MODE=true`):
|
|
104
98
|
```bash
|
|
105
99
|
npx moflo doctor --strict
|
|
106
100
|
```
|
|
107
101
|
|
|
108
|
-
Doctor is the only check with no CI equivalent — it inspects local state (daemon lock, embeddings hygiene, sandbox tier, vector-stats freshness) that CI cannot validate for you. Always runs
|
|
102
|
+
Doctor is the only check with no CI equivalent — it inspects local state (daemon lock, embeddings hygiene, sandbox tier, vector-stats freshness) that CI cannot validate for you. Always runs in `--strict` mode regardless of `CHECK_MODE`.
|
|
103
|
+
|
|
104
|
+
**Never `--fix` on the publish path.** A release pipeline must fail fast on broken local state, not silently repair it; a doctor that auto-repairs masks the very signal we want — "something is off, stop and investigate before shipping." If `doctor --strict` fails, stop and run `flo healer --fix` (or `npx moflo doctor --fix`) interactively, verify the repair, then retry the publish.
|
|
109
105
|
|
|
110
106
|
### Step 6: Smoke Tests (only if `CHECK_MODE=true`)
|
|
111
107
|
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared file-sync helper for the launcher (#854 §3) and the postinstall
|
|
3
|
+
* bootstrap (#857 / #975).
|
|
4
|
+
*
|
|
5
|
+
* Both layers used to inline the same retry/breaker + copy logic. They drifted
|
|
6
|
+
* once already (the bootstrap added hash-skip + atomic tmp+rename for #975
|
|
7
|
+
* while the launcher kept the bare copyFileSync), so this module is the single
|
|
8
|
+
* source of truth.
|
|
9
|
+
*
|
|
10
|
+
* Responsibilities:
|
|
11
|
+
* - hash-skip when src and dest are byte-identical (eliminates the dominant
|
|
12
|
+
* failure class — overwriting an unchanged file open by Claude/indexer).
|
|
13
|
+
* - atomic tmp + rename so concurrent readers never see a torn write.
|
|
14
|
+
* - post-write size verify to catch torn writes from AV mid-stream and
|
|
15
|
+
* partial DrvFs writes that returned success codes.
|
|
16
|
+
* - retry the transient error class (EBUSY/EPERM/EACCES + EVERIFY) with
|
|
17
|
+
* exponential backoff [50,200,800]ms.
|
|
18
|
+
* - circuit-break after CIRCUIT_BREAK_THRESHOLD distinct exhausted-retry
|
|
19
|
+
* failures so a sick host (AV mid-scan over node_modules) doesn't compound
|
|
20
|
+
* wall-clock cost.
|
|
21
|
+
*
|
|
22
|
+
* Ships at `bin/lib/file-sync.mjs`. Bootstrap imports via relative path from
|
|
23
|
+
* `scripts/`; launcher imports via `./lib/file-sync.mjs` after sync to
|
|
24
|
+
* `<consumer>/.claude/scripts/lib/`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
copyFileSync,
|
|
29
|
+
existsSync,
|
|
30
|
+
mkdirSync,
|
|
31
|
+
readFileSync,
|
|
32
|
+
renameSync,
|
|
33
|
+
statSync,
|
|
34
|
+
unlinkSync,
|
|
35
|
+
} from 'node:fs';
|
|
36
|
+
import { createHash } from 'node:crypto';
|
|
37
|
+
import { dirname } from 'node:path';
|
|
38
|
+
|
|
39
|
+
export const TRANSIENT_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']);
|
|
40
|
+
export const RETRY_BACKOFF_MS = [50, 200, 800];
|
|
41
|
+
export const CIRCUIT_BREAK_THRESHOLD = 5;
|
|
42
|
+
|
|
43
|
+
// Code attached to the post-write size-verify failure. Treated as transient by
|
|
44
|
+
// syncWithRetry so torn writes from AV mid-stream / partial DrvFs writes get a
|
|
45
|
+
// retry instead of immediately surfacing as a hard failure.
|
|
46
|
+
export const VERIFY_FAIL_CODE = 'EVERIFY';
|
|
47
|
+
|
|
48
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
49
|
+
|
|
50
|
+
export function fileHash(path) {
|
|
51
|
+
try {
|
|
52
|
+
return createHash('sha1').update(readFileSync(path)).digest('hex');
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function contentEqual(srcPath, destPath) {
|
|
59
|
+
if (!existsSync(destPath)) return false;
|
|
60
|
+
// Size check first — skips the SHA-1 pass on every mis-sized pair without
|
|
61
|
+
// any I/O on the file body. For the bootstrap's small file set the SHA-1
|
|
62
|
+
// is cheap, but this fires on every file on every session-start with
|
|
63
|
+
// version drift, and under load (AV lock + retries) the reads compound.
|
|
64
|
+
let srcSize, destSize;
|
|
65
|
+
try {
|
|
66
|
+
srcSize = statSync(srcPath).size;
|
|
67
|
+
destSize = statSync(destPath).size;
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
if (srcSize !== destSize) return false;
|
|
72
|
+
const srcHash = fileHash(srcPath);
|
|
73
|
+
if (!srcHash) return false;
|
|
74
|
+
const destHash = fileHash(destPath);
|
|
75
|
+
return destHash !== null && srcHash === destHash;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Atomic copy via tmp + rename with post-write size verify.
|
|
80
|
+
*
|
|
81
|
+
* Steps:
|
|
82
|
+
* 1. copyFileSync(src, dest.tmp)
|
|
83
|
+
* 2. Verify dest.tmp size matches src size (catches torn writes from AV
|
|
84
|
+
* mid-stream and partial DrvFs writes that returned success codes).
|
|
85
|
+
* Mismatch unlinks the tmp and throws { code: 'EVERIFY' }, which the
|
|
86
|
+
* retry loop treats as transient.
|
|
87
|
+
* 3. renameSync(dest.tmp, dest) — atomic on Win/macOS/Linux/WSL/DrvFs.
|
|
88
|
+
*
|
|
89
|
+
* If rename fails, the .tmp sidecar persists as a recovery breadcrumb — next
|
|
90
|
+
* session-start can complete the swap once the original lock has cleared.
|
|
91
|
+
*
|
|
92
|
+
* `deps` is dependency injection for tests (#976 fault injection of the
|
|
93
|
+
* truncated-tmp / partial-DrvFs scenario). Production callers omit it.
|
|
94
|
+
*/
|
|
95
|
+
export function atomicCopy(src, dest, deps = {}) {
|
|
96
|
+
const _copyFile = deps.copyFile || copyFileSync;
|
|
97
|
+
const _stat = deps.stat || statSync;
|
|
98
|
+
const _rename = deps.rename || renameSync;
|
|
99
|
+
const _unlink = deps.unlink || unlinkSync;
|
|
100
|
+
|
|
101
|
+
const tmp = `${dest}.tmp`;
|
|
102
|
+
_copyFile(src, tmp);
|
|
103
|
+
let srcSize, tmpSize;
|
|
104
|
+
try {
|
|
105
|
+
srcSize = _stat(src).size;
|
|
106
|
+
tmpSize = _stat(tmp).size;
|
|
107
|
+
} catch (statErr) {
|
|
108
|
+
try { _unlink(tmp); } catch { /* best-effort cleanup */ }
|
|
109
|
+
const err = new Error(`atomicCopy verify stat failed: ${statErr.message || statErr}`);
|
|
110
|
+
err.code = statErr.code || VERIFY_FAIL_CODE;
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
if (srcSize !== tmpSize) {
|
|
114
|
+
try { _unlink(tmp); } catch { /* best-effort cleanup */ }
|
|
115
|
+
const err = new Error(
|
|
116
|
+
`atomicCopy size mismatch (src=${srcSize} tmp=${tmpSize}) for ${dest}`,
|
|
117
|
+
);
|
|
118
|
+
err.code = VERIFY_FAIL_CODE;
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
_rename(tmp, dest);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function errMessage(err) {
|
|
125
|
+
if (!err) return 'unknown error';
|
|
126
|
+
return err.code ? `${err.code} ${err.message || ''}`.trim() : (err.message || String(err));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Build a retry-aware syncer.
|
|
131
|
+
*
|
|
132
|
+
* @param {object} [options]
|
|
133
|
+
* @param {(key: string, dest: string) => void} [options.onSuccess]
|
|
134
|
+
* Fires after every successful syncFile (including hash-skip identical
|
|
135
|
+
* paths). Use it to record manifest entries from the launcher; bootstrap
|
|
136
|
+
* ignores it.
|
|
137
|
+
*
|
|
138
|
+
* @returns {{
|
|
139
|
+
* syncFile: (src: string, dest: string, key: string) => Promise<{ok?: boolean, skipped?: true | 'identical'}>,
|
|
140
|
+
* failures: Array<{key: string, message: string, src?: string, dest?: string}>,
|
|
141
|
+
* isCircuitOpen: () => boolean,
|
|
142
|
+
* }}
|
|
143
|
+
*/
|
|
144
|
+
export function makeSyncer({ onSuccess } = {}) {
|
|
145
|
+
let circuitOpen = false;
|
|
146
|
+
const failures = [];
|
|
147
|
+
|
|
148
|
+
async function syncWithRetry(operation) {
|
|
149
|
+
const maxAttempts = circuitOpen ? 1 : RETRY_BACKOFF_MS.length + 1;
|
|
150
|
+
let lastErr = null;
|
|
151
|
+
let lastCode = null;
|
|
152
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
153
|
+
if (attempt > 0) await sleep(RETRY_BACKOFF_MS[attempt - 1]);
|
|
154
|
+
try {
|
|
155
|
+
operation();
|
|
156
|
+
return { ok: true };
|
|
157
|
+
} catch (err) {
|
|
158
|
+
lastErr = err;
|
|
159
|
+
lastCode = err && err.code ? err.code : null;
|
|
160
|
+
const transient = TRANSIENT_CODES.has(lastCode) || lastCode === VERIFY_FAIL_CODE;
|
|
161
|
+
if (!transient) break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (!circuitOpen && failures.length + 1 >= CIRCUIT_BREAK_THRESHOLD) {
|
|
165
|
+
circuitOpen = true;
|
|
166
|
+
}
|
|
167
|
+
return { ok: false, err: lastErr, code: lastCode };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function syncFile(src, dest, key) {
|
|
171
|
+
if (!existsSync(src)) return { skipped: true };
|
|
172
|
+
try {
|
|
173
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
174
|
+
} catch (err) {
|
|
175
|
+
failures.push({ key, message: errMessage(err), src, dest });
|
|
176
|
+
return { ok: false };
|
|
177
|
+
}
|
|
178
|
+
if (contentEqual(src, dest)) {
|
|
179
|
+
try { onSuccess?.(key, dest); } catch { /* non-fatal */ }
|
|
180
|
+
return { ok: true, skipped: 'identical' };
|
|
181
|
+
}
|
|
182
|
+
const result = await syncWithRetry(() => atomicCopy(src, dest));
|
|
183
|
+
if (result.ok) {
|
|
184
|
+
try { onSuccess?.(key, dest); } catch { /* non-fatal */ }
|
|
185
|
+
return { ok: true };
|
|
186
|
+
}
|
|
187
|
+
const transient = TRANSIENT_CODES.has(result.code) || result.code === VERIFY_FAIL_CODE;
|
|
188
|
+
const tail = transient
|
|
189
|
+
? ` (retried ${RETRY_BACKOFF_MS.length}× after ${result.code}${circuitOpen ? '; circuit open' : ''})`
|
|
190
|
+
: '';
|
|
191
|
+
failures.push({ key, message: `${errMessage(result.err)}${tail}`, src, dest });
|
|
192
|
+
return { ok: false };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
syncFile,
|
|
197
|
+
failures,
|
|
198
|
+
isCircuitOpen: () => circuitOpen,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -8,13 +8,14 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { spawn, execFileSync } from 'child_process';
|
|
11
|
-
import { existsSync, readFileSync, writeFileSync,
|
|
11
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, mkdirSync, statSync } from 'fs';
|
|
12
12
|
import { resolve, dirname, join } from 'path';
|
|
13
13
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
14
14
|
import { mofloDir } from './lib/moflo-paths.mjs';
|
|
15
15
|
import { repairMemoryDbIfCorrupt } from './lib/db-repair.mjs';
|
|
16
16
|
import { resolveMofloBin } from './lib/resolve-bin.mjs';
|
|
17
17
|
import { applyRetiredPrune } from './lib/retired-files.mjs';
|
|
18
|
+
import { makeSyncer, contentEqual } from './lib/file-sync.mjs';
|
|
18
19
|
|
|
19
20
|
// Headless skip (#860). The daemon's headless workers spawn `claude --print`
|
|
20
21
|
// with CLAUDE_CODE_HEADLESS=true (see src/cli/services/headless-worker-
|
|
@@ -166,8 +167,12 @@ const UPGRADE_NOTICE_INPROGRESS_TTL_MS = 5 * 60 * 1000;
|
|
|
166
167
|
const UPGRADE_NOTICE_COMPLETED_TTL_MS = 2 * 60 * 1000;
|
|
167
168
|
const UPGRADE_NOTICE_PATH = () => join(mofloDir(projectRoot), 'upgrade-notice.json');
|
|
168
169
|
|
|
169
|
-
|
|
170
|
-
|
|
170
|
+
// Single-source-of-truth notice writer. Reused by writeUpgradeNotice (the
|
|
171
|
+
// version-bump / drift-heal path) and the §0-bootstrap-sentinel + §3h paths
|
|
172
|
+
// (#975 statusline-channel promotion). Keeps the JSON shape colocated with
|
|
173
|
+
// the TTL constants instead of letting it drift across two inline copies.
|
|
174
|
+
function buildAndWriteNotice(context, status) {
|
|
175
|
+
if (!context) return;
|
|
171
176
|
const ttlMs = status === 'completed'
|
|
172
177
|
? UPGRADE_NOTICE_COMPLETED_TTL_MS
|
|
173
178
|
: UPGRADE_NOTICE_INPROGRESS_TTL_MS;
|
|
@@ -176,9 +181,9 @@ function writeUpgradeNotice(status) {
|
|
|
176
181
|
const now = Date.now();
|
|
177
182
|
const notice = {
|
|
178
183
|
status,
|
|
179
|
-
kind:
|
|
180
|
-
from:
|
|
181
|
-
to:
|
|
184
|
+
kind: context.kind,
|
|
185
|
+
from: context.from,
|
|
186
|
+
to: context.to,
|
|
182
187
|
at: new Date(now).toISOString(),
|
|
183
188
|
expiresAt: new Date(now + ttlMs).toISOString(),
|
|
184
189
|
changes: 0,
|
|
@@ -187,6 +192,10 @@ function writeUpgradeNotice(status) {
|
|
|
187
192
|
} catch { /* non-fatal — statusline just won't show the segment */ }
|
|
188
193
|
}
|
|
189
194
|
|
|
195
|
+
function writeUpgradeNotice(status) {
|
|
196
|
+
buildAndWriteNotice(upgradeNoticeContext, status);
|
|
197
|
+
}
|
|
198
|
+
|
|
190
199
|
// ── 0-pre. Drop any stale upgrade notice (#738, #743) ───────────────────────
|
|
191
200
|
// `upgrade-notice.json` is a transient handshake between launcher and
|
|
192
201
|
// statusline — it should never survive past the launcher run that wrote it.
|
|
@@ -201,6 +210,39 @@ try {
|
|
|
201
210
|
unlinkSync(join(mofloDir(projectRoot), 'upgrade-notice.json'));
|
|
202
211
|
} catch { /* non-fatal — file usually doesn't exist */ }
|
|
203
212
|
|
|
213
|
+
// ── 0-bootstrap-sentinel. Surface partial-bootstrap failures (#975) ─────────
|
|
214
|
+
// `scripts/post-install-bootstrap.mjs` writes `.moflo/bootstrap-failed.json`
|
|
215
|
+
// when its file-sync left some helpers unwritten (WSL DrvFs lock, EBUSY race,
|
|
216
|
+
// breaker open, …). Without this block the user has no in-session signal
|
|
217
|
+
// that the upgrade was incomplete — the launcher itself ran fine, but it's
|
|
218
|
+
// running from STALE files. Emit a high-visibility line pointing them at
|
|
219
|
+
// the healer so the silent failure mode that produced #975 can't recur.
|
|
220
|
+
// Section 3h below clears the sentinel after a clean re-sync.
|
|
221
|
+
//
|
|
222
|
+
// Also write a `kind: 'repair'` upgrade-notice so the statusline surfaces
|
|
223
|
+
// the prompt persistently — emitWarning lands on stderr only and Claude Code
|
|
224
|
+
// relays it once on session start; the statusline keeps the indicator in
|
|
225
|
+
// front of the user until §3h flips it to `completed` (sync resolved) or
|
|
226
|
+
// the 5-min in-progress TTL expires (visibility cap, statusline tests).
|
|
227
|
+
let bootstrapSentinelData = null;
|
|
228
|
+
const BOOTSTRAP_SENTINEL_PATH = resolve(mofloDir(projectRoot), 'bootstrap-failed.json');
|
|
229
|
+
let bootstrapNoticeContext = null;
|
|
230
|
+
try {
|
|
231
|
+
if (existsSync(BOOTSTRAP_SENTINEL_PATH)) {
|
|
232
|
+
bootstrapSentinelData = JSON.parse(readFileSync(BOOTSTRAP_SENTINEL_PATH, 'utf-8'));
|
|
233
|
+
const count = Array.isArray(bootstrapSentinelData?.failures) ? bootstrapSentinelData.failures.length : 0;
|
|
234
|
+
const sentinelVersion = bootstrapSentinelData?.mofloVersion || 'unknown';
|
|
235
|
+
emitWarning(
|
|
236
|
+
`Upgrade detected ${count} unfinished install step(s) from npm install (moflo@${sentinelVersion}). Run /healer --fix to repair.`,
|
|
237
|
+
);
|
|
238
|
+
bootstrapNoticeContext = { kind: 'repair', from: sentinelVersion, to: sentinelVersion };
|
|
239
|
+
buildAndWriteNotice(bootstrapNoticeContext, 'in-progress');
|
|
240
|
+
}
|
|
241
|
+
} catch (err) {
|
|
242
|
+
// Unreadable sentinel — leave it; healer will catch the underlying issue.
|
|
243
|
+
emitWarning(`bootstrap sentinel read skipped (${errMessage(err)})`);
|
|
244
|
+
}
|
|
245
|
+
|
|
204
246
|
// ── 0. Legacy whole-DB / directory migrations have been retired (#851) ─────
|
|
205
247
|
// LEGACY-V2: Pre-#851 the launcher renamed `.claude-flow/` → `.moflo/` and
|
|
206
248
|
// byte-copied `.swarm/memory.db` → `.moflo/moflo.db` on every session start.
|
|
@@ -518,65 +560,21 @@ try {
|
|
|
518
560
|
// pre-upgrade content forever because it was never recorded in the
|
|
519
561
|
// manifest. Surface failures on stderr — Claude Code captures
|
|
520
562
|
// session-start stderr as additionalContext so the user sees them too.
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
// with maxAttempts=1 so a sick host (AV mid-scan over node_modules)
|
|
529
|
-
// doesn't compound the wall-clock cost. Async setTimeout — never
|
|
530
|
-
// busy-wait in a session-start hook (CPU pinning during EBUSY backoff
|
|
531
|
-
// is the worst possible response when the OS is the bottleneck).
|
|
532
|
-
const TRANSIENT_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']);
|
|
533
|
-
const RETRY_BACKOFF_MS = [50, 200, 800];
|
|
534
|
-
const CIRCUIT_BREAK_THRESHOLD = 5;
|
|
535
|
-
let circuitOpen = false;
|
|
536
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
537
|
-
async function syncWithRetry(operation) {
|
|
538
|
-
const maxAttempts = circuitOpen ? 1 : RETRY_BACKOFF_MS.length + 1;
|
|
539
|
-
let lastErr = null;
|
|
540
|
-
let lastCode = null;
|
|
541
|
-
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
542
|
-
if (attempt > 0) await sleep(RETRY_BACKOFF_MS[attempt - 1]);
|
|
543
|
-
try {
|
|
544
|
-
operation();
|
|
545
|
-
return { ok: true };
|
|
546
|
-
} catch (err) {
|
|
547
|
-
lastErr = err;
|
|
548
|
-
lastCode = err && err.code ? err.code : null;
|
|
549
|
-
if (!TRANSIENT_CODES.has(lastCode)) break;
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
if (!circuitOpen && syncFailures.length + 1 >= CIRCUIT_BREAK_THRESHOLD) {
|
|
553
|
-
circuitOpen = true;
|
|
554
|
-
}
|
|
555
|
-
return { ok: false, err: lastErr, code: lastCode };
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
/** Copy src → dest if src exists, record `{path, size}` in manifest.
|
|
559
|
-
* Retries the transient error class with backoff (#854); failures land
|
|
560
|
-
* in syncFailures for the post-block stderr summary. The recorded size
|
|
561
|
-
* is read from the just-written destination so a subsequent launcher
|
|
562
|
-
* can detect content drift via size mismatch. */
|
|
563
|
+
//
|
|
564
|
+
// Retry/breaker semantics (#854) + hash-skip + atomic tmp+rename + post-
|
|
565
|
+
// write verify (#975) live in `./lib/file-sync.mjs`, shared with
|
|
566
|
+
// `scripts/post-install-bootstrap.mjs` so the npm-install path and the
|
|
567
|
+
// session-start path can't drift. The launcher records manifest entries
|
|
568
|
+
// on success via the onSuccess callback so currentManifest stays the
|
|
569
|
+
// single source of truth for next-session retired-file cleanup.
|
|
563
570
|
function recordManifestEntry(manifestKey, dest) {
|
|
564
571
|
let size = null;
|
|
565
572
|
try { size = statSync(dest).size; } catch { /* size left null — drift check still works on file-existence */ }
|
|
566
573
|
currentManifest.push({ path: manifestKey, size });
|
|
567
574
|
}
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
if (result.ok) {
|
|
572
|
-
recordManifestEntry(manifestKey, dest);
|
|
573
|
-
return;
|
|
574
|
-
}
|
|
575
|
-
const tail = TRANSIENT_CODES.has(result.code)
|
|
576
|
-
? ` (retried ${RETRY_BACKOFF_MS.length}× after ${result.code}${circuitOpen ? '; circuit open' : ''})`
|
|
577
|
-
: '';
|
|
578
|
-
syncFailures.push({ key: manifestKey, message: `${errMessage(result.err)}${tail}` });
|
|
579
|
-
}
|
|
575
|
+
const { syncFile, failures: syncFailures } = makeSyncer({
|
|
576
|
+
onSuccess: (key, dest) => recordManifestEntry(key, dest),
|
|
577
|
+
});
|
|
580
578
|
|
|
581
579
|
// Version changed — sync scripts from bin/
|
|
582
580
|
if (autoUpdateConfig.scripts) {
|
|
@@ -663,20 +661,11 @@ try {
|
|
|
663
661
|
for (const srcDir of helperSources) {
|
|
664
662
|
const src = resolve(srcDir, file);
|
|
665
663
|
if (existsSync(src)) {
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
const tail = TRANSIENT_CODES.has(code)
|
|
672
|
-
? ` (retried ${RETRY_BACKOFF_MS.length}× after ${code}${circuitOpen ? '; circuit open' : ''})`
|
|
673
|
-
: '';
|
|
674
|
-
syncFailures.push({
|
|
675
|
-
key: `.claude/helpers/${file}`,
|
|
676
|
-
message: `${errMessage(inlineResult.err)}${tail}`,
|
|
677
|
-
});
|
|
678
|
-
}
|
|
679
|
-
break; // first source wins
|
|
664
|
+
// First existing source wins — same semantics as before. The
|
|
665
|
+
// shared syncFile records manifest + collects failures the
|
|
666
|
+
// same way the rest of section 3 does.
|
|
667
|
+
await syncFile(src, dest, `.claude/helpers/${file}`);
|
|
668
|
+
break;
|
|
680
669
|
}
|
|
681
670
|
}
|
|
682
671
|
}
|
|
@@ -1314,6 +1303,15 @@ try {
|
|
|
1314
1303
|
'review defaults — model routing, sandbox, gates, hooks',
|
|
1315
1304
|
);
|
|
1316
1305
|
}
|
|
1306
|
+
} else {
|
|
1307
|
+
// Previously a silent skip — masked the actual reason consumers didn't
|
|
1308
|
+
// get a yaml after upgrading from pre-#895 versions. If neither template
|
|
1309
|
+
// path resolves the install is incomplete (partial extract, prune ate
|
|
1310
|
+
// the file, dogfood without a built dist/). Surface a healer hint so
|
|
1311
|
+
// the user can repair instead of staring at a missing yaml.
|
|
1312
|
+
emitWarning(
|
|
1313
|
+
`moflo.yaml create skipped — template not found at ${tplPaths.join(' or ')}; run 'flo doctor --fix' to repair`,
|
|
1314
|
+
);
|
|
1317
1315
|
}
|
|
1318
1316
|
}
|
|
1319
1317
|
} catch (err) {
|
|
@@ -1494,6 +1492,41 @@ if (pendingVersionStampWrite) {
|
|
|
1494
1492
|
}
|
|
1495
1493
|
}
|
|
1496
1494
|
|
|
1495
|
+
// ── 3h. Clear bootstrap sentinel if section-3 sync resolved it (#975) ───────
|
|
1496
|
+
// Section 3 above re-attempts the same file copies the bootstrap was supposed
|
|
1497
|
+
// to do, with the launcher's own retry logic. If after section 3 every file
|
|
1498
|
+
// the bootstrap reported as failed is now byte-identical to its source, the
|
|
1499
|
+
// previously-unfinished work is done — drop the sentinel so the warning at
|
|
1500
|
+
// section 0-bootstrap-sentinel doesn't fire on the next session. If anything
|
|
1501
|
+
// is still mismatched, leave the sentinel in place; healer / next session
|
|
1502
|
+
// will re-attempt.
|
|
1503
|
+
if (bootstrapSentinelData?.failures?.length > 0) {
|
|
1504
|
+
try {
|
|
1505
|
+
const allRepaired = bootstrapSentinelData.failures.every((f) => {
|
|
1506
|
+
if (!f?.src || !f?.dest) return false;
|
|
1507
|
+
// contentEqual already does the size short-circuit + SHA-1 hash and
|
|
1508
|
+
// is the same predicate the §3 sync used to decide whether to skip
|
|
1509
|
+
// the copy in the first place — reusing it here keeps "the sentinel
|
|
1510
|
+
// is clearable when bytes match" consistent across both code paths.
|
|
1511
|
+
return contentEqual(f.src, f.dest);
|
|
1512
|
+
});
|
|
1513
|
+
if (allRepaired) {
|
|
1514
|
+
unlinkSync(BOOTSTRAP_SENTINEL_PATH);
|
|
1515
|
+
emitMutation('cleared bootstrap-failed sentinel', 'previously-failed copies are now in sync');
|
|
1516
|
+
// Flip the §0-bootstrap-sentinel "in-progress" repair notice to
|
|
1517
|
+
// "completed" so the statusline shows the post-repair badge. Skip when
|
|
1518
|
+
// §3 already wrote its own upgradeNoticeContext (version bump / drift)
|
|
1519
|
+
// — that path runs the §3f writer with its own kind/version and we
|
|
1520
|
+
// shouldn't clobber it from here.
|
|
1521
|
+
if (bootstrapNoticeContext && !upgradeNoticeContext) {
|
|
1522
|
+
buildAndWriteNotice(bootstrapNoticeContext, 'completed');
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
} catch (err) {
|
|
1526
|
+
emitWarning(`bootstrap sentinel verify skipped (${errMessage(err)})`);
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1497
1530
|
// Bypasses emitMutation — framing, not a mutation, so it must not inflate the count.
|
|
1498
1531
|
if (mutationCount > 0) {
|
|
1499
1532
|
try {
|
|
@@ -10,6 +10,7 @@ import { execSync, exec } from 'child_process';
|
|
|
10
10
|
import { promisify } from 'util';
|
|
11
11
|
import { errorDetail } from '../shared/utils/error-detail.js';
|
|
12
12
|
import { output } from '../output.js';
|
|
13
|
+
import { fetchLatestNpmVersion, parseVersion, isOutdated } from './doctor-version.js';
|
|
13
14
|
const execAsync = promisify(exec);
|
|
14
15
|
/**
|
|
15
16
|
* Execute command asynchronously with proper environment inheritance.
|
|
@@ -132,11 +133,9 @@ export async function checkBuildTools() {
|
|
|
132
133
|
}
|
|
133
134
|
}
|
|
134
135
|
export async function checkClaudeCode() {
|
|
136
|
+
let installedRaw;
|
|
135
137
|
try {
|
|
136
|
-
|
|
137
|
-
const versionMatch = version.match(/v?(\d+\.\d+\.\d+)/);
|
|
138
|
-
const versionStr = versionMatch ? `v${versionMatch[1]}` : version;
|
|
139
|
-
return { name: 'Claude Code CLI', status: 'pass', message: versionStr };
|
|
138
|
+
installedRaw = await runCommand('claude --version');
|
|
140
139
|
}
|
|
141
140
|
catch (e) {
|
|
142
141
|
return {
|
|
@@ -146,6 +145,39 @@ export async function checkClaudeCode() {
|
|
|
146
145
|
fix: 'npm install -g @anthropic-ai/claude-code',
|
|
147
146
|
};
|
|
148
147
|
}
|
|
148
|
+
const versionMatch = installedRaw.match(/v?(\d+\.\d+\.\d+)/);
|
|
149
|
+
const installedClean = versionMatch ? versionMatch[1] : installedRaw.trim();
|
|
150
|
+
const installedDisplay = versionMatch ? `v${installedClean}` : installedRaw.trim();
|
|
151
|
+
// Compare against the latest published @anthropic-ai/claude-code on npm.
|
|
152
|
+
// Replaces the old `claude doctor` delegate (which became a TUI in CC 2.1.x
|
|
153
|
+
// and could not be parsed from a non-TTY child). Freshness was the only
|
|
154
|
+
// user-actionable signal that delegate produced; the auto-updater state +
|
|
155
|
+
// .mcp.json server health it also covered are already verified by the
|
|
156
|
+
// existing daemon and `MCP Servers` checks.
|
|
157
|
+
let latest;
|
|
158
|
+
try {
|
|
159
|
+
latest = await fetchLatestNpmVersion('@anthropic-ai/claude-code');
|
|
160
|
+
}
|
|
161
|
+
catch (e) {
|
|
162
|
+
return {
|
|
163
|
+
name: 'Claude Code CLI',
|
|
164
|
+
status: 'pass',
|
|
165
|
+
message: `${installedDisplay} (registry unreachable: ${errorDetail(e, { firstLineOnly: true })})`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
if (versionMatch && isOutdated(parseVersion(installedClean), parseVersion(latest))) {
|
|
169
|
+
return {
|
|
170
|
+
name: 'Claude Code CLI',
|
|
171
|
+
status: 'warn',
|
|
172
|
+
message: `${installedDisplay} (latest: v${latest})`,
|
|
173
|
+
fix: 'npm install -g @anthropic-ai/claude-code@latest',
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
name: 'Claude Code CLI',
|
|
178
|
+
status: 'pass',
|
|
179
|
+
message: `${installedDisplay} (up to date)`,
|
|
180
|
+
};
|
|
149
181
|
}
|
|
150
182
|
export async function installClaudeCode() {
|
|
151
183
|
try {
|
|
@@ -139,6 +139,23 @@ export async function autoFixCheck(check) {
|
|
|
139
139
|
return false;
|
|
140
140
|
}
|
|
141
141
|
},
|
|
142
|
+
// moflo.yaml auto-create. The session-start launcher already runs
|
|
143
|
+
// `ensureMofloYamlExists` (see bin/session-start-launcher.mjs § 3d-yaml-create,
|
|
144
|
+
// #895) but it can miss when the launcher itself was old at upgrade time —
|
|
145
|
+
// user reported moflo.yaml absent after npm-installing past 4.9.2. Mirror
|
|
146
|
+
// the same canonical create here so doctor --fix (and the /healer skill
|
|
147
|
+
// wrapping it) self-heal on the spot instead of waiting for the next
|
|
148
|
+
// SessionStart firing.
|
|
149
|
+
'moflo.yaml': async () => {
|
|
150
|
+
try {
|
|
151
|
+
const { ensureMofloYamlExists } = await import('../init/moflo-yaml-template.js');
|
|
152
|
+
const result = ensureMofloYamlExists(process.cwd());
|
|
153
|
+
return result.created || existsSync(join(process.cwd(), 'moflo.yaml'));
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
},
|
|
142
159
|
'Daemon Status': async () => {
|
|
143
160
|
const lockFile = join(process.cwd(), '.moflo', 'daemon.lock');
|
|
144
161
|
const pidFile = join(process.cwd(), '.moflo', 'daemon.pid');
|
|
@@ -66,11 +66,11 @@ function isOutdated(current, latest) {
|
|
|
66
66
|
// Manual AbortController (NOT AbortSignal.timeout): the latter leaves
|
|
67
67
|
// a libuv timer alive past process exit on Node 24 / Windows and trips
|
|
68
68
|
// an `!(handle->flags & UV_HANDLE_CLOSING)` assertion in src/win/async.c.
|
|
69
|
-
async function
|
|
69
|
+
export async function fetchLatestNpmVersion(pkg) {
|
|
70
70
|
const ac = new AbortController();
|
|
71
71
|
const timer = setTimeout(() => ac.abort(), REGISTRY_FETCH_TIMEOUT_MS);
|
|
72
72
|
try {
|
|
73
|
-
const response = await fetch(
|
|
73
|
+
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(pkg)}/latest`, {
|
|
74
74
|
headers: { Accept: 'application/json' },
|
|
75
75
|
signal: ac.signal,
|
|
76
76
|
});
|
|
@@ -86,6 +86,10 @@ async function fetchLatestVersion() {
|
|
|
86
86
|
clearTimeout(timer);
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
|
+
export { parseVersion, isOutdated };
|
|
90
|
+
async function fetchLatestVersion() {
|
|
91
|
+
return fetchLatestNpmVersion('moflo');
|
|
92
|
+
}
|
|
89
93
|
export async function checkVersionFreshness() {
|
|
90
94
|
try {
|
|
91
95
|
const currentVersion = readCurrentVersion();
|
package/dist/src/cli/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moflo",
|
|
3
|
-
"version": "4.9.
|
|
3
|
+
"version": "4.9.26",
|
|
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",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
|
85
85
|
"@typescript-eslint/parser": "^7.18.0",
|
|
86
86
|
"eslint": "^8.0.0",
|
|
87
|
-
"moflo": "^4.9.
|
|
87
|
+
"moflo": "^4.9.25",
|
|
88
88
|
"tsx": "^4.21.0",
|
|
89
89
|
"typescript": "^5.9.3",
|
|
90
90
|
"vitest": "^4.0.0"
|
|
@@ -37,15 +37,16 @@
|
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
39
|
import {
|
|
40
|
-
copyFileSync,
|
|
41
40
|
existsSync,
|
|
42
41
|
mkdirSync,
|
|
43
42
|
readdirSync,
|
|
44
43
|
readFileSync,
|
|
45
44
|
statSync,
|
|
45
|
+
writeFileSync,
|
|
46
46
|
} from 'node:fs';
|
|
47
47
|
import { dirname, join, resolve } from 'node:path';
|
|
48
48
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
49
|
+
import { errMessage, makeSyncer } from '../bin/lib/file-sync.mjs';
|
|
49
50
|
|
|
50
51
|
const SCRIPT_PATH = fileURLToPath(import.meta.url);
|
|
51
52
|
const MOFLO_ROOT = resolve(dirname(SCRIPT_PATH), '..');
|
|
@@ -87,68 +88,12 @@ export const SOURCE_HELPER_FILES = [
|
|
|
87
88
|
'post-commit',
|
|
88
89
|
];
|
|
89
90
|
|
|
90
|
-
// ── Retry + circuit breaker (#854
|
|
91
|
+
// ── Retry + atomic copy + circuit breaker (#854 / #975) ─────────────────────
|
|
91
92
|
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
const TRANSIENT_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']);
|
|
98
|
-
const RETRY_BACKOFF_MS = [50, 200, 800];
|
|
99
|
-
const CIRCUIT_BREAK_THRESHOLD = 5;
|
|
100
|
-
|
|
101
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
102
|
-
|
|
103
|
-
function makeSyncer() {
|
|
104
|
-
let circuitOpen = false;
|
|
105
|
-
const failures = [];
|
|
106
|
-
|
|
107
|
-
async function syncWithRetry(operation) {
|
|
108
|
-
const maxAttempts = circuitOpen ? 1 : RETRY_BACKOFF_MS.length + 1;
|
|
109
|
-
let lastErr = null;
|
|
110
|
-
let lastCode = null;
|
|
111
|
-
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
112
|
-
if (attempt > 0) await sleep(RETRY_BACKOFF_MS[attempt - 1]);
|
|
113
|
-
try {
|
|
114
|
-
operation();
|
|
115
|
-
return { ok: true };
|
|
116
|
-
} catch (err) {
|
|
117
|
-
lastErr = err;
|
|
118
|
-
lastCode = err && err.code ? err.code : null;
|
|
119
|
-
if (!TRANSIENT_CODES.has(lastCode)) break;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
if (!circuitOpen && failures.length + 1 >= CIRCUIT_BREAK_THRESHOLD) {
|
|
123
|
-
circuitOpen = true;
|
|
124
|
-
}
|
|
125
|
-
return { ok: false, err: lastErr, code: lastCode };
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
async function syncFile(src, dest, manifestKey) {
|
|
129
|
-
if (!existsSync(src)) return { skipped: true };
|
|
130
|
-
try {
|
|
131
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
132
|
-
} catch (err) {
|
|
133
|
-
failures.push({ key: manifestKey, message: errMessage(err) });
|
|
134
|
-
return { ok: false };
|
|
135
|
-
}
|
|
136
|
-
const result = await syncWithRetry(() => copyFileSync(src, dest));
|
|
137
|
-
if (result.ok) return { ok: true };
|
|
138
|
-
const tail = TRANSIENT_CODES.has(result.code)
|
|
139
|
-
? ` (retried ${RETRY_BACKOFF_MS.length}× after ${result.code}${circuitOpen ? '; circuit open' : ''})`
|
|
140
|
-
: '';
|
|
141
|
-
failures.push({ key: manifestKey, message: `${errMessage(result.err)}${tail}` });
|
|
142
|
-
return { ok: false };
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
return { syncFile, failures };
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function errMessage(err) {
|
|
149
|
-
if (!err) return 'unknown error';
|
|
150
|
-
return err.code ? `${err.code} ${err.message || ''}`.trim() : (err.message || String(err));
|
|
151
|
-
}
|
|
93
|
+
// Implementation lives in `bin/lib/file-sync.mjs` so the launcher's section 3
|
|
94
|
+
// shares the same hash-skip + atomic + verify path. Backoff [50,200,800]ms
|
|
95
|
+
// covers Windows EBUSY windows from concurrent helper invocation + AV scan;
|
|
96
|
+
// breaker opens after 5 distinct exhausted-retry failures.
|
|
152
97
|
|
|
153
98
|
// ── Project root discovery ──────────────────────────────────────────────────
|
|
154
99
|
//
|
|
@@ -294,6 +239,37 @@ export async function runBootstrap({
|
|
|
294
239
|
log(
|
|
295
240
|
`moflo: postinstall bootstrap left ${failures.length} file(s) unsynced — run 'flo doctor --fix' to repair:\n${sample}${more}`,
|
|
296
241
|
);
|
|
242
|
+
|
|
243
|
+
// #975: write a sentinel that session-start picks up so the user gets a
|
|
244
|
+
// visible "upgrade left work undone" prompt instead of a silent stale
|
|
245
|
+
// launcher. The bootstrap's stderr alone is buried in `npm install`
|
|
246
|
+
// output noise. Best-effort write — we never block install on this.
|
|
247
|
+
try {
|
|
248
|
+
const mofloDir = resolve(projectRoot, '.moflo');
|
|
249
|
+
mkdirSync(mofloDir, { recursive: true });
|
|
250
|
+
let mofloVersion = 'unknown';
|
|
251
|
+
try {
|
|
252
|
+
const pkgPath = resolve(mofloRoot, 'package.json');
|
|
253
|
+
if (existsSync(pkgPath)) {
|
|
254
|
+
mofloVersion = JSON.parse(readFileSync(pkgPath, 'utf-8')).version || 'unknown';
|
|
255
|
+
}
|
|
256
|
+
} catch { /* version is informational only */ }
|
|
257
|
+
const sentinel = {
|
|
258
|
+
timestamp: new Date().toISOString(),
|
|
259
|
+
mofloVersion,
|
|
260
|
+
failures: failures.map((f) => ({
|
|
261
|
+
key: f.key,
|
|
262
|
+
message: f.message,
|
|
263
|
+
src: f.src,
|
|
264
|
+
dest: f.dest,
|
|
265
|
+
})),
|
|
266
|
+
};
|
|
267
|
+
writeFileSync(
|
|
268
|
+
resolve(mofloDir, 'bootstrap-failed.json'),
|
|
269
|
+
JSON.stringify(sentinel, null, 2),
|
|
270
|
+
'utf-8',
|
|
271
|
+
);
|
|
272
|
+
} catch { /* sentinel write must not block install */ }
|
|
297
273
|
}
|
|
298
274
|
|
|
299
275
|
return { ran: true, synced, failed: failures.length, failures };
|