@mutmutco/hub 4.4.6 → 4.4.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.
Files changed (2) hide show
  1. package/dist/index.cjs +478 -237
  2. package/package.json +4 -1
package/dist/index.cjs CHANGED
@@ -22,12 +22,15 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
22
22
  var index_exports = {};
23
23
  __export(index_exports, {
24
24
  ACCENT: () => ACCENT,
25
+ FACE_PRODUCT: () => FACE_PRODUCT,
25
26
  MM_GREEN: () => MM_GREEN,
26
27
  RESET: () => RESET,
27
28
  accent: () => accent,
28
29
  acquireSharedDocLock: () => acquireSharedDocLock,
30
+ attentionRows: () => attentionRows,
29
31
  brandLine: () => brandLine,
30
32
  buildWatcherCommand: () => buildWatcherCommand,
33
+ checkAtomicity: () => checkAtomicity,
31
34
  checklistLine: () => checklistLine,
32
35
  claudeArm: () => claudeArm,
33
36
  claudeHostEvidence: () => claudeHostEvidence,
@@ -67,7 +70,6 @@ __export(index_exports, {
67
70
  progressiveRow: () => progressiveRow,
68
71
  readLaunchTarget: () => readLaunchTarget,
69
72
  readPending: () => readPending,
70
- receipt: () => receipt,
71
73
  receiptText: () => receiptText,
72
74
  reconcile: () => reconcile,
73
75
  reconcileCursorUserHooks: () => reconcileCursorUserHooks,
@@ -82,17 +84,15 @@ __export(index_exports, {
82
84
  sharedDocLockTimeoutReceipt: () => sharedDocLockTimeoutReceipt,
83
85
  shippedTargetFrom: () => shippedTargetFrom,
84
86
  shouldHandOff: () => shouldHandOff,
85
- signOff: () => signOff,
86
- startSpinner: () => startSpinner,
87
87
  surfaceArmPlan: () => surfaceArmPlan,
88
88
  systemdServiceUnit: () => systemdServiceUnit,
89
89
  systemdTimerUnit: () => systemdTimerUnit,
90
90
  taskXml: () => taskXml,
91
91
  updateHeader: () => updateHeader,
92
+ updaterFace: () => updaterFace,
92
93
  watcherLockPath: () => watcherLockPath,
93
- welcome: () => welcome,
94
94
  withSharedDocLock: () => withSharedDocLock,
95
- wrapWords: () => wrapWords,
95
+ wrapWords: () => wrapWords2,
96
96
  writeLaunchTarget: () => writeLaunchTarget,
97
97
  writePending: () => writePending
98
98
  });
@@ -101,6 +101,206 @@ var import_node_child_process9 = require("node:child_process");
101
101
  var import_node_fs18 = require("node:fs");
102
102
  var import_node_path13 = require("node:path");
103
103
 
104
+ // node_modules/@mutmutco/installer-face/dist/index.js
105
+ var PRODUCTS = Object.freeze({
106
+ "mm-strategy": Object.freeze({
107
+ name: "MM Strategy",
108
+ accent: "38;2;249;115;22",
109
+ warm: "Welcome. Let's set up MM Strategy \u2014 about a minute."
110
+ }),
111
+ "mmi-hub": Object.freeze({
112
+ name: "mmi-hub",
113
+ accent: "38;2;125;211;252",
114
+ warm: "Welcome back. Checking your surfaces\u2026"
115
+ }),
116
+ "jerv-hub": Object.freeze({
117
+ name: "jerv-hub",
118
+ accent: "38;2;248;113;113",
119
+ warm: "Welcome back. Checking your surfaces\u2026"
120
+ }),
121
+ jervcode: Object.freeze({
122
+ name: "JervCode",
123
+ accent: "38;2;192;132;252",
124
+ warm: "Welcome back. Keeping JervCode current\u2026"
125
+ })
126
+ });
127
+ function identityFor(product) {
128
+ const identity = PRODUCTS[product];
129
+ if (!identity) {
130
+ throw new Error(`unknown product ${JSON.stringify(product)} \u2014 add it to installer/face/src/products.ts rather than composing a greeting locally (known: ${Object.keys(PRODUCTS).join(", ")})`);
131
+ }
132
+ return identity;
133
+ }
134
+ var GLYPH = Object.freeze({
135
+ diamond: "\u25C6",
136
+ hollow: "\u25C7",
137
+ bar: "\u2502",
138
+ check: "\u2714",
139
+ cross: "\u2716",
140
+ dot: "\u25CF",
141
+ boxTop: "\u256D",
142
+ boxTopEnd: "\u256E",
143
+ boxBottom: "\u2570",
144
+ boxBottomEnd: "\u256F",
145
+ rule: "\u2500"
146
+ });
147
+ var PALETTE = Object.freeze({
148
+ green: "38;2;31;209;138",
149
+ ink: "38;2;237;237;237",
150
+ muted: "38;2;150;150;150",
151
+ line: "38;2;42;42;42"
152
+ });
153
+ var TITLE_COLUMN = 6;
154
+ var ANSI = /\u001b\[[0-9;]*m/g;
155
+ function visibleWidth(text) {
156
+ return [...String(text).replace(ANSI, "")].length;
157
+ }
158
+ function faceWidth(columns) {
159
+ const raw = Number(columns);
160
+ return Math.max(40, Math.min(100, Number.isFinite(raw) && raw > 0 ? raw : 100));
161
+ }
162
+ function wrapWords(text, width) {
163
+ const limit = Math.max(8, Math.floor(width));
164
+ const lines = [];
165
+ let line = "";
166
+ for (const word of String(text).split(/\s+/u).filter(Boolean)) {
167
+ let token = word;
168
+ while (visibleWidth(token) > limit) {
169
+ if (line) {
170
+ lines.push(line);
171
+ line = "";
172
+ }
173
+ lines.push([...token].slice(0, limit).join(""));
174
+ token = [...token].slice(limit).join("");
175
+ }
176
+ if (!line) line = token;
177
+ else if (visibleWidth(line) + 1 + visibleWidth(token) <= limit) line += ` ${token}`;
178
+ else {
179
+ lines.push(line);
180
+ line = token;
181
+ }
182
+ }
183
+ if (line) lines.push(line);
184
+ return lines.length ? lines : [""];
185
+ }
186
+ function createFace({ product, color = false, columns }) {
187
+ const identity = identityFor(product);
188
+ const width = faceWidth(columns);
189
+ const paint = (sgr, text) => color ? `\x1B[${sgr}m${text}\x1B[0m` : String(text);
190
+ const bar = () => paint(PALETTE.muted, GLYPH.bar);
191
+ const indent = " ".repeat(TITLE_COLUMN - 1);
192
+ const welcome = () => [
193
+ `${paint(identity.accent, GLYPH.diamond)} ${paint(identity.accent, identity.name)} \u2014 Mutatis Mutandis`,
194
+ bar(),
195
+ `${bar()} ${identity.warm}`,
196
+ bar()
197
+ ];
198
+ const step = (title, seconds = null, kind = "ok") => {
199
+ const glyph = kind === "fail" ? paint(identity.accent, GLYPH.cross) : kind === "note" ? paint(PALETTE.muted, GLYPH.hollow) : paint(PALETTE.green, GLYPH.check);
200
+ const time = seconds === null || seconds === void 0 ? "" : paint(PALETTE.muted, `${Math.max(0, Math.round(seconds))}s`);
201
+ const column = Math.min(44, Math.max(0, width - 8));
202
+ const reserved = time ? 6 : 0;
203
+ const [first, ...rest] = wrapWords(title, width - TITLE_COLUMN - reserved);
204
+ const head = `${bar()} ${glyph} ${first}`;
205
+ const pad = Math.max(1, column - visibleWidth(head));
206
+ return [time ? `${head}${" ".repeat(pad)}${time}` : head, ...rest.map((line) => `${bar()}${indent}${line}`)].join("\n");
207
+ };
208
+ const relay = (text) => String(text).split("\n").map((line) => line.trim() === "" ? bar() : `${bar()}${indent}${line}`).join("\n");
209
+ const receipt = (lines) => {
210
+ const body = (Array.isArray(lines) ? lines : String(lines).split("\n")).flatMap((raw) => {
211
+ const line = String(raw);
212
+ if (visibleWidth(line) <= width - 6) return [line];
213
+ const lead = /^\s*/u.exec(line)?.[0] ?? "";
214
+ return wrapWords(line, width - 6 - lead.length).map((part) => `${lead}${part}`);
215
+ });
216
+ const content = Math.min(width - 6, Math.max(...body.map(visibleWidth)));
217
+ const rule = paint(identity.accent, GLYPH.rule.repeat(content + 4));
218
+ const frame = (left, right) => `${paint(identity.accent, left)}${rule}${paint(identity.accent, right)}`;
219
+ return [
220
+ frame(GLYPH.boxTop, GLYPH.boxTopEnd),
221
+ ...body.map((line) => `${paint(identity.accent, GLYPH.bar)} ${line}${" ".repeat(Math.max(0, content - visibleWidth(line)))} ${paint(identity.accent, GLYPH.bar)}`),
222
+ frame(GLYPH.boxBottom, GLYPH.boxBottomEnd)
223
+ ];
224
+ };
225
+ const signOff = () => `${paint(identity.accent, GLYPH.diamond)} ${paint(identity.accent, `${identity.name} \xB7 Mutatis Mutandis`)}`;
226
+ const refusal = (message3) => `${bar()} ${paint(identity.accent, GLYPH.cross)} ${message3}`;
227
+ return { identity, width, welcome, step, relay, receipt, signOff, refusal, paint };
228
+ }
229
+ var RELAY_INDENT = " ".repeat(TITLE_COLUMN - 1);
230
+ var SEPARATOR = "\xB7";
231
+ var subjectOf = (row) => row.aspect ? `${row.surface} ${row.aspect}` : row.surface;
232
+ var groupKey = (row) => `${subjectOf(row)}\0${row.state}\0${row.mark ?? ""}\0${row.note ?? ""}`;
233
+ function renderRows(face, rows) {
234
+ if (rows.length === 0) return [];
235
+ const groups = /* @__PURE__ */ new Map();
236
+ for (const row of rows) {
237
+ const key = groupKey(row);
238
+ const found = groups.get(key);
239
+ if (found) {
240
+ if (row.instance) found.instances.push(row.instance);
241
+ } else {
242
+ groups.set(key, { row, instances: row.instance ? [row.instance] : [] });
243
+ }
244
+ }
245
+ const subjects = [...groups.values()].map(({ row, instances }) => instances.length > 1 ? subjectOf(row) : [subjectOf(row), instances[0]].filter(Boolean).join(` ${SEPARATOR} `));
246
+ const column = Math.min(Math.max(...subjects.map(visibleWidth)), Math.max(12, face.width - 24));
247
+ const lines = [];
248
+ for (const [index, { row, instances }] of [...groups.values()].entries()) {
249
+ const subject = subjects[index];
250
+ const tail2 = instances.length > 1 ? `${row.state} ${SEPARATOR} ${instances.join(", ")}` : row.state;
251
+ const body = `${clip(subject, column).padEnd(column)} ${row.mark ? `${markGlyph(face, row.mark)} ` : ""}${tail2}`;
252
+ lines.push(...face.relay(body).split("\n"));
253
+ if (row.note) {
254
+ for (const part of wrapWords(row.note, Math.max(16, face.width - column - 10))) {
255
+ lines.push(...face.relay(`${" ".repeat(column)} ${part}`).split("\n"));
256
+ }
257
+ }
258
+ }
259
+ return lines;
260
+ }
261
+ function markGlyph(face, kind) {
262
+ const glyph = kind === "fail" ? GLYPH.cross : kind === "note" ? GLYPH.hollow : GLYPH.check;
263
+ return face.paint(kind === "fail" ? face.identity.accent : kind === "note" ? "38;2;150;150;150" : "38;2;31;209;138", glyph);
264
+ }
265
+ function clip(text, width) {
266
+ if (visibleWidth(text) <= width) return text;
267
+ return `\u2026${[...text].slice(-(width - 1)).join("")}`;
268
+ }
269
+ var FRAMES = ["\u25D2", "\u25D0", "\u25D3", "\u25D1"];
270
+ function createSpinner(face, { animate, stream = process.stderr, intervalMs = 90 }) {
271
+ let timer = null;
272
+ let text = "";
273
+ let frame = 0;
274
+ const clear = () => {
275
+ if (animate) stream.write("\r\x1B[2K");
276
+ };
277
+ const draw = () => {
278
+ if (!animate) return;
279
+ const line = face.step(text, null, "note").split("\n")[0].replace(GLYPH.hollow, FRAMES[frame % FRAMES.length]);
280
+ stream.write(`\r\x1B[2K${line}`);
281
+ frame += 1;
282
+ };
283
+ return {
284
+ start(next) {
285
+ text = next;
286
+ frame = 0;
287
+ draw();
288
+ if (animate && !timer) timer = setInterval(draw, intervalMs).unref();
289
+ },
290
+ say(next) {
291
+ text = next;
292
+ draw();
293
+ },
294
+ stop() {
295
+ if (timer) clearInterval(timer);
296
+ timer = null;
297
+ clear();
298
+ }
299
+ };
300
+ }
301
+ var SPINNER_FRAMES = Object.freeze([...FRAMES]);
302
+ var ALLOWED = new Set(Object.values(GLYPH));
303
+
104
304
  // src/state.ts
105
305
  var import_node_fs = require("node:fs");
106
306
  var import_node_os = require("node:os");
@@ -165,11 +365,12 @@ function withLease(path, fn, waitMs = LEASE_WAIT_MS) {
165
365
  for (; ; ) {
166
366
  const attempt = tryAcquire(path);
167
367
  if (typeof attempt === "object") {
168
- try {
169
- return fn();
170
- } finally {
171
- attempt.release();
368
+ const result = fn();
369
+ if (result && typeof result.then === "function") {
370
+ return result.finally(() => attempt.release());
172
371
  }
372
+ attempt.release();
373
+ return result;
173
374
  }
174
375
  if (Date.now() >= deadline) {
175
376
  throw new UpdaterBusy(`another updater holds ${path} (waited ${waitMs}ms) \u2014 deferring`);
@@ -188,23 +389,29 @@ function appendJournal(path, event) {
188
389
  return false;
189
390
  }
190
391
  }
191
- function journalHighWater(path, surface) {
392
+ function journalHighWater(path, surface, events) {
192
393
  let high = null;
394
+ for (const event of events ?? parseJournal(path)) {
395
+ if (event.dryRun) continue;
396
+ if (event.surface === surface && event.verdict === "ok" && event.to && (!high || compareSemver(event.to, high) > 0)) {
397
+ high = event.to;
398
+ }
399
+ }
400
+ return high;
401
+ }
402
+ function parseJournal(path) {
403
+ const events = [];
193
404
  try {
194
405
  for (const line of (0, import_node_fs.readFileSync)(path, "utf8").split("\n")) {
195
406
  if (!line.trim()) continue;
196
407
  try {
197
- const event = JSON.parse(line);
198
- if (event.dryRun) continue;
199
- if (event.surface === surface && event.verdict === "ok" && event.to && (!high || compareSemver(event.to, high) > 0)) {
200
- high = event.to;
201
- }
408
+ events.push(JSON.parse(line));
202
409
  } catch {
203
410
  }
204
411
  }
205
412
  } catch {
206
413
  }
207
- return high;
414
+ return events;
208
415
  }
209
416
  function journalSurfaceRepeatedFail(path, surface, currentRun) {
210
417
  let lastVerdict = null;
@@ -255,6 +462,9 @@ function fileExists(path) {
255
462
  return (0, import_node_fs.existsSync)(path);
256
463
  }
257
464
 
465
+ // src/reconcile.ts
466
+ var import_node_perf_hooks2 = require("node:perf_hooks");
467
+
258
468
  // src/npm.ts
259
469
  var import_node_child_process = require("node:child_process");
260
470
  var import_node_fs2 = require("node:fs");
@@ -295,10 +505,40 @@ function runNpm(args, env = process.env, options = {}) {
295
505
  const result = npm.kind === "node-script" ? (0, import_node_child_process.spawnSync)(process.execPath, [npm.script, ...args], { encoding: "utf8", env, cwd: options.cwd, windowsHide: true }) : (0, import_node_child_process.spawnSync)(npm.command, args, { encoding: "utf8", env, cwd: options.cwd, shell: process.platform === "win32", windowsHide: true });
296
506
  return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
297
507
  }
508
+ function runNpmAsync(args, env = process.env, options = {}) {
509
+ const npm = resolveNpm(env);
510
+ const [file, argv, shell] = npm.kind === "node-script" ? [process.execPath, [npm.script, ...args], false] : [npm.command, args, process.platform === "win32"];
511
+ return new Promise((resolve) => {
512
+ let stdout = "";
513
+ let stderr = "";
514
+ let child;
515
+ try {
516
+ child = (0, import_node_child_process.spawn)(file, argv, { env, cwd: options.cwd, windowsHide: true, shell });
517
+ } catch (error) {
518
+ resolve({ status: null, stdout, stderr: String(error?.message ?? error) });
519
+ return;
520
+ }
521
+ child.stdout?.setEncoding("utf8");
522
+ child.stderr?.setEncoding("utf8");
523
+ child.stdout?.on("data", (chunk) => {
524
+ stdout += chunk;
525
+ });
526
+ child.stderr?.on("data", (chunk) => {
527
+ stderr += chunk;
528
+ });
529
+ child.on("error", (error) => resolve({ status: null, stdout, stderr: String(error?.message ?? error) }));
530
+ child.on("close", (status) => resolve({ status, stdout, stderr }));
531
+ });
532
+ }
533
+ var globalRootMemo = /* @__PURE__ */ new WeakMap();
298
534
  function globalRoot(env = process.env) {
535
+ const key = env;
536
+ const memoized = globalRootMemo.get(key);
537
+ if (memoized !== void 0) return memoized;
299
538
  const root = runNpm(["root", "-g"], env);
300
- if (root.status !== 0 || !root.stdout.trim()) return null;
301
- return root.stdout.trim();
539
+ const resolved = root.status !== 0 || !root.stdout.trim() ? null : root.stdout.trim();
540
+ globalRootMemo.set(key, resolved);
541
+ return resolved;
302
542
  }
303
543
  function npmView(spec, field, env = process.env) {
304
544
  const args = ["view", spec, "--json"];
@@ -318,8 +558,15 @@ function parseNpmDistIntegrity(value) {
318
558
  return typeof only === "string" && only.length > 0 ? only : null;
319
559
  }
320
560
  var npmStringField = parseNpmDistIntegrity;
321
- function npmDistIntegrity(spec, env = process.env) {
322
- return parseNpmDistIntegrity(npmView(spec, "dist.integrity", env));
561
+ async function npmDistIntegrityAsync(spec, env = process.env) {
562
+ const args = ["view", spec, "dist.integrity", "--json"];
563
+ const result = await runNpmAsync(args, env);
564
+ if (result.status !== 0 || !result.stdout.trim()) return null;
565
+ try {
566
+ return parseNpmDistIntegrity(JSON.parse(result.stdout));
567
+ } catch {
568
+ return null;
569
+ }
323
570
  }
324
571
  function npmVersions(packageName, env = process.env) {
325
572
  const versions = npmView(packageName, "versions", env);
@@ -399,38 +646,60 @@ function gateCandidate(cachePath, repoUrl, candidate) {
399
646
  }
400
647
  return { ok: true, tagCommit: commit, bom };
401
648
  }
402
- function checkAtomicity(cachePath, tagCommit, bom, candidate, npm) {
403
- const rows = [];
404
- for (const row of npmPackRows(bom)) {
649
+ var ATOMICITY_CONCURRENCY = 4;
650
+ async function mapBounded(items, bound, fn) {
651
+ const results = new Array(items.length);
652
+ let next = 0;
653
+ const workers = Array.from({ length: Math.min(bound, items.length) }, async () => {
654
+ for (; ; ) {
655
+ const index = next++;
656
+ if (index >= items.length) return;
657
+ results[index] = await fn(items[index], index);
658
+ }
659
+ });
660
+ await Promise.all(workers);
661
+ return results;
662
+ }
663
+ async function checkAtomicity(cachePath, tagCommit, bom, candidate, npm) {
664
+ const bomRows = npmPackRows(bom);
665
+ const verdicts = new Array(bomRows.length).fill(null);
666
+ const pending = [];
667
+ bomRows.forEach((row, index) => {
405
668
  if (row.version !== candidate) {
406
- rows.push({ id: row.id, ok: false, detail: `BOM row at ${row.version}, candidate ${candidate}` });
407
- continue;
669
+ verdicts[index] = { id: row.id, ok: false, detail: `BOM row at ${row.version}, candidate ${candidate}` };
670
+ return;
408
671
  }
409
672
  const manifestText = showFile(cachePath, tagCommit, `${row.identity.packagePath}/package.json`);
410
673
  let name;
411
- let manifestVersion;
674
+ let manifestVersion2;
412
675
  try {
413
676
  const manifest2 = JSON.parse(manifestText ?? "");
414
677
  name = manifest2.name;
415
- manifestVersion = manifest2.version;
678
+ manifestVersion2 = manifest2.version;
416
679
  } catch {
417
680
  }
418
- if (!name || manifestVersion !== candidate) {
419
- rows.push({ id: row.id, name, ok: false, detail: `package.json at ${row.identity.packagePath} is not stamped ${candidate}` });
420
- continue;
681
+ if (!name || manifestVersion2 !== candidate) {
682
+ verdicts[index] = { id: row.id, name, ok: false, detail: `package.json at ${row.identity.packagePath} is not stamped ${candidate}` };
683
+ return;
421
684
  }
422
- const integrity = npm.distIntegrity(`${name}@${candidate}`);
685
+ pending.push({ index, id: row.id, name });
686
+ });
687
+ const integrities = await mapBounded(pending, ATOMICITY_CONCURRENCY, (entry) => Promise.resolve(npm.distIntegrity(`${entry.name}@${candidate}`)));
688
+ pending.forEach((entry, i) => {
689
+ const integrity = integrities[i];
423
690
  if (!integrity) {
424
- rows.push({ id: row.id, name, ok: false, detail: `${name}@${candidate} not on npm` });
425
- continue;
691
+ verdicts[entry.index] = { id: entry.id, name: entry.name, ok: false, detail: `${entry.name}@${candidate} not on npm` };
692
+ return;
426
693
  }
694
+ const row = bomRows[entry.index];
427
695
  const legacySsri = /^sha\d+-/.test(row.identity.value ?? "");
428
696
  if (legacySsri && integrity !== row.identity.value) {
429
- rows.push({ id: row.id, name, ok: false, detail: `${name}@${candidate} dist.integrity differs from BOM identity` });
430
- continue;
697
+ verdicts[entry.index] = { id: entry.id, name: entry.name, ok: false, detail: `${entry.name}@${candidate} dist.integrity differs from BOM identity` };
698
+ return;
431
699
  }
432
- rows.push({ id: row.id, name, ok: true, detail: legacySsri ? `${name}@${candidate} published, integrity matches` : `${name}@${candidate} published` });
433
- }
700
+ verdicts[entry.index] = { id: entry.id, name: entry.name, ok: true, detail: legacySsri ? `${entry.name}@${candidate} published, integrity matches` : `${entry.name}@${candidate} published` };
701
+ });
702
+ const rows = verdicts.map((row, index) => row ?? { id: bomRows[index].id, ok: false, detail: "atomicity probe produced no verdict" });
434
703
  return { ok: rows.length > 0 && rows.every((row) => row.ok), rows };
435
704
  }
436
705
 
@@ -606,6 +875,15 @@ function probeVersion(distPath) {
606
875
  function tail(output) {
607
876
  return output.split("\n").filter(Boolean).slice(-3).join(" | ");
608
877
  }
878
+ function manifestVersion(packageRoot) {
879
+ if (!packageRoot) return null;
880
+ try {
881
+ const version = JSON.parse((0, import_node_fs5.readFileSync)((0, import_node_path4.join)(packageRoot, "package.json"), "utf8")).version;
882
+ return typeof version === "string" ? version : null;
883
+ } catch {
884
+ return null;
885
+ }
886
+ }
609
887
  function stagePackage(options) {
610
888
  const { surface, packageName, target, witness, paths, env } = options;
611
889
  const stageRoot = (0, import_node_path4.join)(paths.staging, surface, target);
@@ -633,12 +911,17 @@ function npmGlobalArm(options) {
633
911
  const { surface, packageName, distRel, target, dryRun, paths } = options;
634
912
  const spec = `${packageName}@${target}`;
635
913
  const result = (from, to, verdict, detail) => ({ surface, from, to, verdict, detail });
914
+ const highWater = journalHighWater(paths.journalPath, surface, options.journalEvents);
915
+ const localRoot = globalRoot(env);
916
+ const localVersion = localRoot ? manifestVersion((0, import_node_path4.join)(localRoot, ...packageName.split("/"))) : null;
917
+ if (localVersion === target && highWater === target) {
918
+ return result(localVersion, target, "ok", `already at target ${target} (installed manifest + journal high-water)`);
919
+ }
636
920
  const dist = globalDist(env, packageName, distRel);
637
921
  const installed = dist ? probeVersion(dist) : null;
638
922
  if (installed && compareSemver(installed, target) > 0) {
639
923
  return result(installed, installed, "skip", `installed ${installed} is above candidate ${target} (monotonic)`);
640
924
  }
641
- const highWater = journalHighWater(paths.journalPath, surface);
642
925
  if (highWater && compareSemver(highWater, target) > 0) {
643
926
  return result(installed, highWater, "skip", `journal high-water ${highWater} is above candidate ${target} (monotonic)`);
644
927
  }
@@ -862,7 +1145,7 @@ function claudeArm(options) {
862
1145
  if (from && compareSemver(from, target) > 0) {
863
1146
  return result(from, from, "skip", `installed ${from} is above candidate ${target} (monotonic)`);
864
1147
  }
865
- const highWater = journalHighWater(paths.journalPath, "claude");
1148
+ const highWater = journalHighWater(paths.journalPath, "claude", options.journalEvents);
866
1149
  if (highWater && compareSemver(highWater, target) > 0) {
867
1150
  return result(from, highWater, "skip", `journal high-water ${highWater} is above candidate ${target} (monotonic)`);
868
1151
  }
@@ -1422,7 +1705,7 @@ function cursorArm(options) {
1422
1705
  if (installed && compareSemver(installed, target) > 0) {
1423
1706
  return result(installed, installed, "skip", `installed ${installed} is above candidate ${target} (monotonic)`);
1424
1707
  }
1425
- const highWater = journalHighWater(paths.journalPath, "cursor");
1708
+ const highWater = journalHighWater(paths.journalPath, "cursor", options.journalEvents);
1426
1709
  if (highWater && compareSemver(highWater, target) > 0) {
1427
1710
  return result(installed, highWater, "skip", `journal high-water ${highWater} is above candidate ${target} (monotonic)`);
1428
1711
  }
@@ -1566,11 +1849,11 @@ function parseListText(stdout) {
1566
1849
  }
1567
1850
  function probeJervcodeInstallation(env = process.env, runner = runHostCommand) {
1568
1851
  const manifestLocation = materializedManifestLocation(env);
1569
- const manifestVersion = installedVersion(env);
1852
+ const manifestVersion2 = installedVersion(env);
1570
1853
  const manifestEvidence = () => ({
1571
- version: manifestVersion,
1854
+ version: manifestVersion2,
1572
1855
  location: manifestLocation,
1573
- detail: manifestVersion ? "materialized pi package manifest" : "materialized pi package manifest was unreadable"
1856
+ detail: manifestVersion2 ? "materialized pi package manifest" : "materialized pi package manifest was unreadable"
1574
1857
  });
1575
1858
  const hosted = hostEnv(env);
1576
1859
  const command = hostCommands(env).find((bin) => runner(bin, ["--version"], hosted).status === 0);
@@ -1586,9 +1869,9 @@ function probeJervcodeInstallation(env = process.env, runner = runHostCommand) {
1586
1869
  };
1587
1870
  }
1588
1871
  const hostError = result.status !== 0 ? `${hostLocation} failed: ${failure2(result)}` : `${hostLocation} did not list npm:${PI_PACKAGE}`;
1589
- if (manifestVersion) {
1872
+ if (manifestVersion2) {
1590
1873
  return {
1591
- version: manifestVersion,
1874
+ version: manifestVersion2,
1592
1875
  location: manifestLocation,
1593
1876
  detail: `${hostError}; using materialized pi package manifest`
1594
1877
  };
@@ -1619,7 +1902,7 @@ function jervcodeArm(options) {
1619
1902
  if (!before) return result(null, options.target, "defer", `cannot read ${(0, import_node_path9.join)(jervcodeAgentDir(env), "settings.json")}`);
1620
1903
  const from = installedVersion(env);
1621
1904
  const legacy = before.filter(isLegacyMmiPiPath);
1622
- const highWater = journalHighWater(options.paths.journalPath, "jervcode");
1905
+ const highWater = journalHighWater(options.paths.journalPath, "jervcode", options.journalEvents);
1623
1906
  const floor = from && compareSemver(from, options.target) > 0 ? `installed ${from} is above candidate ${options.target} (monotonic)` : highWater && compareSemver(highWater, options.target) > 0 ? `journal high-water ${highWater} is above candidate ${options.target} (monotonic)` : null;
1624
1907
  if (options.dryRun) {
1625
1908
  const action = floor ? `hold ${from ?? "the install"} and skip the install (${floor})` : before.includes(exact) && from === options.target ? "keep exact pin" : `install ${exact}`;
@@ -1811,7 +2094,7 @@ function hermesHomes(env = process.env) {
1811
2094
  var VERDICT_RANK = { fail: 3, defer: 2, ok: 1, skip: 0 };
1812
2095
  function hermesArm(options) {
1813
2096
  const env = options.env ?? process.env;
1814
- if (!hermesHostPresent(env, options.hostCommandRunner)) {
2097
+ if (!(options.hostPresent ?? hermesHostPresent(env, options.hostCommandRunner))) {
1815
2098
  return {
1816
2099
  surface: "hermes",
1817
2100
  from: null,
@@ -1854,7 +2137,7 @@ function armHermesHome(home, env, options, primary) {
1854
2137
  return result(installed, options.target, "defer", `${live} carries no ${PACKAGE2} package identity \u2014 refusing to displace an unmanaged Hermes mmi plugin`);
1855
2138
  }
1856
2139
  if (installed && compareSemver(installed, options.target) > 0) return result(installed, installed, "skip", `installed ${installed} is above candidate ${options.target} (monotonic)`);
1857
- const highWater = primary ? journalHighWater(options.paths.journalPath, "hermes") : null;
2140
+ const highWater = primary ? journalHighWater(options.paths.journalPath, "hermes", options.journalEvents) : null;
1858
2141
  if (highWater && compareSemver(highWater, options.target) > 0) return result(installed, highWater, "skip", `journal high-water ${highWater} is above candidate ${options.target} (monotonic)`);
1859
2142
  const hostRunner = options.hostCommandRunner ?? runHostCommand;
1860
2143
  if (installed === options.target && treeHealthy2(live)) {
@@ -1956,6 +2239,10 @@ function probeHermesLaunchStaging(env = process.env) {
1956
2239
  // src/reap.ts
1957
2240
  var import_node_fs14 = require("node:fs");
1958
2241
  var import_node_path11 = require("node:path");
2242
+ var import_node_perf_hooks = require("node:perf_hooks");
2243
+ function elapsedMs(startedAt) {
2244
+ return Math.round((import_node_perf_hooks.performance.now() - startedAt) * 10) / 10;
2245
+ }
1959
2246
  var KEEP_GENERATIONS2 = 2;
1960
2247
  var SCRATCH_AGE_MS = 24 * 60 * 6e4;
1961
2248
  function listDirs(path) {
@@ -2052,10 +2339,11 @@ function reap(options) {
2052
2339
  const { paths, dryRun } = options;
2053
2340
  const env = options.env ?? process.env;
2054
2341
  const guarded = (category, run) => {
2342
+ const startedAt = import_node_perf_hooks.performance.now();
2055
2343
  try {
2056
- return run();
2344
+ return { ...run(), durationMs: elapsedMs(startedAt) };
2057
2345
  } catch (error) {
2058
- return { category, reaped: 0, verdict: "fail", detail: `reap failed: ${error?.message ?? error}` };
2346
+ return { category, reaped: 0, verdict: "fail", detail: `reap failed: ${error?.message ?? error}`, durationMs: elapsedMs(startedAt) };
2059
2347
  }
2060
2348
  };
2061
2349
  const atomicWritePaths = [claudeMarketplacesPath(env)];
@@ -2072,7 +2360,7 @@ function shouldHandOff(self, options) {
2072
2360
  if (!options.dist) return false;
2073
2361
  return self.verdict === "ok" && Boolean(self.from) && self.from !== self.to;
2074
2362
  }
2075
- function reconcile(options) {
2363
+ async function reconcile(options) {
2076
2364
  const env = options.env ?? process.env;
2077
2365
  const shipped = options.shippedTarget?.trim() || null;
2078
2366
  const say = (line) => {
@@ -2088,9 +2376,9 @@ function reconcile(options) {
2088
2376
  if (!appendJournal(paths.journalPath, { ts: (/* @__PURE__ */ new Date()).toISOString(), run, ...stamped })) summary.journalOk = false;
2089
2377
  };
2090
2378
  const summary = { run, target: null, gate: null, managedSurfaces: null, atomicity: null, cli: null, updater: null, plugins: [], reap: [], surfaces: [], journalOk: true, reexec: null, exit: 0 };
2091
- const recordArm = (arm) => {
2379
+ const recordArm = (arm, durationMs) => {
2092
2380
  if (arm.verdict === "fail") arm.repeatFailure = journalSurfaceRepeatedFail(paths.journalPath, arm.surface, run);
2093
- journal({ kind: "arm", surface: arm.surface, from: arm.from, to: arm.to, verdict: arm.verdict, detail: arm.detail, compat: arm.compat });
2381
+ journal({ kind: "arm", surface: arm.surface, from: arm.from, to: arm.to, verdict: arm.verdict, detail: arm.detail, compat: arm.compat, durationMs });
2094
2382
  options.onArm?.(arm);
2095
2383
  if (arm.verdict === "ok" && !options.dryRun && !arm.launchStaged) clearPending(paths, arm.surface);
2096
2384
  if (arm.verdict === "defer") summary.exit = Math.max(summary.exit, 2);
@@ -2099,7 +2387,8 @@ function reconcile(options) {
2099
2387
  try {
2100
2388
  say("acquiring single-writer lease");
2101
2389
  const leaseWaitMs = Number(env.MMI_UPDATER_LEASE_WAIT_MS) || void 0;
2102
- return withLease(paths.leasePath, () => {
2390
+ return await withLease(paths.leasePath, async () => {
2391
+ const journalEvents = parseJournal(paths.journalPath);
2103
2392
  if (!options.dryRun) {
2104
2393
  const pendingBefore = readPending(paths);
2105
2394
  if (pendingBefore.length) {
@@ -2108,7 +2397,7 @@ function reconcile(options) {
2108
2397
  }
2109
2398
  }
2110
2399
  summary.reap = reap({ paths, dryRun: options.dryRun, env });
2111
- for (const row of summary.reap) journal({ kind: "reap", surface: row.category, verdict: row.verdict, count: row.reaped, detail: row.detail });
2400
+ for (const row of summary.reap) journal({ kind: "reap", surface: row.category, verdict: row.verdict, count: row.reaped, detail: row.detail, durationMs: row.durationMs });
2112
2401
  for (const row of summary.reap.filter((entry) => entry.verdict !== "skip")) say(`reap ${row.category}: ${row.verdict}${row.detail ? " \u2014 " + row.detail : ""}`);
2113
2402
  if (shipped) {
2114
2403
  say(`shipped target v${shipped} \u2014 the payload is the release; no npm poll, no gate`);
@@ -2118,41 +2407,48 @@ function reconcile(options) {
2118
2407
  journal({ kind: "target", to: shipped, verdict: "ok", detail: "shipped payload" });
2119
2408
  } else {
2120
2409
  say("polling npm for @mutmutco/cli versions");
2410
+ const pollStartedAt = import_node_perf_hooks2.performance.now();
2121
2411
  const versions = npmVersions("@mutmutco/cli", env).sort((a, b) => compareSemver(b, a));
2412
+ const pollMs = elapsedMs(pollStartedAt);
2122
2413
  if (!versions.length) {
2123
- journal({ kind: "poll", verdict: "fail", detail: "@mutmutco/cli has no npm versions \u2014 cannot discover a candidate" });
2414
+ journal({ kind: "poll", verdict: "fail", durationMs: pollMs, detail: "@mutmutco/cli has no npm versions \u2014 cannot discover a candidate" });
2124
2415
  summary.exit = 1;
2125
2416
  summary.detail = "npm poll returned no versions for @mutmutco/cli";
2126
2417
  return summary;
2127
2418
  }
2419
+ journal({ kind: "poll", verdict: "ok", count: versions.length, durationMs: pollMs, detail: `${versions.length} versions; newest ${versions[0]}` });
2128
2420
  say("fetching release BOM gate cache");
2421
+ const fetchStartedAt = import_node_perf_hooks2.performance.now();
2129
2422
  ensureBareCache(paths.gitCache, repoUrl);
2130
2423
  fetchIntoCache(paths.gitCache, repoUrl, "refs/heads/main:refs/heads/main");
2424
+ journal({ kind: "gate", surface: "cache", verdict: "ok", durationMs: elapsedMs(fetchStartedAt), detail: "release BOM gate cache: refs/heads/main fetched" });
2425
+ const walkStartedAt = import_node_perf_hooks2.performance.now();
2131
2426
  for (const candidate of versions.slice(0, bound)) {
2132
2427
  say(`gating candidate v${candidate}`);
2428
+ const candidateStartedAt = import_node_perf_hooks2.performance.now();
2133
2429
  const gate = gateCandidate(paths.gitCache, repoUrl, candidate);
2134
2430
  if (!gate.ok) {
2135
2431
  say(`candidate v${candidate} rejected (${gate.reason ?? "gate"})`);
2136
- journal({ kind: "candidate-reject", to: candidate, verdict: "skip", detail: gate.reason ?? "gate rejected" });
2432
+ journal({ kind: "candidate-reject", to: candidate, verdict: "skip", durationMs: elapsedMs(candidateStartedAt), detail: gate.reason ?? "gate rejected" });
2137
2433
  continue;
2138
2434
  }
2139
- const atomicity = checkAtomicity(paths.gitCache, gate.tagCommit, gate.bom, candidate, {
2140
- distIntegrity: (spec) => npmDistIntegrity(spec, env)
2435
+ const atomicity = await checkAtomicity(paths.gitCache, gate.tagCommit, gate.bom, candidate, {
2436
+ distIntegrity: (spec) => npmDistIntegrityAsync(spec, env)
2141
2437
  });
2142
2438
  if (!atomicity.ok) {
2143
- journal({ kind: "candidate-reject", to: candidate, verdict: "skip", detail: `atomicity: ${atomicity.rows.filter((row) => !row.ok).map((row) => row.detail).join("; ")}` });
2439
+ journal({ kind: "candidate-reject", to: candidate, verdict: "skip", durationMs: elapsedMs(candidateStartedAt), detail: `atomicity: ${atomicity.rows.filter((row) => !row.ok).map((row) => row.detail).join("; ")}` });
2144
2440
  continue;
2145
2441
  }
2146
2442
  summary.target = candidate;
2147
2443
  summary.managedSurfaces = Array.isArray(gate.bom?.managedSurfaces) ? gate.bom.managedSurfaces.map(String) : null;
2148
2444
  summary.gate = `tag v${candidate} reachable from main, BOM stamp coherent`;
2149
2445
  summary.atomicity = atomicity;
2150
- journal({ kind: "target", to: candidate, verdict: "ok", detail: summary.gate });
2446
+ journal({ kind: "target", to: candidate, verdict: "ok", durationMs: elapsedMs(candidateStartedAt), detail: summary.gate });
2151
2447
  break;
2152
2448
  }
2153
2449
  if (!summary.target) {
2154
2450
  say("no green target \u2014 deferred");
2155
- journal({ kind: "target", verdict: "defer", detail: `no green candidate in the newest ${Math.min(bound, versions.length)} versions \u2014 registry not ready for this fleet stamp yet` });
2451
+ journal({ kind: "target", verdict: "defer", durationMs: elapsedMs(walkStartedAt), detail: `no green candidate in the newest ${Math.min(bound, versions.length)} versions \u2014 registry not ready for this fleet stamp yet` });
2156
2452
  for (const probe of enumerateSurfaces(env)) {
2157
2453
  summary.surfaces.push({ id: probe.id, present: probe.present, action: probe.present ? "deferred-no-target" : "absent-skip" });
2158
2454
  journal({ kind: "surface", surface: probe.id, present: probe.present, verdict: "skip", detail: probe.present ? "present; no green target yet" : `absent (${probe.marker}) \u2014 skipped` });
@@ -2163,6 +2459,7 @@ function reconcile(options) {
2163
2459
  say(`target v${summary.target} (${summary.gate})`);
2164
2460
  options.onTargetResolved?.(summary.target, summary.gate);
2165
2461
  say("updater: self-bootstrap");
2462
+ const selfStartedAt = import_node_perf_hooks2.performance.now();
2166
2463
  const self = npmGlobalArm({
2167
2464
  surface: "updater",
2168
2465
  packageName: "@mutmutco/hub",
@@ -2170,10 +2467,11 @@ function reconcile(options) {
2170
2467
  target: summary.target,
2171
2468
  dryRun: options.dryRun,
2172
2469
  paths,
2173
- env
2470
+ env,
2471
+ journalEvents
2174
2472
  });
2175
2473
  summary.updater = self;
2176
- recordArm(self);
2474
+ recordArm(self, elapsedMs(selfStartedAt));
2177
2475
  say(`updater: ${self.verdict}${self.detail ? " \u2014 " + self.detail : ""}`);
2178
2476
  const handoffDist = globalDist(env, "@mutmutco/hub", "dist/index.cjs");
2179
2477
  if (shouldHandOff(self, { dryRun: options.dryRun, dist: handoffDist, guard: env.MMI_UPDATER_REEXEC })) {
@@ -2183,6 +2481,7 @@ function reconcile(options) {
2183
2481
  return summary;
2184
2482
  }
2185
2483
  say(`cli: converging to v${summary.target}`);
2484
+ const cliStartedAt = import_node_perf_hooks2.performance.now();
2186
2485
  const cli = npmGlobalArm({
2187
2486
  surface: "cli",
2188
2487
  packageName: "@mutmutco/cli",
@@ -2190,10 +2489,11 @@ function reconcile(options) {
2190
2489
  target: summary.target,
2191
2490
  dryRun: options.dryRun,
2192
2491
  paths,
2193
- env
2492
+ env,
2493
+ journalEvents
2194
2494
  });
2195
2495
  summary.cli = cli;
2196
- recordArm(cli);
2496
+ recordArm(cli, elapsedMs(cliStartedAt));
2197
2497
  say(`cli: ${cli.verdict}${cli.detail ? " \u2014 " + cli.detail : ""}`);
2198
2498
  for (const probe of enumerateSurfaces(env)) {
2199
2499
  if (!probe.present) {
@@ -2208,11 +2508,12 @@ function reconcile(options) {
2208
2508
  continue;
2209
2509
  }
2210
2510
  say(`${probe.id}: converging to v${summary.target}`);
2211
- const arm = probe.id === "claude" ? claudeArm({ target: summary.target, dryRun: options.dryRun, paths, env, repoUrl }) : probe.id === "codex" ? codexArm({ target: summary.target, dryRun: options.dryRun, env, repoUrl }) : probe.id === "cursor" ? cursorArm({ target: summary.target, dryRun: options.dryRun, paths, env }) : probe.id === "jervcode" ? jervcodeArm({ target: summary.target, dryRun: options.dryRun, paths, env }) : probe.id === "hermes" ? hermesArm({ target: summary.target, dryRun: options.dryRun, paths, env }) : null;
2511
+ const armStartedAt = import_node_perf_hooks2.performance.now();
2512
+ const arm = probe.id === "claude" ? claudeArm({ target: summary.target, dryRun: options.dryRun, paths, env, repoUrl, journalEvents }) : probe.id === "codex" ? codexArm({ target: summary.target, dryRun: options.dryRun, env, repoUrl }) : probe.id === "cursor" ? cursorArm({ target: summary.target, dryRun: options.dryRun, paths, env, journalEvents }) : probe.id === "jervcode" ? jervcodeArm({ target: summary.target, dryRun: options.dryRun, paths, env, journalEvents }) : probe.id === "hermes" ? hermesArm({ target: summary.target, dryRun: options.dryRun, paths, env, hostPresent: probe.present, journalEvents }) : null;
2212
2513
  if (arm) {
2213
2514
  summary.plugins.push(arm);
2214
2515
  summary.surfaces.push({ id: probe.id, present: true, action: `arm-${arm.verdict}` });
2215
- recordArm(arm);
2516
+ recordArm(arm, elapsedMs(armStartedAt));
2216
2517
  say(`${probe.id}: ${arm.verdict}${arm.detail ? " \u2014 " + arm.detail : ""}`);
2217
2518
  } else {
2218
2519
  summary.surfaces.push({ id: probe.id, present: true, action: "arm-pending" });
@@ -2924,28 +3225,50 @@ var ARM_TITLE = {
2924
3225
  hermes: "Arming Hermes",
2925
3226
  jervcode: "Arming JervCode"
2926
3227
  };
2927
- var CHECKLIST_TAIL = {
2928
- updated: "",
2929
- current: " current",
2930
- keep: " keep",
2931
- retry: " will retry",
2932
- failed: " failed",
2933
- pending: " pending"
3228
+ var FACE_PRODUCT = "mmi-hub";
3229
+ function updaterFace(options = {}) {
3230
+ return createFace({ product: FACE_PRODUCT, color: options.color ?? false, columns: options.width });
3231
+ }
3232
+ var STATE_WORD = {
3233
+ updated: "updated",
3234
+ current: "current",
3235
+ keep: "kept",
3236
+ retry: "will retry",
3237
+ failed: "failed",
3238
+ pending: "pending"
2934
3239
  };
2935
- function checklistLine(arm, seconds, options = {}) {
2936
- const tty = options.tty ?? false;
2937
- const color = tty && (options.color ?? true);
2938
- const dryRun = options.dryRun ?? false;
2939
- const kind = classify(arm).kind;
2940
- const tone = kind === "failed" ? RED : kind === "retry" ? YELLOW : MM_GREEN;
2941
- const paint = (text) => color ? tone + text + RESET : text;
2942
- const glyph = kind === "failed" ? "\u2716" : kind === "retry" ? "\u25CF" : "\u2714";
2943
- const title = ARM_TITLE[arm.surface] ?? `Arming ${arm.surface}`;
2944
- const tail2 = dryRun && kind === "updated" ? " would update" : CHECKLIST_TAIL[kind];
2945
- const time = seconds !== null && kind !== "retry" && kind !== "failed" ? ` ${Math.max(1, Math.round(seconds))}s` : "";
2946
- return ` ${paint(glyph)} ${title}${tail2}${time}`;
2947
- }
2948
- function wrapWords(text, available) {
3240
+ function stepKindFor(kind) {
3241
+ return kind === "failed" ? "fail" : kind === "retry" ? "note" : "ok";
3242
+ }
3243
+ function checklistLine(arm, seconds, face, options = {}) {
3244
+ const { kind } = classify(arm);
3245
+ const state = options.dryRun && kind === "updated" ? "would update" : STATE_WORD[kind];
3246
+ const title = `${ARM_TITLE[arm.surface] ?? `Arming ${arm.surface}`} \u2014 ${state}`;
3247
+ const truthful = kind === "failed" || kind === "retry" ? null : seconds;
3248
+ return face.step(title, truthful, stepKindFor(kind));
3249
+ }
3250
+ function receiptText(summary, autoupdate, options = {}) {
3251
+ const dry = options.dryRun ?? false;
3252
+ const counts = reportCounts(summary);
3253
+ const health = "Check health any time: mmi-hub status";
3254
+ if (!summary.target) {
3255
+ return [`${summary.exit >= 1 ? "\u2716" : "\u25CF"} mmi-hub is not ready \u2014 no green release yet (hourly retry)`, "", health];
3256
+ }
3257
+ const ready = counts.failed === 0;
3258
+ const glyph = counts.failed > 0 ? "\u2716" : counts.retry > 0 ? "\u25CF" : dry ? "\u25C7" : "\u2714";
3259
+ const state = counts.failed > 0 ? `${counts.failed} of ${counts.total} surfaces failed` : counts.retry > 0 ? `${counts.retry} of ${counts.total} surfaces will retry` : counts.updated > 0 ? `${counts.updated}/${counts.total} surfaces ${dry ? "would update" : "updated"}` : `all ${counts.total} surfaces current`;
3260
+ const tail2 = autoupdate ? ` \xB7 ${autoupdate.ok ? "auto-update on" : `auto-update NOT enabled \u2014 ${autoupdate.detail}`}` : ` \xB7 ${summary.target}${dry ? " (planned)" : " (verified)"}`;
3261
+ return [`${glyph} mmi-hub is ${ready ? "ready" : "not ready"} \u2014 ${state}${tail2}`, "", health];
3262
+ }
3263
+ function attentionRows(summary) {
3264
+ return collectRows(summary).filter((row) => row.kind === "failed" || row.kind === "retry" || row.kind === "pending").map((row) => ({
3265
+ surface: row.name,
3266
+ state: statusText(row.kind, false),
3267
+ mark: row.kind === "failed" ? "fail" : "note",
3268
+ note: row.kind === "failed" ? failConsequence(row) : row.kind === "retry" ? retryHint(row.detail) : "arm not landed"
3269
+ }));
3270
+ }
3271
+ function wrapWords2(text, available) {
2949
3272
  if (available < 1) return [text];
2950
3273
  const lines = [];
2951
3274
  let current = "";
@@ -2966,7 +3289,7 @@ function noteLines(prefix, note, width) {
2966
3289
  const available = width - used - 1;
2967
3290
  const indent = available >= MIN_NOTE_SPACE ? " ".repeat(used + 1) : " ".repeat(Math.min(used + 1, Math.max(2, width - MIN_NOTE_SPACE)));
2968
3291
  const room = available >= MIN_NOTE_SPACE ? available : width - indent.length;
2969
- const parts = wrapWords(note, room);
3292
+ const parts = wrapWords2(note, room);
2970
3293
  const first = available >= MIN_NOTE_SPACE ? prefix + " " + parts[0] : prefix.replace(/\s+$/, "");
2971
3294
  const lines = [first];
2972
3295
  for (const part of parts.slice(available >= MIN_NOTE_SPACE ? 1 : 0)) lines.push(indent + part);
@@ -2989,7 +3312,7 @@ function collectRows(summary) {
2989
3312
  }
2990
3313
  function headline(detail, width) {
2991
3314
  const first = (detail ?? "").split(/(?<=[.;:])\s/)[0]?.replace(/\s+/g, " ").trim() ?? "";
2992
- return wrapWords(first || "blocked", Math.max(10, width - 4))[0];
3315
+ return wrapWords2(first || "blocked", Math.max(10, width - 4))[0];
2993
3316
  }
2994
3317
  function updateHeader(target, dryRun = false, options) {
2995
3318
  return brandLine(
@@ -3091,124 +3414,6 @@ function formatReconcileReport(summary, options = {}) {
3091
3414
  return out.join("\n") + "\n";
3092
3415
  }
3093
3416
 
3094
- // src/face.ts
3095
- var DEFAULT_WIDTH2 = 100;
3096
- function welcome(color, output = process.stdout) {
3097
- const mark = color ? MM_GREEN + "\u25C6" + RESET : "\u25C6";
3098
- output.write(`${mark} ${accent("mmi-hub", color)} \u2014 Mutatis Mutandis
3099
- `);
3100
- output.write("\u2502\n");
3101
- output.write("\u2502 Welcome back. Checking your surfaces\u2026\n");
3102
- output.write("\u2502\n");
3103
- }
3104
- var TONES = { "\u2714": MM_GREEN, "\u2716": RED, "\u25CF": YELLOW };
3105
- function paintGlyph(line, color) {
3106
- if (!color) return line;
3107
- const glyph = line.trimStart()[0];
3108
- const tone = TONES[glyph];
3109
- if (!tone) return line;
3110
- const at = line.indexOf(glyph);
3111
- return line.slice(0, at) + tone + glyph + RESET + line.slice(at + 1);
3112
- }
3113
- function receipt(text, options = {}) {
3114
- const color = options.color ?? false;
3115
- const width = Math.max(40, options.width ?? DEFAULT_WIDTH2);
3116
- const output = options.output ?? process.stdout;
3117
- const edges = 2 * 2 + 2;
3118
- const available = width - edges;
3119
- const lines = [];
3120
- for (const paragraph of text.split("\n")) {
3121
- if (!paragraph.trim()) lines.push(null);
3122
- else if (paragraph.length <= available) lines.push(paragraph);
3123
- else for (const line of wrapWords(paragraph, available)) lines.push(line);
3124
- }
3125
- const inner = Math.min(available, Math.max(20, ...lines.map((line) => line?.length ?? 0)));
3126
- const rule = "\u2500".repeat(inner + 2 * 2);
3127
- const frame = (text2) => color ? ACCENT + text2 + RESET : text2;
3128
- const blank = `${frame("\u2502")}${" ".repeat(inner + 2 * 2)}${frame("\u2502")}`;
3129
- output.write(frame(`\u256D${rule}\u256E`) + "\n");
3130
- for (const line of lines) {
3131
- output.write(line === null ? blank + "\n" : `${frame("\u2502")} ${paintGlyph(line.padEnd(inner), color)} ${frame("\u2502")}
3132
- `);
3133
- }
3134
- output.write(frame(`\u2570${rule}\u256F`) + "\n");
3135
- }
3136
- function signOff(color, output = process.stdout) {
3137
- output.write(`\u2570 ${accent("mmi-hub \xB7 Mutatis Mutandis", color)}
3138
- `);
3139
- }
3140
- function receiptText(summary, autoupdate, options = {}) {
3141
- const dry = options.dryRun ?? false;
3142
- const counts = reportCounts(summary);
3143
- const health = "Check health any time: mmi-hub status";
3144
- if (!summary.target) {
3145
- return `${summary.exit >= 1 ? "\u2716" : "\u25CF"} mmi-hub is not ready \u2014 no green release yet (hourly retry)
3146
- ${health}`;
3147
- }
3148
- const ready = counts.failed === 0;
3149
- const glyph = counts.failed > 0 ? "\u2716" : counts.retry > 0 ? "\u25CF" : dry ? "\u25C7" : "\u2714";
3150
- const state = counts.failed > 0 ? `${counts.failed} of ${counts.total} surfaces failed` : counts.retry > 0 ? `${counts.retry} of ${counts.total} surfaces will retry` : counts.updated > 0 ? `${counts.updated}/${counts.total} surfaces ${dry ? "would update" : "updated"}` : `all ${counts.total} surfaces current`;
3151
- const tail2 = autoupdate ? ` \xB7 ${autoupdate.ok ? "auto-update on" : `auto-update NOT enabled \u2014 ${autoupdate.detail}`}` : ` \xB7 ${summary.target}${dry ? " (planned)" : " (verified)"}`;
3152
- return `${glyph} mmi-hub is ${ready ? "ready" : "not ready"} \u2014 ${state}${tail2}
3153
- ${health}`;
3154
- }
3155
-
3156
- // src/spinner.ts
3157
- var import_node_worker_threads = require("node:worker_threads");
3158
- var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
3159
- var WORKER_SRC = `
3160
- const { parentPort, workerData } = require('node:worker_threads');
3161
- const frames = ${JSON.stringify(FRAMES)};
3162
- const control = new Int32Array(workerData.sab);
3163
- let frame = 0, label = '', timer = null;
3164
- function tick() {
3165
- if (Atomics.load(control, 0) === 1) return; // paused by the main thread
3166
- if (!label) return;
3167
- process.stderr.write('\\r' + frames[frame++ % frames.length] + ' ' + label + '\\x1b[K');
3168
- }
3169
- parentPort.on('message', (message) => {
3170
- if (message && message.type === 'label') { label = message.label; if (!timer) timer = setInterval(tick, 80); }
3171
- else if (message === 'stop') { if (timer) clearInterval(timer); process.stderr.write('\\r\\x1b[K'); process.exit(0); }
3172
- });
3173
- `;
3174
- var NOOP = { label() {
3175
- }, persist() {
3176
- }, stop() {
3177
- }, active: false };
3178
- function startSpinner(enabled) {
3179
- if (!enabled) return NOOP;
3180
- let worker;
3181
- let control;
3182
- try {
3183
- const sab = new SharedArrayBuffer(4);
3184
- control = new Int32Array(sab);
3185
- worker = new import_node_worker_threads.Worker(WORKER_SRC, { eval: true, workerData: { sab } });
3186
- worker.unref();
3187
- worker.on("error", () => {
3188
- });
3189
- } catch {
3190
- return NOOP;
3191
- }
3192
- let stopped = false;
3193
- return {
3194
- active: true,
3195
- label: (text) => {
3196
- if (!stopped) worker.postMessage({ type: "label", label: text });
3197
- },
3198
- persist: (line) => {
3199
- Atomics.store(control, 0, 1);
3200
- process.stderr.write("\r\x1B[K");
3201
- process.stdout.write(line + "\n");
3202
- Atomics.store(control, 0, 0);
3203
- },
3204
- stop: () => {
3205
- if (stopped) return;
3206
- stopped = true;
3207
- worker.postMessage("stop");
3208
- }
3209
- };
3210
- }
3211
-
3212
3417
  // src/stamp.ts
3213
3418
  var import_node_fs17 = require("node:fs");
3214
3419
  var ALWAYS_ARMED = ["cli", "updater"];
@@ -3306,17 +3511,33 @@ function printReconcile(summary, json, dryRun = false) {
3306
3511
  function faceEnabled(args, json) {
3307
3512
  return !json && !args.includes("--verbose") && !process.env.NO_COLOR && process.env.TERM !== "dumb" && Boolean(process.stdout.isTTY) && Boolean(process.stderr.isTTY);
3308
3513
  }
3309
- function openFace(args, json) {
3310
- const face = faceEnabled(args, json);
3311
- if (face && !process.env.MMI_UPDATER_REEXEC) welcome(true);
3514
+ function openFace(args, json, dryRun) {
3515
+ if (!faceEnabled(args, json)) return null;
3516
+ const face = updaterFace(humanOptions(dryRun));
3517
+ if (!process.env.MMI_UPDATER_REEXEC) for (const line of face.welcome()) process.stdout.write(line + "\n");
3312
3518
  return face;
3313
3519
  }
3314
- function finishFace(summary, autoupdate, dryRun) {
3520
+ function finishFace(face, summary, autoupdate, dryRun) {
3315
3521
  const options = humanOptions(dryRun);
3316
- if (!summary.target) process.stdout.write(formatReconcileReport(summary, options));
3317
- else if (summary.exit >= 1) process.stdout.write(reportFooterLines(summary, options).join("\n") + "\n");
3318
- receipt(receiptText(summary, autoupdate, { dryRun }), { color: options.color, width: options.width });
3319
- signOff(true);
3522
+ const write = (line) => {
3523
+ process.stdout.write(line + "\n");
3524
+ };
3525
+ if (!summary.target) {
3526
+ process.stdout.write(formatReconcileReport(summary, { ...options, tty: false }));
3527
+ } else {
3528
+ for (const line of renderRows(face, attentionRows(summary))) write(line);
3529
+ }
3530
+ const body = receiptText(summary, autoupdate, { dryRun });
3531
+ const counts = reportCounts(summary);
3532
+ if (counts.failed > 0) {
3533
+ body.push("", "Fix: mmi-cli doctor, then mmi-hub update", `Log: ${options.journalPath}`);
3534
+ }
3535
+ const self = summary.updater;
3536
+ if (!dryRun && self && self.from && self.to && self.from !== self.to && self.verdict !== "fail") {
3537
+ body.push("", `Note: hub moved ${self.from} \u2192 ${self.to} while the surfaces above were converging, so they used ${self.from} logic \u2014 rerun \`mmi-hub update\` to bring them to ${self.to}`);
3538
+ }
3539
+ for (const line of face.receipt(body)) write(line);
3540
+ write(face.signOff());
3320
3541
  }
3321
3542
  function handOff(reexec, args) {
3322
3543
  const child = (0, import_node_child_process9.spawnSync)(process.execPath, [reexec.dist, ...args], {
@@ -3353,7 +3574,7 @@ function shippedTargetFrom(args) {
3353
3574
  const value = (args[index + 1] ?? "").trim();
3354
3575
  return value && !value.startsWith("--") ? value : void 0;
3355
3576
  }
3356
- function runUpdate(args, json, face = false, shippedTarget) {
3577
+ async function runUpdate(args, json, face = null, shippedTarget) {
3357
3578
  const dryRun = args.includes("--dry-run");
3358
3579
  const verbose = args.includes("--verbose");
3359
3580
  const narrate = (line) => {
@@ -3362,20 +3583,35 @@ function runUpdate(args, json, face = false, shippedTarget) {
3362
3583
  };
3363
3584
  const progressive = !json && !verbose && Boolean(process.stderr.isTTY);
3364
3585
  if (!progressive) {
3365
- return { summary: reconcile({ dryRun, narrate: verbose ? narrate : void 0, shippedTarget }), rendered: false };
3586
+ return reconcile({ dryRun, narrate: verbose ? narrate : void 0, shippedTarget }).then((summary2) => ({ summary: summary2, rendered: false }));
3366
3587
  }
3367
3588
  const reportOptions = humanOptions(dryRun);
3368
- const spinner = startSpinner(true);
3589
+ const spinner = createSpinner(face ?? updaterFace(reportOptions), { animate: true, stream: process.stderr });
3590
+ let spinning = false;
3591
+ const phase = (line) => {
3592
+ if (spinning) spinner.say(line);
3593
+ else {
3594
+ spinner.start(line);
3595
+ spinning = true;
3596
+ }
3597
+ };
3598
+ const persist = (line) => {
3599
+ if (spinning) {
3600
+ spinner.stop();
3601
+ spinning = false;
3602
+ }
3603
+ process.stdout.write(line + "\n");
3604
+ };
3369
3605
  let headerEmitted = false;
3370
3606
  let summary;
3371
- const { emit, stream } = heldOutput((line) => spinner.persist(line));
3607
+ const { emit, stream } = heldOutput(persist);
3372
3608
  let arms = 0;
3373
3609
  let mark = Date.now();
3374
3610
  try {
3375
- summary = reconcile({
3611
+ summary = await reconcile({
3376
3612
  dryRun,
3377
3613
  shippedTarget,
3378
- onPhase: (line) => spinner.label(line),
3614
+ onPhase: (line) => phase(line),
3379
3615
  onTargetResolved: face ? void 0 : (target) => {
3380
3616
  emit(updateHeader(target, dryRun, reportOptions));
3381
3617
  emit("");
@@ -3385,7 +3621,7 @@ function runUpdate(args, json, face = false, shippedTarget) {
3385
3621
  const seconds = (Date.now() - mark) / 1e3;
3386
3622
  mark = Date.now();
3387
3623
  if (arms++) stream();
3388
- emit(face ? checklistLine(arm, seconds, reportOptions) : progressiveRow(arm, reportOptions));
3624
+ emit(face ? checklistLine(arm, seconds, face, reportOptions) : progressiveRow(arm, reportOptions));
3389
3625
  }
3390
3626
  });
3391
3627
  } finally {
@@ -3398,7 +3634,7 @@ function runUpdate(args, json, face = false, shippedTarget) {
3398
3634
  else process.stdout.write(formatReconcileReport(summary, reportOptions));
3399
3635
  return { summary, rendered: true };
3400
3636
  }
3401
- function main(args = process.argv.slice(2)) {
3637
+ async function main(args = process.argv.slice(2)) {
3402
3638
  if (args.includes("--version")) {
3403
3639
  process.stdout.write(ownVersion() + "\n");
3404
3640
  return 0;
@@ -3415,18 +3651,19 @@ function main(args = process.argv.slice(2)) {
3415
3651
  return status.failures.length ? 1 : 0;
3416
3652
  }
3417
3653
  if (command === "update") {
3418
- const face = openFace(args, json);
3419
- const { summary, rendered } = runUpdate(args, json, face);
3654
+ const dryRun = args.includes("--dry-run");
3655
+ const face = openFace(args, json, dryRun);
3656
+ const { summary, rendered } = await runUpdate(args, json, face);
3420
3657
  if (summary.reexec) return handOff(summary.reexec, args);
3421
- if (face) finishFace(summary, null, args.includes("--dry-run"));
3422
- else if (!rendered) printReconcile(summary, json, args.includes("--dry-run"));
3658
+ if (face) finishFace(face, summary, null, dryRun);
3659
+ else if (!rendered) printReconcile(summary, json, dryRun);
3423
3660
  return summary.exit;
3424
3661
  }
3425
3662
  if (command === "install") {
3426
- const face = openFace(args, json);
3427
3663
  const dryRun = args.includes("--dry-run");
3664
+ const face = openFace(args, json, dryRun);
3428
3665
  const shippedTarget = shippedTargetFrom(args);
3429
- const { summary, rendered } = runUpdate(args, json, face, shippedTarget);
3666
+ const { summary, rendered } = await runUpdate(args, json, face, shippedTarget);
3430
3667
  if (summary.reexec) {
3431
3668
  const handedOff = handOff(summary.reexec, ["update", ...args.filter((arg) => arg !== "install" && arg !== "--shipped" && arg !== shippedTarget)]);
3432
3669
  const scheduled = installTask();
@@ -3443,7 +3680,7 @@ function main(args = process.argv.slice(2)) {
3443
3680
  if (json) {
3444
3681
  process.stdout.write(JSON.stringify({ convergence: summary, autoupdate: schedule }, null, 2) + "\n");
3445
3682
  } else if (face) {
3446
- finishFace(summary, schedule, dryRun);
3683
+ finishFace(face, summary, schedule, dryRun);
3447
3684
  } else {
3448
3685
  if (!rendered) printReconcile(summary, false, dryRun);
3449
3686
  process.stdout.write(schedule.ok ? ` Auto-update on \u2014 hourly task "MMI Fleet Updater".
@@ -3468,17 +3705,24 @@ function main(args = process.argv.slice(2)) {
3468
3705
  return 1;
3469
3706
  }
3470
3707
  if (typeof require !== "undefined" && require.main === module) {
3471
- process.exit(main());
3708
+ main().then((code) => process.exit(code)).catch((error) => {
3709
+ process.stderr.write(`mmi-hub: ${error?.stack ?? error}
3710
+ `);
3711
+ process.exit(1);
3712
+ });
3472
3713
  }
3473
3714
  // Annotate the CommonJS export names for ESM import in node:
3474
3715
  0 && (module.exports = {
3475
3716
  ACCENT,
3717
+ FACE_PRODUCT,
3476
3718
  MM_GREEN,
3477
3719
  RESET,
3478
3720
  accent,
3479
3721
  acquireSharedDocLock,
3722
+ attentionRows,
3480
3723
  brandLine,
3481
3724
  buildWatcherCommand,
3725
+ checkAtomicity,
3482
3726
  checklistLine,
3483
3727
  claudeArm,
3484
3728
  claudeHostEvidence,
@@ -3518,7 +3762,6 @@ if (typeof require !== "undefined" && require.main === module) {
3518
3762
  progressiveRow,
3519
3763
  readLaunchTarget,
3520
3764
  readPending,
3521
- receipt,
3522
3765
  receiptText,
3523
3766
  reconcile,
3524
3767
  reconcileCursorUserHooks,
@@ -3533,15 +3776,13 @@ if (typeof require !== "undefined" && require.main === module) {
3533
3776
  sharedDocLockTimeoutReceipt,
3534
3777
  shippedTargetFrom,
3535
3778
  shouldHandOff,
3536
- signOff,
3537
- startSpinner,
3538
3779
  surfaceArmPlan,
3539
3780
  systemdServiceUnit,
3540
3781
  systemdTimerUnit,
3541
3782
  taskXml,
3542
3783
  updateHeader,
3784
+ updaterFace,
3543
3785
  watcherLockPath,
3544
- welcome,
3545
3786
  withSharedDocLock,
3546
3787
  wrapWords,
3547
3788
  writeLaunchTarget,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/hub",
3
- "version": "4.4.6",
3
+ "version": "4.4.7",
4
4
  "description": "Install and maintain the MMI CLI and every present host surface from one release-gated Hub command.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -25,6 +25,9 @@
25
25
  "test": "node build.mjs && node --test test/*.test.cjs",
26
26
  "typecheck": "tsc --noEmit"
27
27
  },
28
+ "dependencies": {
29
+ "@mutmutco/installer-face": "^0.2.0"
30
+ },
28
31
  "devDependencies": {
29
32
  "@types/node": "^22.0.0",
30
33
  "esbuild": "^0.28.1",