moflo 4.9.4 → 4.9.6
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.
|
@@ -44,6 +44,10 @@ flo-search "[query]" --namespace guidance # Semantic search
|
|
|
44
44
|
flo doctor --fix # Health check
|
|
45
45
|
\`\`\`
|
|
46
46
|
|
|
47
|
+
### After upgrading MoFlo
|
|
48
|
+
|
|
49
|
+
After running \`npm install moflo@*\` (or \`npm install\` that touches moflo), check \`.moflo/restart-pending.json\`. If it exists, surface the \`message\` field to the user verbatim (Claude Code can't pick up new hooks/MCP tools/launcher mid-session — they're only loaded at session-start), then delete the file.
|
|
50
|
+
|
|
47
51
|
### Full Reference
|
|
48
52
|
|
|
49
53
|
- **Subagents protocol:** \`.claude/guidance/shipped/moflo-subagents.md\`
|
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.6",
|
|
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",
|
|
@@ -40,14 +40,15 @@
|
|
|
40
40
|
"README.md",
|
|
41
41
|
"LICENSE",
|
|
42
42
|
"scripts/prune-native-binaries.mjs",
|
|
43
|
-
"scripts/post-install-notice.mjs"
|
|
43
|
+
"scripts/post-install-notice.mjs",
|
|
44
|
+
"scripts/post-install-bootstrap.mjs"
|
|
44
45
|
],
|
|
45
46
|
"scripts": {
|
|
46
47
|
"dev": "tsx watch src/cli/index.ts",
|
|
47
48
|
"prebuild": "node scripts/sync-version.mjs && node scripts/clean-dist.mjs",
|
|
48
49
|
"build": "tsc",
|
|
49
50
|
"prepublishOnly": "npm run build",
|
|
50
|
-
"postinstall": "node scripts/prune-native-binaries.mjs && node scripts/post-install-notice.mjs",
|
|
51
|
+
"postinstall": "node scripts/prune-native-binaries.mjs && node scripts/post-install-notice.mjs && node scripts/post-install-bootstrap.mjs",
|
|
51
52
|
"test": "node scripts/test-runner.mjs",
|
|
52
53
|
"test:ui": "vitest --ui",
|
|
53
54
|
"test:smoke": "node harness/consumer-smoke/run.mjs",
|
|
@@ -80,7 +81,7 @@
|
|
|
80
81
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
|
81
82
|
"@typescript-eslint/parser": "^7.18.0",
|
|
82
83
|
"eslint": "^8.0.0",
|
|
83
|
-
"moflo": "^4.9.
|
|
84
|
+
"moflo": "^4.9.5",
|
|
84
85
|
"tsx": "^4.21.0",
|
|
85
86
|
"typescript": "^5.9.3",
|
|
86
87
|
"vitest": "^4.0.0"
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Postinstall self-update bootstrap (#857).
|
|
4
|
+
*
|
|
5
|
+
* Problem this solves:
|
|
6
|
+
* The launcher in <consumer>/.claude/scripts/session-start-launcher.mjs
|
|
7
|
+
* is responsible for copying itself + helpers from node_modules/moflo/
|
|
8
|
+
* on every upgrade. Pre-#854 launchers wrap each copyFileSync in a bare
|
|
9
|
+
* `catch { /* non-fatal *\/ }` and can't reliably replace themselves on
|
|
10
|
+
* Windows under file-lock contention (EBUSY/EPERM/EACCES from concurrent
|
|
11
|
+
* helper invocation, AV real-time scan, npm verification handles).
|
|
12
|
+
*
|
|
13
|
+
* The fix for that lives in the new launcher (#854/#855), but the old
|
|
14
|
+
* launcher has to work to deploy the new one. It doesn't, and consumers
|
|
15
|
+
* stay stuck across 8+ version bumps until manually unstuck.
|
|
16
|
+
*
|
|
17
|
+
* Fix:
|
|
18
|
+
* This script runs at npm postinstall — driven by npm, not by the broken
|
|
19
|
+
* launcher — and copies bin/ scripts + helpers DIRECTLY into the
|
|
20
|
+
* consumer's .claude/scripts/ and .claude/helpers/. After the bootstrap
|
|
21
|
+
* runs, the next session-start launches the NEW launcher, which then
|
|
22
|
+
* handles the rest of the upgrade work (guidance sync, manifest, version
|
|
23
|
+
* stamp, daemon recycle, HNSW rebuild).
|
|
24
|
+
*
|
|
25
|
+
* The bootstrap only has to do enough to break the deadlock.
|
|
26
|
+
*
|
|
27
|
+
* The lists below MUST stay aligned with bin/session-start-launcher.mjs
|
|
28
|
+
* section 3 (the launcher's own sync). A unit test asserts list parity
|
|
29
|
+
* (mcp-tools-drift-guard pattern). See SCRIPT_FILES / BIN_HELPER_FILES /
|
|
30
|
+
* SOURCE_HELPER_FILES exports.
|
|
31
|
+
*
|
|
32
|
+
* Failure posture:
|
|
33
|
+
* - Surface per-file failures on stderr with `flo doctor --fix` advice
|
|
34
|
+
* - Skip silently if <consumer>/.claude doesn't exist (consumer hasn't
|
|
35
|
+
* run `flo init` yet — bootstrap is a no-op for first-time installs)
|
|
36
|
+
* - Never exit non-zero (postinstall failures block npm install)
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import {
|
|
40
|
+
copyFileSync,
|
|
41
|
+
existsSync,
|
|
42
|
+
mkdirSync,
|
|
43
|
+
readdirSync,
|
|
44
|
+
statSync,
|
|
45
|
+
} from 'node:fs';
|
|
46
|
+
import { dirname, join, resolve } from 'node:path';
|
|
47
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
48
|
+
|
|
49
|
+
const SCRIPT_PATH = fileURLToPath(import.meta.url);
|
|
50
|
+
const MOFLO_ROOT = resolve(dirname(SCRIPT_PATH), '..');
|
|
51
|
+
|
|
52
|
+
// ── Sync lists — keep in lockstep with bin/session-start-launcher.mjs §3 ─────
|
|
53
|
+
//
|
|
54
|
+
// Drift guard: tests/unit/post-install-bootstrap-drift.test.ts asserts these
|
|
55
|
+
// arrays match the launcher's section-3 sync lists by parsing both files.
|
|
56
|
+
|
|
57
|
+
export const SCRIPT_FILES = [
|
|
58
|
+
'hooks.mjs',
|
|
59
|
+
'session-start-launcher.mjs',
|
|
60
|
+
'index-guidance.mjs',
|
|
61
|
+
'build-embeddings.mjs',
|
|
62
|
+
'generate-code-map.mjs',
|
|
63
|
+
'semantic-search.mjs',
|
|
64
|
+
'index-tests.mjs',
|
|
65
|
+
'index-patterns.mjs',
|
|
66
|
+
'index-all.mjs',
|
|
67
|
+
'setup-project.mjs',
|
|
68
|
+
'run-migrations.mjs',
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
export const BIN_HELPER_FILES = [
|
|
72
|
+
'gate.cjs',
|
|
73
|
+
'gate-hook.mjs',
|
|
74
|
+
'prompt-hook.mjs',
|
|
75
|
+
'hook-handler.cjs',
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
export const SOURCE_HELPER_FILES = [
|
|
79
|
+
'auto-memory-hook.mjs',
|
|
80
|
+
'statusline.cjs',
|
|
81
|
+
'intelligence.cjs',
|
|
82
|
+
'subagent-start.cjs',
|
|
83
|
+
'subagent-bootstrap.json',
|
|
84
|
+
'pre-commit',
|
|
85
|
+
'post-commit',
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
// ── Retry + circuit breaker (#854 contract) ──────────────────────────────────
|
|
89
|
+
//
|
|
90
|
+
// Mirrors the launcher's syncWithRetry. Backoff [50,200,800]ms covers Windows
|
|
91
|
+
// EBUSY windows from concurrent helper invocation + AV real-time scan. The
|
|
92
|
+
// breaker opens after 5 distinct files exhaust retries so a sick host
|
|
93
|
+
// (AV mid-scan over node_modules) doesn't compound wall-clock cost.
|
|
94
|
+
|
|
95
|
+
const TRANSIENT_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']);
|
|
96
|
+
const RETRY_BACKOFF_MS = [50, 200, 800];
|
|
97
|
+
const CIRCUIT_BREAK_THRESHOLD = 5;
|
|
98
|
+
|
|
99
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
100
|
+
|
|
101
|
+
function makeSyncer() {
|
|
102
|
+
let circuitOpen = false;
|
|
103
|
+
const failures = [];
|
|
104
|
+
|
|
105
|
+
async function syncWithRetry(operation) {
|
|
106
|
+
const maxAttempts = circuitOpen ? 1 : RETRY_BACKOFF_MS.length + 1;
|
|
107
|
+
let lastErr = null;
|
|
108
|
+
let lastCode = null;
|
|
109
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
110
|
+
if (attempt > 0) await sleep(RETRY_BACKOFF_MS[attempt - 1]);
|
|
111
|
+
try {
|
|
112
|
+
operation();
|
|
113
|
+
return { ok: true };
|
|
114
|
+
} catch (err) {
|
|
115
|
+
lastErr = err;
|
|
116
|
+
lastCode = err && err.code ? err.code : null;
|
|
117
|
+
if (!TRANSIENT_CODES.has(lastCode)) break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!circuitOpen && failures.length + 1 >= CIRCUIT_BREAK_THRESHOLD) {
|
|
121
|
+
circuitOpen = true;
|
|
122
|
+
}
|
|
123
|
+
return { ok: false, err: lastErr, code: lastCode };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function syncFile(src, dest, manifestKey) {
|
|
127
|
+
if (!existsSync(src)) return { skipped: true };
|
|
128
|
+
try {
|
|
129
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
130
|
+
} catch (err) {
|
|
131
|
+
failures.push({ key: manifestKey, message: errMessage(err) });
|
|
132
|
+
return { ok: false };
|
|
133
|
+
}
|
|
134
|
+
const result = await syncWithRetry(() => copyFileSync(src, dest));
|
|
135
|
+
if (result.ok) return { ok: true };
|
|
136
|
+
const tail = TRANSIENT_CODES.has(result.code)
|
|
137
|
+
? ` (retried ${RETRY_BACKOFF_MS.length}× after ${result.code}${circuitOpen ? '; circuit open' : ''})`
|
|
138
|
+
: '';
|
|
139
|
+
failures.push({ key: manifestKey, message: `${errMessage(result.err)}${tail}` });
|
|
140
|
+
return { ok: false };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { syncFile, failures };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function errMessage(err) {
|
|
147
|
+
if (!err) return 'unknown error';
|
|
148
|
+
return err.code ? `${err.code} ${err.message || ''}`.trim() : (err.message || String(err));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Project root discovery ──────────────────────────────────────────────────
|
|
152
|
+
//
|
|
153
|
+
// npm sets INIT_CWD to the directory where the user originally ran
|
|
154
|
+
// `npm install`. That's the consumer's project root regardless of which
|
|
155
|
+
// package's postinstall is running. Falls back to cwd for direct execution.
|
|
156
|
+
|
|
157
|
+
function consumerProjectRoot() {
|
|
158
|
+
return process.env.INIT_CWD || process.cwd();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Main bootstrap ──────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
export async function runBootstrap({
|
|
164
|
+
projectRoot = consumerProjectRoot(),
|
|
165
|
+
mofloRoot = MOFLO_ROOT,
|
|
166
|
+
log = (msg) => process.stderr.write(`${msg}\n`),
|
|
167
|
+
} = {}) {
|
|
168
|
+
const claudeDir = resolve(projectRoot, '.claude');
|
|
169
|
+
if (!existsSync(claudeDir)) {
|
|
170
|
+
return { ran: false, reason: 'no-claude-dir' };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// moflo's own dogfood install: don't bootstrap into the source repo.
|
|
174
|
+
// The source repo's .claude/ IS the truth source — we'd be copying
|
|
175
|
+
// the very files we just built ON TOP of themselves, which on Windows
|
|
176
|
+
// hits the same file-lock issues we're trying to avoid.
|
|
177
|
+
if (resolve(projectRoot) === resolve(mofloRoot)) {
|
|
178
|
+
return { ran: false, reason: 'moflo-self-install' };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const binDir = resolve(mofloRoot, 'bin');
|
|
182
|
+
if (!existsSync(binDir)) {
|
|
183
|
+
return { ran: false, reason: 'no-bin-dir' };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const { syncFile, failures } = makeSyncer();
|
|
187
|
+
let synced = 0;
|
|
188
|
+
|
|
189
|
+
// 1. Top-level scripts → .claude/scripts/
|
|
190
|
+
const scriptsDir = resolve(claudeDir, 'scripts');
|
|
191
|
+
if (!existsSync(scriptsDir)) mkdirSync(scriptsDir, { recursive: true });
|
|
192
|
+
for (const file of SCRIPT_FILES) {
|
|
193
|
+
const result = await syncFile(
|
|
194
|
+
resolve(binDir, file),
|
|
195
|
+
resolve(scriptsDir, file),
|
|
196
|
+
`.claude/scripts/${file}`,
|
|
197
|
+
);
|
|
198
|
+
if (result.ok) synced++;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 2. bin/lib/ → .claude/scripts/lib/ (read entire dir)
|
|
202
|
+
const libSrcDir = resolve(binDir, 'lib');
|
|
203
|
+
const libDestDir = resolve(scriptsDir, 'lib');
|
|
204
|
+
if (existsSync(libSrcDir)) {
|
|
205
|
+
if (!existsSync(libDestDir)) mkdirSync(libDestDir, { recursive: true });
|
|
206
|
+
let libEntries;
|
|
207
|
+
try {
|
|
208
|
+
libEntries = readdirSync(libSrcDir);
|
|
209
|
+
} catch (err) {
|
|
210
|
+
log(`bootstrap: lib readdir failed (${errMessage(err)})`);
|
|
211
|
+
libEntries = [];
|
|
212
|
+
}
|
|
213
|
+
for (const file of libEntries) {
|
|
214
|
+
const src = resolve(libSrcDir, file);
|
|
215
|
+
try {
|
|
216
|
+
if (!statSync(src).isFile()) continue;
|
|
217
|
+
} catch { continue; }
|
|
218
|
+
const result = await syncFile(src, resolve(libDestDir, file), `.claude/scripts/lib/${file}`);
|
|
219
|
+
if (result.ok) synced++;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 3. bin/migrations/ → .claude/scripts/migrations/ (recursive)
|
|
224
|
+
const migrationsSrcDir = resolve(binDir, 'migrations');
|
|
225
|
+
const migrationsDestDir = resolve(scriptsDir, 'migrations');
|
|
226
|
+
if (existsSync(migrationsSrcDir)) {
|
|
227
|
+
if (!existsSync(migrationsDestDir)) mkdirSync(migrationsDestDir, { recursive: true });
|
|
228
|
+
let migEntries;
|
|
229
|
+
try {
|
|
230
|
+
migEntries = readdirSync(migrationsSrcDir, { recursive: true, withFileTypes: true });
|
|
231
|
+
} catch (err) {
|
|
232
|
+
log(`bootstrap: migrations readdir failed (${errMessage(err)})`);
|
|
233
|
+
migEntries = [];
|
|
234
|
+
}
|
|
235
|
+
for (const entry of migEntries) {
|
|
236
|
+
if (!entry.isFile()) continue;
|
|
237
|
+
const parent = entry.parentPath || entry.path || migrationsSrcDir;
|
|
238
|
+
const absSrc = resolve(parent, entry.name);
|
|
239
|
+
const rel = absSrc.slice(migrationsSrcDir.length + 1).split(/[\\/]/).join('/');
|
|
240
|
+
const result = await syncFile(absSrc, resolve(migrationsDestDir, rel), `.claude/scripts/migrations/${rel}`);
|
|
241
|
+
if (result.ok) synced++;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 4. bin/ helpers → .claude/helpers/
|
|
246
|
+
const helpersDir = resolve(claudeDir, 'helpers');
|
|
247
|
+
if (!existsSync(helpersDir)) mkdirSync(helpersDir, { recursive: true });
|
|
248
|
+
for (const file of BIN_HELPER_FILES) {
|
|
249
|
+
const result = await syncFile(
|
|
250
|
+
resolve(binDir, file),
|
|
251
|
+
resolve(helpersDir, file),
|
|
252
|
+
`.claude/helpers/${file}`,
|
|
253
|
+
);
|
|
254
|
+
if (result.ok) synced++;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 5. moflo's own .claude/helpers/ → consumer .claude/helpers/
|
|
258
|
+
// (these never lived in bin/ — they're shipped via .claude/helpers/** in files[])
|
|
259
|
+
const sourceHelpersDir = resolve(mofloRoot, '.claude/helpers');
|
|
260
|
+
if (existsSync(sourceHelpersDir)) {
|
|
261
|
+
for (const file of SOURCE_HELPER_FILES) {
|
|
262
|
+
const src = resolve(sourceHelpersDir, file);
|
|
263
|
+
if (!existsSync(src)) continue;
|
|
264
|
+
const result = await syncFile(src, resolve(helpersDir, file), `.claude/helpers/${file}`);
|
|
265
|
+
if (result.ok) synced++;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Surface failures so npm log + Claude relay catches them, with the same
|
|
270
|
+
// healer advice the launcher uses.
|
|
271
|
+
if (failures.length > 0) {
|
|
272
|
+
const sample = failures.slice(0, 5).map((f) => ` - ${f.key}: ${f.message}`).join('\n');
|
|
273
|
+
const more = failures.length > 5 ? `\n …and ${failures.length - 5} more` : '';
|
|
274
|
+
log(
|
|
275
|
+
`moflo: postinstall bootstrap left ${failures.length} file(s) unsynced — run 'flo doctor --fix' to repair:\n${sample}${more}`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return { ran: true, synced, failed: failures.length, failures };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ── Entry point ─────────────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
285
|
+
runBootstrap()
|
|
286
|
+
.catch((err) => {
|
|
287
|
+
// Never block install. Log and exit 0.
|
|
288
|
+
process.stderr.write(`moflo: bootstrap failed (${errMessage(err)})\n`);
|
|
289
|
+
})
|
|
290
|
+
.finally(() => process.exit(0));
|
|
291
|
+
}
|
|
@@ -1,25 +1,37 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Postinstall restart-nudge
|
|
3
|
+
* Postinstall restart-nudge — drops a notice file Claude reads after upgrade.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* Problem this solves:
|
|
6
|
+
* When `npm install` runs inside Claude Code (typically because the user
|
|
7
|
+
* asked Claude to upgrade moflo), the just-installed bits are on disk but
|
|
8
|
+
* the running session still has the OLD launcher, hooks, MCP server, and
|
|
9
|
+
* statusline loaded. The launcher only re-reads them on the NEXT
|
|
10
|
+
* session-start — the upgrade is inert until the user restarts.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* The original v4.9.4 design printed a banner to stdout, expecting npm to
|
|
13
|
+
* relay it. It does not: npm 7+ defaults to `foreground-scripts: false`
|
|
14
|
+
* and captures install-script stdout/stderr into log files. The banner
|
|
15
|
+
* never reached Claude. (#856.)
|
|
16
|
+
*
|
|
17
|
+
* Fix:
|
|
18
|
+
* This script drops `<project>/.moflo/restart-pending.json` on every
|
|
19
|
+
* relevant install. Claude is instructed (via the moflo CLAUDE.md
|
|
20
|
+
* injection) to read + surface + delete the file after running
|
|
21
|
+
* `npm install moflo@*`. No reliance on npm cooperating with stdout.
|
|
22
|
+
*
|
|
23
|
+
* The banner is still printed to stdout for the rare `--foreground-scripts`
|
|
24
|
+
* user, and the dedup tracker is preserved so repeat postinstalls of the
|
|
25
|
+
* same version don't double-write the notice.
|
|
26
|
+
*
|
|
27
|
+
* Files written:
|
|
28
|
+
* - .moflo/restart-pending.json (the payload Claude reads)
|
|
29
|
+
* - .moflo/last-install-banner.json (dedup tracker, version-stamped)
|
|
15
30
|
*
|
|
16
31
|
* Gating:
|
|
17
|
-
* - Only
|
|
18
|
-
*
|
|
19
|
-
* - Dedupes by version:
|
|
20
|
-
* pair, so unrelated `npm install` runs that re-trigger postinstall
|
|
21
|
-
* don't re-spam the banner. Tracker lives at
|
|
22
|
-
* `<project>/.moflo/last-install-banner.json`.
|
|
32
|
+
* - Only fires when CLAUDE_PROJECT_DIR or CLAUDECODE is set; non-Claude
|
|
33
|
+
* installs and CI stay silent.
|
|
34
|
+
* - Dedupes by version: same (project, version) pair won't re-write.
|
|
23
35
|
*
|
|
24
36
|
* Failure posture: never blocks an install. Errors are swallowed; exit 0.
|
|
25
37
|
*/
|
|
@@ -52,7 +64,7 @@ function installedVersion() {
|
|
|
52
64
|
}
|
|
53
65
|
}
|
|
54
66
|
|
|
55
|
-
function
|
|
67
|
+
function readTrackedVersion(trackerPath) {
|
|
56
68
|
if (!existsSync(trackerPath)) return null;
|
|
57
69
|
try {
|
|
58
70
|
return JSON.parse(readFileSync(trackerPath, 'utf-8')).version || null;
|
|
@@ -61,33 +73,36 @@ function readLastBanner(trackerPath) {
|
|
|
61
73
|
}
|
|
62
74
|
}
|
|
63
75
|
|
|
64
|
-
function
|
|
76
|
+
function writeJson(filePath, payload) {
|
|
65
77
|
try {
|
|
66
|
-
mkdirSync(dirname(
|
|
67
|
-
writeFileSync(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
78
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
79
|
+
writeFileSync(filePath, JSON.stringify(payload, null, 2));
|
|
80
|
+
return true;
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
72
84
|
}
|
|
73
85
|
|
|
74
|
-
function
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
'═══════════════════════════════════════════════════════════════════',
|
|
80
|
-
` MoFlo ${version} installed.`,
|
|
86
|
+
function buildMessage(version) {
|
|
87
|
+
// Plain-text payload Claude relays verbatim. Names Claude Code explicitly
|
|
88
|
+
// so the assistant frames it as a restart instruction, not a log line.
|
|
89
|
+
return [
|
|
90
|
+
`MoFlo ${version} installed.`,
|
|
81
91
|
'',
|
|
82
|
-
'
|
|
92
|
+
'Please restart Claude Code to load the new MoFlo.',
|
|
83
93
|
'',
|
|
84
|
-
'
|
|
85
|
-
'
|
|
86
|
-
'
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
94
|
+
'Hooks, MCP tools, statusline, and the session-start launcher are',
|
|
95
|
+
'loaded once at session-start — the running session is still on the',
|
|
96
|
+
'previous moflo until you exit and reopen Claude Code.',
|
|
97
|
+
].join('\n');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function printBanner(version, message) {
|
|
101
|
+
// Stdout fallback for --foreground-scripts users. With npm's default
|
|
102
|
+
// config this output is captured and never seen — that's why the notice
|
|
103
|
+
// file exists. Kept anyway because it costs nothing.
|
|
104
|
+
const border = '═'.repeat(67);
|
|
105
|
+
process.stdout.write(`\n${border}\n MoFlo ${version} installed.\n\n ⚠ ${message.split('\n')[2]}\n${border}\n\n`);
|
|
91
106
|
}
|
|
92
107
|
|
|
93
108
|
function run() {
|
|
@@ -98,12 +113,19 @@ function run() {
|
|
|
98
113
|
|
|
99
114
|
const projectRoot = consumerProjectRoot();
|
|
100
115
|
const trackerPath = join(projectRoot, '.moflo', 'last-install-banner.json');
|
|
101
|
-
const
|
|
116
|
+
const noticePath = join(projectRoot, '.moflo', 'restart-pending.json');
|
|
117
|
+
|
|
118
|
+
const lastShown = readTrackedVersion(trackerPath);
|
|
102
119
|
if (lastShown === version) return { fired: false, reason: 'already-shown' };
|
|
103
120
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
121
|
+
const writtenAt = new Date().toISOString();
|
|
122
|
+
const message = buildMessage(version);
|
|
123
|
+
|
|
124
|
+
writeJson(noticePath, { version, writtenAt, message });
|
|
125
|
+
writeJson(trackerPath, { version, shownAt: writtenAt });
|
|
126
|
+
|
|
127
|
+
printBanner(version, message);
|
|
128
|
+
return { fired: true, version, noticePath };
|
|
107
129
|
}
|
|
108
130
|
|
|
109
131
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|