@mutmutco/hub 4.4.6 → 4.4.9

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 +486 -243
  2. package/package.json +4 -1
package/dist/index.cjs CHANGED
@@ -22,12 +22,16 @@ 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,
31
+ autoupdateLine: () => autoupdateLine,
29
32
  brandLine: () => brandLine,
30
33
  buildWatcherCommand: () => buildWatcherCommand,
34
+ checkAtomicity: () => checkAtomicity,
31
35
  checklistLine: () => checklistLine,
32
36
  claudeArm: () => claudeArm,
33
37
  claudeHostEvidence: () => claudeHostEvidence,
@@ -67,7 +71,6 @@ __export(index_exports, {
67
71
  progressiveRow: () => progressiveRow,
68
72
  readLaunchTarget: () => readLaunchTarget,
69
73
  readPending: () => readPending,
70
- receipt: () => receipt,
71
74
  receiptText: () => receiptText,
72
75
  reconcile: () => reconcile,
73
76
  reconcileCursorUserHooks: () => reconcileCursorUserHooks,
@@ -82,17 +85,15 @@ __export(index_exports, {
82
85
  sharedDocLockTimeoutReceipt: () => sharedDocLockTimeoutReceipt,
83
86
  shippedTargetFrom: () => shippedTargetFrom,
84
87
  shouldHandOff: () => shouldHandOff,
85
- signOff: () => signOff,
86
- startSpinner: () => startSpinner,
87
88
  surfaceArmPlan: () => surfaceArmPlan,
88
89
  systemdServiceUnit: () => systemdServiceUnit,
89
90
  systemdTimerUnit: () => systemdTimerUnit,
90
91
  taskXml: () => taskXml,
91
92
  updateHeader: () => updateHeader,
93
+ updaterFace: () => updaterFace,
92
94
  watcherLockPath: () => watcherLockPath,
93
- welcome: () => welcome,
94
95
  withSharedDocLock: () => withSharedDocLock,
95
- wrapWords: () => wrapWords,
96
+ wrapWords: () => wrapWords2,
96
97
  writeLaunchTarget: () => writeLaunchTarget,
97
98
  writePending: () => writePending
98
99
  });
@@ -101,6 +102,207 @@ var import_node_child_process9 = require("node:child_process");
101
102
  var import_node_fs18 = require("node:fs");
102
103
  var import_node_path13 = require("node:path");
103
104
 
105
+ // node_modules/@mutmutco/installer-face/dist/index.js
106
+ var PRODUCTS = Object.freeze({
107
+ "mm-strategy": Object.freeze({
108
+ name: "MM Strategy",
109
+ accent: "38;2;249;115;22",
110
+ warm: "Welcome. Let's set up MM Strategy \u2014 about a minute."
111
+ }),
112
+ "mmi-hub": Object.freeze({
113
+ name: "mmi-hub",
114
+ accent: "38;2;125;211;252",
115
+ warm: "Welcome back. Checking your surfaces\u2026"
116
+ }),
117
+ "jerv-hub": Object.freeze({
118
+ name: "jerv-hub",
119
+ accent: "38;2;248;113;113",
120
+ warm: "Welcome back. Checking your surfaces\u2026"
121
+ }),
122
+ jervcode: Object.freeze({
123
+ name: "JervCode",
124
+ accent: "38;2;192;132;252",
125
+ warm: "Welcome back. Keeping JervCode current\u2026"
126
+ })
127
+ });
128
+ function identityFor(product) {
129
+ const identity = PRODUCTS[product];
130
+ if (!identity) {
131
+ 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(", ")})`);
132
+ }
133
+ return identity;
134
+ }
135
+ var GLYPH = Object.freeze({
136
+ diamond: "\u25C6",
137
+ hollow: "\u25C7",
138
+ bar: "\u2502",
139
+ check: "\u2714",
140
+ cross: "\u2716",
141
+ dot: "\u25CF",
142
+ boxTop: "\u256D",
143
+ boxTopEnd: "\u256E",
144
+ boxBottom: "\u2570",
145
+ boxBottomEnd: "\u256F",
146
+ rule: "\u2500"
147
+ });
148
+ var PALETTE = Object.freeze({
149
+ green: "38;2;31;209;138",
150
+ ink: "38;2;237;237;237",
151
+ muted: "38;2;150;150;150",
152
+ line: "38;2;42;42;42"
153
+ });
154
+ var TITLE_COLUMN = 6;
155
+ var ANSI = /\u001b\[[0-9;]*m/g;
156
+ function visibleWidth(text) {
157
+ return [...String(text).replace(ANSI, "")].length;
158
+ }
159
+ function faceWidth(columns) {
160
+ const raw = Number(columns);
161
+ return Math.max(40, Math.min(100, Number.isFinite(raw) && raw > 0 ? raw : 100));
162
+ }
163
+ function wrapWords(text, width) {
164
+ const limit = Math.max(8, Math.floor(width));
165
+ const lines = [];
166
+ let line = "";
167
+ for (const word of String(text).split(/\s+/u).filter(Boolean)) {
168
+ let token = word;
169
+ while (visibleWidth(token) > limit) {
170
+ if (line) {
171
+ lines.push(line);
172
+ line = "";
173
+ }
174
+ lines.push([...token].slice(0, limit).join(""));
175
+ token = [...token].slice(limit).join("");
176
+ }
177
+ if (!line) line = token;
178
+ else if (visibleWidth(line) + 1 + visibleWidth(token) <= limit) line += ` ${token}`;
179
+ else {
180
+ lines.push(line);
181
+ line = token;
182
+ }
183
+ }
184
+ if (line) lines.push(line);
185
+ return lines.length ? lines : [""];
186
+ }
187
+ function createFace({ product, color = false, columns }) {
188
+ const identity = identityFor(product);
189
+ const width = faceWidth(columns);
190
+ const paint = (sgr, text) => color ? `\x1B[${sgr}m${text}\x1B[0m` : String(text);
191
+ const bar = () => paint(PALETTE.muted, GLYPH.bar);
192
+ const indent = " ".repeat(TITLE_COLUMN - 1);
193
+ const welcome = () => [
194
+ `${paint(identity.accent, GLYPH.diamond)} ${paint(identity.accent, identity.name)} \u2014 Mutatis Mutandis`,
195
+ bar(),
196
+ `${bar()} ${identity.warm}`,
197
+ bar()
198
+ ];
199
+ const step = (title, seconds = null, kind = "ok") => {
200
+ const glyph = kind === "fail" ? paint(identity.accent, GLYPH.cross) : kind === "note" ? paint(PALETTE.muted, GLYPH.hollow) : paint(PALETTE.green, GLYPH.check);
201
+ const time = seconds === null || seconds === void 0 ? "" : paint(PALETTE.muted, `${Math.max(0, Math.round(seconds))}s`);
202
+ const column = Math.min(44, Math.max(0, width - 8));
203
+ const reserved = time ? 6 : 0;
204
+ const [first, ...rest] = wrapWords(title, width - TITLE_COLUMN - reserved);
205
+ const head = `${bar()} ${glyph} ${first}`;
206
+ const pad = Math.max(1, column - visibleWidth(head));
207
+ return [time ? `${head}${" ".repeat(pad)}${time}` : head, ...rest.map((line) => `${bar()}${indent}${line}`)].join("\n");
208
+ };
209
+ const relay = (text) => String(text).split("\n").map((line) => line.trim() === "" ? bar() : `${bar()}${indent}${line}`).join("\n");
210
+ const receipt = (lines) => {
211
+ const body = (Array.isArray(lines) ? lines : String(lines).split("\n")).flatMap((raw) => {
212
+ const line = String(raw);
213
+ if (visibleWidth(line) <= width - 6) return [line];
214
+ const lead = /^\s*/u.exec(line)?.[0] ?? "";
215
+ return wrapWords(line, width - 6 - lead.length).map((part) => `${lead}${part}`);
216
+ });
217
+ const content = Math.min(width - 6, Math.max(...body.map(visibleWidth)));
218
+ const rule = paint(identity.accent, GLYPH.rule.repeat(content + 4));
219
+ const frame = (left, right) => `${paint(identity.accent, left)}${rule}${paint(identity.accent, right)}`;
220
+ const bodyRow = (line) => line.startsWith(GLYPH.check) ? `${paint(PALETTE.green, GLYPH.check)}${line.slice(1)}` : line;
221
+ return [
222
+ frame(GLYPH.boxTop, GLYPH.boxTopEnd),
223
+ ...body.map((line) => `${paint(identity.accent, GLYPH.bar)} ${bodyRow(line)}${" ".repeat(Math.max(0, content - visibleWidth(line)))} ${paint(identity.accent, GLYPH.bar)}`),
224
+ frame(GLYPH.boxBottom, GLYPH.boxBottomEnd)
225
+ ];
226
+ };
227
+ const signOff = () => `${paint(identity.accent, GLYPH.diamond)} ${paint(identity.accent, `${identity.name} \xB7 Mutatis Mutandis`)}`;
228
+ const refusal = (message3) => `${bar()} ${paint(identity.accent, GLYPH.cross)} ${message3}`;
229
+ return { identity, width, welcome, step, relay, receipt, signOff, refusal, paint };
230
+ }
231
+ var RELAY_INDENT = " ".repeat(TITLE_COLUMN - 1);
232
+ var SEPARATOR = "\xB7";
233
+ var subjectOf = (row) => row.aspect ? `${row.surface} ${row.aspect}` : row.surface;
234
+ var groupKey = (row) => `${subjectOf(row)}\0${row.state}\0${row.mark ?? ""}\0${row.note ?? ""}`;
235
+ function renderRows(face, rows) {
236
+ if (rows.length === 0) return [];
237
+ const groups = /* @__PURE__ */ new Map();
238
+ for (const row of rows) {
239
+ const key = groupKey(row);
240
+ const found = groups.get(key);
241
+ if (found) {
242
+ if (row.instance) found.instances.push(row.instance);
243
+ } else {
244
+ groups.set(key, { row, instances: row.instance ? [row.instance] : [] });
245
+ }
246
+ }
247
+ const subjects = [...groups.values()].map(({ row, instances }) => instances.length > 1 ? subjectOf(row) : [subjectOf(row), instances[0]].filter(Boolean).join(` ${SEPARATOR} `));
248
+ const column = Math.min(Math.max(...subjects.map(visibleWidth)), Math.max(12, face.width - 24));
249
+ const lines = [];
250
+ for (const [index, { row, instances }] of [...groups.values()].entries()) {
251
+ const subject = subjects[index];
252
+ const tail2 = instances.length > 1 ? `${row.state} ${SEPARATOR} ${instances.join(", ")}` : row.state;
253
+ const body = `${clip(subject, column).padEnd(column)} ${row.mark ? `${markGlyph(face, row.mark)} ` : ""}${tail2}`;
254
+ lines.push(...face.relay(body).split("\n"));
255
+ if (row.note) {
256
+ for (const part of wrapWords(row.note, Math.max(16, face.width - TITLE_COLUMN))) {
257
+ lines.push(...face.relay(part).split("\n"));
258
+ }
259
+ }
260
+ }
261
+ return lines;
262
+ }
263
+ function markGlyph(face, kind) {
264
+ const glyph = kind === "fail" ? GLYPH.cross : kind === "note" ? GLYPH.hollow : GLYPH.check;
265
+ return face.paint(kind === "fail" ? face.identity.accent : kind === "note" ? "38;2;150;150;150" : "38;2;31;209;138", glyph);
266
+ }
267
+ function clip(text, width) {
268
+ if (visibleWidth(text) <= width) return text;
269
+ return `\u2026${[...text].slice(-(width - 1)).join("")}`;
270
+ }
271
+ var FRAMES = ["\u25D2", "\u25D0", "\u25D3", "\u25D1"];
272
+ function createSpinner(face, { animate, stream = process.stderr, intervalMs = 90 }) {
273
+ let timer = null;
274
+ let text = "";
275
+ let frame = 0;
276
+ const clear = () => {
277
+ if (animate) stream.write("\r\x1B[2K");
278
+ };
279
+ const draw = () => {
280
+ if (!animate) return;
281
+ const line = face.step(text, null, "note").split("\n")[0].replace(GLYPH.hollow, FRAMES[frame % FRAMES.length]);
282
+ stream.write(`\r\x1B[2K${line}`);
283
+ frame += 1;
284
+ };
285
+ return {
286
+ start(next) {
287
+ text = next;
288
+ frame = 0;
289
+ draw();
290
+ if (animate && !timer) timer = setInterval(draw, intervalMs).unref();
291
+ },
292
+ say(next) {
293
+ text = next;
294
+ draw();
295
+ },
296
+ stop() {
297
+ if (timer) clearInterval(timer);
298
+ timer = null;
299
+ clear();
300
+ }
301
+ };
302
+ }
303
+ var SPINNER_FRAMES = Object.freeze([...FRAMES]);
304
+ var ALLOWED = new Set(Object.values(GLYPH));
305
+
104
306
  // src/state.ts
105
307
  var import_node_fs = require("node:fs");
106
308
  var import_node_os = require("node:os");
@@ -165,11 +367,12 @@ function withLease(path, fn, waitMs = LEASE_WAIT_MS) {
165
367
  for (; ; ) {
166
368
  const attempt = tryAcquire(path);
167
369
  if (typeof attempt === "object") {
168
- try {
169
- return fn();
170
- } finally {
171
- attempt.release();
370
+ const result = fn();
371
+ if (result && typeof result.then === "function") {
372
+ return result.finally(() => attempt.release());
172
373
  }
374
+ attempt.release();
375
+ return result;
173
376
  }
174
377
  if (Date.now() >= deadline) {
175
378
  throw new UpdaterBusy(`another updater holds ${path} (waited ${waitMs}ms) \u2014 deferring`);
@@ -188,23 +391,29 @@ function appendJournal(path, event) {
188
391
  return false;
189
392
  }
190
393
  }
191
- function journalHighWater(path, surface) {
394
+ function journalHighWater(path, surface, events) {
192
395
  let high = null;
396
+ for (const event of events ?? parseJournal(path)) {
397
+ if (event.dryRun) continue;
398
+ if (event.surface === surface && event.verdict === "ok" && event.to && (!high || compareSemver(event.to, high) > 0)) {
399
+ high = event.to;
400
+ }
401
+ }
402
+ return high;
403
+ }
404
+ function parseJournal(path) {
405
+ const events = [];
193
406
  try {
194
407
  for (const line of (0, import_node_fs.readFileSync)(path, "utf8").split("\n")) {
195
408
  if (!line.trim()) continue;
196
409
  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
- }
410
+ events.push(JSON.parse(line));
202
411
  } catch {
203
412
  }
204
413
  }
205
414
  } catch {
206
415
  }
207
- return high;
416
+ return events;
208
417
  }
209
418
  function journalSurfaceRepeatedFail(path, surface, currentRun) {
210
419
  let lastVerdict = null;
@@ -255,6 +464,9 @@ function fileExists(path) {
255
464
  return (0, import_node_fs.existsSync)(path);
256
465
  }
257
466
 
467
+ // src/reconcile.ts
468
+ var import_node_perf_hooks2 = require("node:perf_hooks");
469
+
258
470
  // src/npm.ts
259
471
  var import_node_child_process = require("node:child_process");
260
472
  var import_node_fs2 = require("node:fs");
@@ -295,10 +507,40 @@ function runNpm(args, env = process.env, options = {}) {
295
507
  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
508
  return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
297
509
  }
510
+ function runNpmAsync(args, env = process.env, options = {}) {
511
+ const npm = resolveNpm(env);
512
+ const [file, argv, shell] = npm.kind === "node-script" ? [process.execPath, [npm.script, ...args], false] : [npm.command, args, process.platform === "win32"];
513
+ return new Promise((resolve) => {
514
+ let stdout = "";
515
+ let stderr = "";
516
+ let child;
517
+ try {
518
+ child = (0, import_node_child_process.spawn)(file, argv, { env, cwd: options.cwd, windowsHide: true, shell });
519
+ } catch (error) {
520
+ resolve({ status: null, stdout, stderr: String(error?.message ?? error) });
521
+ return;
522
+ }
523
+ child.stdout?.setEncoding("utf8");
524
+ child.stderr?.setEncoding("utf8");
525
+ child.stdout?.on("data", (chunk) => {
526
+ stdout += chunk;
527
+ });
528
+ child.stderr?.on("data", (chunk) => {
529
+ stderr += chunk;
530
+ });
531
+ child.on("error", (error) => resolve({ status: null, stdout, stderr: String(error?.message ?? error) }));
532
+ child.on("close", (status) => resolve({ status, stdout, stderr }));
533
+ });
534
+ }
535
+ var globalRootMemo = /* @__PURE__ */ new WeakMap();
298
536
  function globalRoot(env = process.env) {
537
+ const key = env;
538
+ const memoized = globalRootMemo.get(key);
539
+ if (memoized !== void 0) return memoized;
299
540
  const root = runNpm(["root", "-g"], env);
300
- if (root.status !== 0 || !root.stdout.trim()) return null;
301
- return root.stdout.trim();
541
+ const resolved = root.status !== 0 || !root.stdout.trim() ? null : root.stdout.trim();
542
+ globalRootMemo.set(key, resolved);
543
+ return resolved;
302
544
  }
303
545
  function npmView(spec, field, env = process.env) {
304
546
  const args = ["view", spec, "--json"];
@@ -318,8 +560,15 @@ function parseNpmDistIntegrity(value) {
318
560
  return typeof only === "string" && only.length > 0 ? only : null;
319
561
  }
320
562
  var npmStringField = parseNpmDistIntegrity;
321
- function npmDistIntegrity(spec, env = process.env) {
322
- return parseNpmDistIntegrity(npmView(spec, "dist.integrity", env));
563
+ async function npmDistIntegrityAsync(spec, env = process.env) {
564
+ const args = ["view", spec, "dist.integrity", "--json"];
565
+ const result = await runNpmAsync(args, env);
566
+ if (result.status !== 0 || !result.stdout.trim()) return null;
567
+ try {
568
+ return parseNpmDistIntegrity(JSON.parse(result.stdout));
569
+ } catch {
570
+ return null;
571
+ }
323
572
  }
324
573
  function npmVersions(packageName, env = process.env) {
325
574
  const versions = npmView(packageName, "versions", env);
@@ -399,38 +648,60 @@ function gateCandidate(cachePath, repoUrl, candidate) {
399
648
  }
400
649
  return { ok: true, tagCommit: commit, bom };
401
650
  }
402
- function checkAtomicity(cachePath, tagCommit, bom, candidate, npm) {
403
- const rows = [];
404
- for (const row of npmPackRows(bom)) {
651
+ var ATOMICITY_CONCURRENCY = 4;
652
+ async function mapBounded(items, bound, fn) {
653
+ const results = new Array(items.length);
654
+ let next = 0;
655
+ const workers = Array.from({ length: Math.min(bound, items.length) }, async () => {
656
+ for (; ; ) {
657
+ const index = next++;
658
+ if (index >= items.length) return;
659
+ results[index] = await fn(items[index], index);
660
+ }
661
+ });
662
+ await Promise.all(workers);
663
+ return results;
664
+ }
665
+ async function checkAtomicity(cachePath, tagCommit, bom, candidate, npm) {
666
+ const bomRows = npmPackRows(bom);
667
+ const verdicts = new Array(bomRows.length).fill(null);
668
+ const pending = [];
669
+ bomRows.forEach((row, index) => {
405
670
  if (row.version !== candidate) {
406
- rows.push({ id: row.id, ok: false, detail: `BOM row at ${row.version}, candidate ${candidate}` });
407
- continue;
671
+ verdicts[index] = { id: row.id, ok: false, detail: `BOM row at ${row.version}, candidate ${candidate}` };
672
+ return;
408
673
  }
409
674
  const manifestText = showFile(cachePath, tagCommit, `${row.identity.packagePath}/package.json`);
410
675
  let name;
411
- let manifestVersion;
676
+ let manifestVersion2;
412
677
  try {
413
678
  const manifest2 = JSON.parse(manifestText ?? "");
414
679
  name = manifest2.name;
415
- manifestVersion = manifest2.version;
680
+ manifestVersion2 = manifest2.version;
416
681
  } catch {
417
682
  }
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;
683
+ if (!name || manifestVersion2 !== candidate) {
684
+ verdicts[index] = { id: row.id, name, ok: false, detail: `package.json at ${row.identity.packagePath} is not stamped ${candidate}` };
685
+ return;
421
686
  }
422
- const integrity = npm.distIntegrity(`${name}@${candidate}`);
687
+ pending.push({ index, id: row.id, name });
688
+ });
689
+ const integrities = await mapBounded(pending, ATOMICITY_CONCURRENCY, (entry) => Promise.resolve(npm.distIntegrity(`${entry.name}@${candidate}`)));
690
+ pending.forEach((entry, i) => {
691
+ const integrity = integrities[i];
423
692
  if (!integrity) {
424
- rows.push({ id: row.id, name, ok: false, detail: `${name}@${candidate} not on npm` });
425
- continue;
693
+ verdicts[entry.index] = { id: entry.id, name: entry.name, ok: false, detail: `${entry.name}@${candidate} not on npm` };
694
+ return;
426
695
  }
696
+ const row = bomRows[entry.index];
427
697
  const legacySsri = /^sha\d+-/.test(row.identity.value ?? "");
428
698
  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;
699
+ verdicts[entry.index] = { id: entry.id, name: entry.name, ok: false, detail: `${entry.name}@${candidate} dist.integrity differs from BOM identity` };
700
+ return;
431
701
  }
432
- rows.push({ id: row.id, name, ok: true, detail: legacySsri ? `${name}@${candidate} published, integrity matches` : `${name}@${candidate} published` });
433
- }
702
+ verdicts[entry.index] = { id: entry.id, name: entry.name, ok: true, detail: legacySsri ? `${entry.name}@${candidate} published, integrity matches` : `${entry.name}@${candidate} published` };
703
+ });
704
+ const rows = verdicts.map((row, index) => row ?? { id: bomRows[index].id, ok: false, detail: "atomicity probe produced no verdict" });
434
705
  return { ok: rows.length > 0 && rows.every((row) => row.ok), rows };
435
706
  }
436
707
 
@@ -606,6 +877,15 @@ function probeVersion(distPath) {
606
877
  function tail(output) {
607
878
  return output.split("\n").filter(Boolean).slice(-3).join(" | ");
608
879
  }
880
+ function manifestVersion(packageRoot) {
881
+ if (!packageRoot) return null;
882
+ try {
883
+ const version = JSON.parse((0, import_node_fs5.readFileSync)((0, import_node_path4.join)(packageRoot, "package.json"), "utf8")).version;
884
+ return typeof version === "string" ? version : null;
885
+ } catch {
886
+ return null;
887
+ }
888
+ }
609
889
  function stagePackage(options) {
610
890
  const { surface, packageName, target, witness, paths, env } = options;
611
891
  const stageRoot = (0, import_node_path4.join)(paths.staging, surface, target);
@@ -633,12 +913,17 @@ function npmGlobalArm(options) {
633
913
  const { surface, packageName, distRel, target, dryRun, paths } = options;
634
914
  const spec = `${packageName}@${target}`;
635
915
  const result = (from, to, verdict, detail) => ({ surface, from, to, verdict, detail });
916
+ const highWater = journalHighWater(paths.journalPath, surface, options.journalEvents);
917
+ const localRoot = globalRoot(env);
918
+ const localVersion = localRoot ? manifestVersion((0, import_node_path4.join)(localRoot, ...packageName.split("/"))) : null;
919
+ if (localVersion === target && highWater === target) {
920
+ return result(localVersion, target, "ok", `already at target ${target} (installed manifest + journal high-water)`);
921
+ }
636
922
  const dist = globalDist(env, packageName, distRel);
637
923
  const installed = dist ? probeVersion(dist) : null;
638
924
  if (installed && compareSemver(installed, target) > 0) {
639
925
  return result(installed, installed, "skip", `installed ${installed} is above candidate ${target} (monotonic)`);
640
926
  }
641
- const highWater = journalHighWater(paths.journalPath, surface);
642
927
  if (highWater && compareSemver(highWater, target) > 0) {
643
928
  return result(installed, highWater, "skip", `journal high-water ${highWater} is above candidate ${target} (monotonic)`);
644
929
  }
@@ -862,7 +1147,7 @@ function claudeArm(options) {
862
1147
  if (from && compareSemver(from, target) > 0) {
863
1148
  return result(from, from, "skip", `installed ${from} is above candidate ${target} (monotonic)`);
864
1149
  }
865
- const highWater = journalHighWater(paths.journalPath, "claude");
1150
+ const highWater = journalHighWater(paths.journalPath, "claude", options.journalEvents);
866
1151
  if (highWater && compareSemver(highWater, target) > 0) {
867
1152
  return result(from, highWater, "skip", `journal high-water ${highWater} is above candidate ${target} (monotonic)`);
868
1153
  }
@@ -1422,7 +1707,7 @@ function cursorArm(options) {
1422
1707
  if (installed && compareSemver(installed, target) > 0) {
1423
1708
  return result(installed, installed, "skip", `installed ${installed} is above candidate ${target} (monotonic)`);
1424
1709
  }
1425
- const highWater = journalHighWater(paths.journalPath, "cursor");
1710
+ const highWater = journalHighWater(paths.journalPath, "cursor", options.journalEvents);
1426
1711
  if (highWater && compareSemver(highWater, target) > 0) {
1427
1712
  return result(installed, highWater, "skip", `journal high-water ${highWater} is above candidate ${target} (monotonic)`);
1428
1713
  }
@@ -1566,11 +1851,11 @@ function parseListText(stdout) {
1566
1851
  }
1567
1852
  function probeJervcodeInstallation(env = process.env, runner = runHostCommand) {
1568
1853
  const manifestLocation = materializedManifestLocation(env);
1569
- const manifestVersion = installedVersion(env);
1854
+ const manifestVersion2 = installedVersion(env);
1570
1855
  const manifestEvidence = () => ({
1571
- version: manifestVersion,
1856
+ version: manifestVersion2,
1572
1857
  location: manifestLocation,
1573
- detail: manifestVersion ? "materialized pi package manifest" : "materialized pi package manifest was unreadable"
1858
+ detail: manifestVersion2 ? "materialized pi package manifest" : "materialized pi package manifest was unreadable"
1574
1859
  });
1575
1860
  const hosted = hostEnv(env);
1576
1861
  const command = hostCommands(env).find((bin) => runner(bin, ["--version"], hosted).status === 0);
@@ -1586,9 +1871,9 @@ function probeJervcodeInstallation(env = process.env, runner = runHostCommand) {
1586
1871
  };
1587
1872
  }
1588
1873
  const hostError = result.status !== 0 ? `${hostLocation} failed: ${failure2(result)}` : `${hostLocation} did not list npm:${PI_PACKAGE}`;
1589
- if (manifestVersion) {
1874
+ if (manifestVersion2) {
1590
1875
  return {
1591
- version: manifestVersion,
1876
+ version: manifestVersion2,
1592
1877
  location: manifestLocation,
1593
1878
  detail: `${hostError}; using materialized pi package manifest`
1594
1879
  };
@@ -1619,7 +1904,7 @@ function jervcodeArm(options) {
1619
1904
  if (!before) return result(null, options.target, "defer", `cannot read ${(0, import_node_path9.join)(jervcodeAgentDir(env), "settings.json")}`);
1620
1905
  const from = installedVersion(env);
1621
1906
  const legacy = before.filter(isLegacyMmiPiPath);
1622
- const highWater = journalHighWater(options.paths.journalPath, "jervcode");
1907
+ const highWater = journalHighWater(options.paths.journalPath, "jervcode", options.journalEvents);
1623
1908
  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
1909
  if (options.dryRun) {
1625
1910
  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 +2096,7 @@ function hermesHomes(env = process.env) {
1811
2096
  var VERDICT_RANK = { fail: 3, defer: 2, ok: 1, skip: 0 };
1812
2097
  function hermesArm(options) {
1813
2098
  const env = options.env ?? process.env;
1814
- if (!hermesHostPresent(env, options.hostCommandRunner)) {
2099
+ if (!(options.hostPresent ?? hermesHostPresent(env, options.hostCommandRunner))) {
1815
2100
  return {
1816
2101
  surface: "hermes",
1817
2102
  from: null,
@@ -1854,7 +2139,7 @@ function armHermesHome(home, env, options, primary) {
1854
2139
  return result(installed, options.target, "defer", `${live} carries no ${PACKAGE2} package identity \u2014 refusing to displace an unmanaged Hermes mmi plugin`);
1855
2140
  }
1856
2141
  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;
2142
+ const highWater = primary ? journalHighWater(options.paths.journalPath, "hermes", options.journalEvents) : null;
1858
2143
  if (highWater && compareSemver(highWater, options.target) > 0) return result(installed, highWater, "skip", `journal high-water ${highWater} is above candidate ${options.target} (monotonic)`);
1859
2144
  const hostRunner = options.hostCommandRunner ?? runHostCommand;
1860
2145
  if (installed === options.target && treeHealthy2(live)) {
@@ -1956,6 +2241,10 @@ function probeHermesLaunchStaging(env = process.env) {
1956
2241
  // src/reap.ts
1957
2242
  var import_node_fs14 = require("node:fs");
1958
2243
  var import_node_path11 = require("node:path");
2244
+ var import_node_perf_hooks = require("node:perf_hooks");
2245
+ function elapsedMs(startedAt) {
2246
+ return Math.round((import_node_perf_hooks.performance.now() - startedAt) * 10) / 10;
2247
+ }
1959
2248
  var KEEP_GENERATIONS2 = 2;
1960
2249
  var SCRATCH_AGE_MS = 24 * 60 * 6e4;
1961
2250
  function listDirs(path) {
@@ -2052,10 +2341,11 @@ function reap(options) {
2052
2341
  const { paths, dryRun } = options;
2053
2342
  const env = options.env ?? process.env;
2054
2343
  const guarded = (category, run) => {
2344
+ const startedAt = import_node_perf_hooks.performance.now();
2055
2345
  try {
2056
- return run();
2346
+ return { ...run(), durationMs: elapsedMs(startedAt) };
2057
2347
  } catch (error) {
2058
- return { category, reaped: 0, verdict: "fail", detail: `reap failed: ${error?.message ?? error}` };
2348
+ return { category, reaped: 0, verdict: "fail", detail: `reap failed: ${error?.message ?? error}`, durationMs: elapsedMs(startedAt) };
2059
2349
  }
2060
2350
  };
2061
2351
  const atomicWritePaths = [claudeMarketplacesPath(env)];
@@ -2072,7 +2362,7 @@ function shouldHandOff(self, options) {
2072
2362
  if (!options.dist) return false;
2073
2363
  return self.verdict === "ok" && Boolean(self.from) && self.from !== self.to;
2074
2364
  }
2075
- function reconcile(options) {
2365
+ async function reconcile(options) {
2076
2366
  const env = options.env ?? process.env;
2077
2367
  const shipped = options.shippedTarget?.trim() || null;
2078
2368
  const say = (line) => {
@@ -2088,9 +2378,9 @@ function reconcile(options) {
2088
2378
  if (!appendJournal(paths.journalPath, { ts: (/* @__PURE__ */ new Date()).toISOString(), run, ...stamped })) summary.journalOk = false;
2089
2379
  };
2090
2380
  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) => {
2381
+ const recordArm = (arm, durationMs) => {
2092
2382
  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 });
2383
+ journal({ kind: "arm", surface: arm.surface, from: arm.from, to: arm.to, verdict: arm.verdict, detail: arm.detail, compat: arm.compat, durationMs });
2094
2384
  options.onArm?.(arm);
2095
2385
  if (arm.verdict === "ok" && !options.dryRun && !arm.launchStaged) clearPending(paths, arm.surface);
2096
2386
  if (arm.verdict === "defer") summary.exit = Math.max(summary.exit, 2);
@@ -2099,7 +2389,8 @@ function reconcile(options) {
2099
2389
  try {
2100
2390
  say("acquiring single-writer lease");
2101
2391
  const leaseWaitMs = Number(env.MMI_UPDATER_LEASE_WAIT_MS) || void 0;
2102
- return withLease(paths.leasePath, () => {
2392
+ return await withLease(paths.leasePath, async () => {
2393
+ const journalEvents = parseJournal(paths.journalPath);
2103
2394
  if (!options.dryRun) {
2104
2395
  const pendingBefore = readPending(paths);
2105
2396
  if (pendingBefore.length) {
@@ -2108,7 +2399,7 @@ function reconcile(options) {
2108
2399
  }
2109
2400
  }
2110
2401
  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 });
2402
+ 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
2403
  for (const row of summary.reap.filter((entry) => entry.verdict !== "skip")) say(`reap ${row.category}: ${row.verdict}${row.detail ? " \u2014 " + row.detail : ""}`);
2113
2404
  if (shipped) {
2114
2405
  say(`shipped target v${shipped} \u2014 the payload is the release; no npm poll, no gate`);
@@ -2118,41 +2409,48 @@ function reconcile(options) {
2118
2409
  journal({ kind: "target", to: shipped, verdict: "ok", detail: "shipped payload" });
2119
2410
  } else {
2120
2411
  say("polling npm for @mutmutco/cli versions");
2412
+ const pollStartedAt = import_node_perf_hooks2.performance.now();
2121
2413
  const versions = npmVersions("@mutmutco/cli", env).sort((a, b) => compareSemver(b, a));
2414
+ const pollMs = elapsedMs(pollStartedAt);
2122
2415
  if (!versions.length) {
2123
- journal({ kind: "poll", verdict: "fail", detail: "@mutmutco/cli has no npm versions \u2014 cannot discover a candidate" });
2416
+ journal({ kind: "poll", verdict: "fail", durationMs: pollMs, detail: "@mutmutco/cli has no npm versions \u2014 cannot discover a candidate" });
2124
2417
  summary.exit = 1;
2125
2418
  summary.detail = "npm poll returned no versions for @mutmutco/cli";
2126
2419
  return summary;
2127
2420
  }
2421
+ journal({ kind: "poll", verdict: "ok", count: versions.length, durationMs: pollMs, detail: `${versions.length} versions; newest ${versions[0]}` });
2128
2422
  say("fetching release BOM gate cache");
2423
+ const fetchStartedAt = import_node_perf_hooks2.performance.now();
2129
2424
  ensureBareCache(paths.gitCache, repoUrl);
2130
2425
  fetchIntoCache(paths.gitCache, repoUrl, "refs/heads/main:refs/heads/main");
2426
+ journal({ kind: "gate", surface: "cache", verdict: "ok", durationMs: elapsedMs(fetchStartedAt), detail: "release BOM gate cache: refs/heads/main fetched" });
2427
+ const walkStartedAt = import_node_perf_hooks2.performance.now();
2131
2428
  for (const candidate of versions.slice(0, bound)) {
2132
2429
  say(`gating candidate v${candidate}`);
2430
+ const candidateStartedAt = import_node_perf_hooks2.performance.now();
2133
2431
  const gate = gateCandidate(paths.gitCache, repoUrl, candidate);
2134
2432
  if (!gate.ok) {
2135
2433
  say(`candidate v${candidate} rejected (${gate.reason ?? "gate"})`);
2136
- journal({ kind: "candidate-reject", to: candidate, verdict: "skip", detail: gate.reason ?? "gate rejected" });
2434
+ journal({ kind: "candidate-reject", to: candidate, verdict: "skip", durationMs: elapsedMs(candidateStartedAt), detail: gate.reason ?? "gate rejected" });
2137
2435
  continue;
2138
2436
  }
2139
- const atomicity = checkAtomicity(paths.gitCache, gate.tagCommit, gate.bom, candidate, {
2140
- distIntegrity: (spec) => npmDistIntegrity(spec, env)
2437
+ const atomicity = await checkAtomicity(paths.gitCache, gate.tagCommit, gate.bom, candidate, {
2438
+ distIntegrity: (spec) => npmDistIntegrityAsync(spec, env)
2141
2439
  });
2142
2440
  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("; ")}` });
2441
+ 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
2442
  continue;
2145
2443
  }
2146
2444
  summary.target = candidate;
2147
2445
  summary.managedSurfaces = Array.isArray(gate.bom?.managedSurfaces) ? gate.bom.managedSurfaces.map(String) : null;
2148
2446
  summary.gate = `tag v${candidate} reachable from main, BOM stamp coherent`;
2149
2447
  summary.atomicity = atomicity;
2150
- journal({ kind: "target", to: candidate, verdict: "ok", detail: summary.gate });
2448
+ journal({ kind: "target", to: candidate, verdict: "ok", durationMs: elapsedMs(candidateStartedAt), detail: summary.gate });
2151
2449
  break;
2152
2450
  }
2153
2451
  if (!summary.target) {
2154
2452
  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` });
2453
+ 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
2454
  for (const probe of enumerateSurfaces(env)) {
2157
2455
  summary.surfaces.push({ id: probe.id, present: probe.present, action: probe.present ? "deferred-no-target" : "absent-skip" });
2158
2456
  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 +2461,7 @@ function reconcile(options) {
2163
2461
  say(`target v${summary.target} (${summary.gate})`);
2164
2462
  options.onTargetResolved?.(summary.target, summary.gate);
2165
2463
  say("updater: self-bootstrap");
2464
+ const selfStartedAt = import_node_perf_hooks2.performance.now();
2166
2465
  const self = npmGlobalArm({
2167
2466
  surface: "updater",
2168
2467
  packageName: "@mutmutco/hub",
@@ -2170,10 +2469,11 @@ function reconcile(options) {
2170
2469
  target: summary.target,
2171
2470
  dryRun: options.dryRun,
2172
2471
  paths,
2173
- env
2472
+ env,
2473
+ journalEvents
2174
2474
  });
2175
2475
  summary.updater = self;
2176
- recordArm(self);
2476
+ recordArm(self, elapsedMs(selfStartedAt));
2177
2477
  say(`updater: ${self.verdict}${self.detail ? " \u2014 " + self.detail : ""}`);
2178
2478
  const handoffDist = globalDist(env, "@mutmutco/hub", "dist/index.cjs");
2179
2479
  if (shouldHandOff(self, { dryRun: options.dryRun, dist: handoffDist, guard: env.MMI_UPDATER_REEXEC })) {
@@ -2183,6 +2483,7 @@ function reconcile(options) {
2183
2483
  return summary;
2184
2484
  }
2185
2485
  say(`cli: converging to v${summary.target}`);
2486
+ const cliStartedAt = import_node_perf_hooks2.performance.now();
2186
2487
  const cli = npmGlobalArm({
2187
2488
  surface: "cli",
2188
2489
  packageName: "@mutmutco/cli",
@@ -2190,10 +2491,11 @@ function reconcile(options) {
2190
2491
  target: summary.target,
2191
2492
  dryRun: options.dryRun,
2192
2493
  paths,
2193
- env
2494
+ env,
2495
+ journalEvents
2194
2496
  });
2195
2497
  summary.cli = cli;
2196
- recordArm(cli);
2498
+ recordArm(cli, elapsedMs(cliStartedAt));
2197
2499
  say(`cli: ${cli.verdict}${cli.detail ? " \u2014 " + cli.detail : ""}`);
2198
2500
  for (const probe of enumerateSurfaces(env)) {
2199
2501
  if (!probe.present) {
@@ -2208,11 +2510,12 @@ function reconcile(options) {
2208
2510
  continue;
2209
2511
  }
2210
2512
  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;
2513
+ const armStartedAt = import_node_perf_hooks2.performance.now();
2514
+ 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
2515
  if (arm) {
2213
2516
  summary.plugins.push(arm);
2214
2517
  summary.surfaces.push({ id: probe.id, present: true, action: `arm-${arm.verdict}` });
2215
- recordArm(arm);
2518
+ recordArm(arm, elapsedMs(armStartedAt));
2216
2519
  say(`${probe.id}: ${arm.verdict}${arm.detail ? " \u2014 " + arm.detail : ""}`);
2217
2520
  } else {
2218
2521
  summary.surfaces.push({ id: probe.id, present: true, action: "arm-pending" });
@@ -2924,28 +3227,53 @@ var ARM_TITLE = {
2924
3227
  hermes: "Arming Hermes",
2925
3228
  jervcode: "Arming JervCode"
2926
3229
  };
2927
- var CHECKLIST_TAIL = {
2928
- updated: "",
2929
- current: " current",
2930
- keep: " keep",
2931
- retry: " will retry",
2932
- failed: " failed",
2933
- pending: " pending"
3230
+ var FACE_PRODUCT = "mmi-hub";
3231
+ function updaterFace(options = {}) {
3232
+ return createFace({ product: FACE_PRODUCT, color: options.color ?? false, columns: options.width });
3233
+ }
3234
+ var STATE_WORD = {
3235
+ updated: "updated",
3236
+ current: "current",
3237
+ keep: "kept",
3238
+ retry: "will retry",
3239
+ failed: "failed",
3240
+ pending: "pending"
2934
3241
  };
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) {
3242
+ function stepKindFor(kind) {
3243
+ return kind === "failed" ? "fail" : kind === "retry" ? "note" : "ok";
3244
+ }
3245
+ function checklistLine(arm, seconds, face, options = {}) {
3246
+ const { kind } = classify(arm);
3247
+ const state = options.dryRun && kind === "updated" ? "would update" : STATE_WORD[kind];
3248
+ const title = `${ARM_TITLE[arm.surface] ?? `Arming ${arm.surface}`} \u2014 ${state}`;
3249
+ const truthful = kind === "failed" || kind === "retry" ? null : seconds;
3250
+ return face.step(title, truthful, stepKindFor(kind));
3251
+ }
3252
+ function receiptText(summary, autoupdate, options = {}) {
3253
+ const dry = options.dryRun ?? false;
3254
+ const counts = reportCounts(summary);
3255
+ const health = "Check health any time: mmi-hub status";
3256
+ if (!summary.target) {
3257
+ return [`${summary.exit >= 1 ? "\u2716" : "\u25CF"} mmi-hub is not ready \u2014 no green release yet (hourly retry)`, "", health];
3258
+ }
3259
+ const ready = counts.failed === 0;
3260
+ const glyph = counts.failed > 0 ? "\u2716" : counts.retry > 0 ? "\u25CF" : dry ? "\u25C7" : "\u2714";
3261
+ 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`;
3262
+ const tail2 = autoupdate ? ` \xB7 ${autoupdate.ok ? "auto-update on" : `auto-update NOT enabled \u2014 ${autoupdate.detail}`}` : ` \xB7 ${summary.target}${dry ? " (planned)" : " (verified)"}`;
3263
+ return [`${glyph} mmi-hub is ${ready ? "ready" : "not ready"} \u2014 ${state}${tail2}`, "", health];
3264
+ }
3265
+ function autoupdateLine(state) {
3266
+ return state.ok ? 'Auto-update on \u2014 hourly task "MMI Fleet Updater".' : `Auto-update NOT enabled \u2014 ${state.detail}`;
3267
+ }
3268
+ function attentionRows(summary) {
3269
+ return collectRows(summary).filter((row) => row.kind === "failed" || row.kind === "retry" || row.kind === "pending").map((row) => ({
3270
+ surface: row.name,
3271
+ state: statusText(row.kind, false),
3272
+ mark: row.kind === "failed" ? "fail" : "note",
3273
+ note: row.kind === "failed" ? failConsequence(row) : row.kind === "retry" ? retryHint(row.detail) : "arm not landed"
3274
+ }));
3275
+ }
3276
+ function wrapWords2(text, available) {
2949
3277
  if (available < 1) return [text];
2950
3278
  const lines = [];
2951
3279
  let current = "";
@@ -2966,7 +3294,7 @@ function noteLines(prefix, note, width) {
2966
3294
  const available = width - used - 1;
2967
3295
  const indent = available >= MIN_NOTE_SPACE ? " ".repeat(used + 1) : " ".repeat(Math.min(used + 1, Math.max(2, width - MIN_NOTE_SPACE)));
2968
3296
  const room = available >= MIN_NOTE_SPACE ? available : width - indent.length;
2969
- const parts = wrapWords(note, room);
3297
+ const parts = wrapWords2(note, room);
2970
3298
  const first = available >= MIN_NOTE_SPACE ? prefix + " " + parts[0] : prefix.replace(/\s+$/, "");
2971
3299
  const lines = [first];
2972
3300
  for (const part of parts.slice(available >= MIN_NOTE_SPACE ? 1 : 0)) lines.push(indent + part);
@@ -2989,7 +3317,7 @@ function collectRows(summary) {
2989
3317
  }
2990
3318
  function headline(detail, width) {
2991
3319
  const first = (detail ?? "").split(/(?<=[.;:])\s/)[0]?.replace(/\s+/g, " ").trim() ?? "";
2992
- return wrapWords(first || "blocked", Math.max(10, width - 4))[0];
3320
+ return wrapWords2(first || "blocked", Math.max(10, width - 4))[0];
2993
3321
  }
2994
3322
  function updateHeader(target, dryRun = false, options) {
2995
3323
  return brandLine(
@@ -3091,124 +3419,6 @@ function formatReconcileReport(summary, options = {}) {
3091
3419
  return out.join("\n") + "\n";
3092
3420
  }
3093
3421
 
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
3422
  // src/stamp.ts
3213
3423
  var import_node_fs17 = require("node:fs");
3214
3424
  var ALWAYS_ARMED = ["cli", "updater"];
@@ -3306,17 +3516,33 @@ function printReconcile(summary, json, dryRun = false) {
3306
3516
  function faceEnabled(args, json) {
3307
3517
  return !json && !args.includes("--verbose") && !process.env.NO_COLOR && process.env.TERM !== "dumb" && Boolean(process.stdout.isTTY) && Boolean(process.stderr.isTTY);
3308
3518
  }
3309
- function openFace(args, json) {
3310
- const face = faceEnabled(args, json);
3311
- if (face && !process.env.MMI_UPDATER_REEXEC) welcome(true);
3519
+ function openFace(args, json, dryRun) {
3520
+ if (!faceEnabled(args, json)) return null;
3521
+ const face = updaterFace(humanOptions(dryRun));
3522
+ if (!process.env.MMI_UPDATER_REEXEC) for (const line of face.welcome()) process.stdout.write(line + "\n");
3312
3523
  return face;
3313
3524
  }
3314
- function finishFace(summary, autoupdate, dryRun) {
3525
+ function finishFace(face, summary, autoupdate, dryRun) {
3315
3526
  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);
3527
+ const write = (line) => {
3528
+ process.stdout.write(line + "\n");
3529
+ };
3530
+ if (!summary.target) {
3531
+ process.stdout.write(formatReconcileReport(summary, { ...options, tty: false }));
3532
+ } else {
3533
+ for (const line of renderRows(face, attentionRows(summary))) write(line);
3534
+ }
3535
+ const body = receiptText(summary, autoupdate, { dryRun });
3536
+ const counts = reportCounts(summary);
3537
+ if (counts.failed > 0) {
3538
+ body.push("", "Fix: mmi-cli doctor, then mmi-hub update", `Log: ${options.journalPath}`);
3539
+ }
3540
+ const self = summary.updater;
3541
+ if (!dryRun && self && self.from && self.to && self.from !== self.to && self.verdict !== "fail") {
3542
+ 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}`);
3543
+ }
3544
+ for (const line of face.receipt(body)) write(line);
3545
+ write(face.signOff());
3320
3546
  }
3321
3547
  function handOff(reexec, args) {
3322
3548
  const child = (0, import_node_child_process9.spawnSync)(process.execPath, [reexec.dist, ...args], {
@@ -3353,7 +3579,7 @@ function shippedTargetFrom(args) {
3353
3579
  const value = (args[index + 1] ?? "").trim();
3354
3580
  return value && !value.startsWith("--") ? value : void 0;
3355
3581
  }
3356
- function runUpdate(args, json, face = false, shippedTarget) {
3582
+ async function runUpdate(args, json, face = null, shippedTarget) {
3357
3583
  const dryRun = args.includes("--dry-run");
3358
3584
  const verbose = args.includes("--verbose");
3359
3585
  const narrate = (line) => {
@@ -3362,20 +3588,35 @@ function runUpdate(args, json, face = false, shippedTarget) {
3362
3588
  };
3363
3589
  const progressive = !json && !verbose && Boolean(process.stderr.isTTY);
3364
3590
  if (!progressive) {
3365
- return { summary: reconcile({ dryRun, narrate: verbose ? narrate : void 0, shippedTarget }), rendered: false };
3591
+ return reconcile({ dryRun, narrate: verbose ? narrate : void 0, shippedTarget }).then((summary2) => ({ summary: summary2, rendered: false }));
3366
3592
  }
3367
3593
  const reportOptions = humanOptions(dryRun);
3368
- const spinner = startSpinner(true);
3594
+ const spinner = createSpinner(face ?? updaterFace(reportOptions), { animate: true, stream: process.stderr });
3595
+ let spinning = false;
3596
+ const phase = (line) => {
3597
+ if (spinning) spinner.say(line);
3598
+ else {
3599
+ spinner.start(line);
3600
+ spinning = true;
3601
+ }
3602
+ };
3603
+ const persist = (line) => {
3604
+ if (spinning) {
3605
+ spinner.stop();
3606
+ spinning = false;
3607
+ }
3608
+ process.stdout.write(line + "\n");
3609
+ };
3369
3610
  let headerEmitted = false;
3370
3611
  let summary;
3371
- const { emit, stream } = heldOutput((line) => spinner.persist(line));
3612
+ const { emit, stream } = heldOutput(persist);
3372
3613
  let arms = 0;
3373
3614
  let mark = Date.now();
3374
3615
  try {
3375
- summary = reconcile({
3616
+ summary = await reconcile({
3376
3617
  dryRun,
3377
3618
  shippedTarget,
3378
- onPhase: (line) => spinner.label(line),
3619
+ onPhase: (line) => phase(line),
3379
3620
  onTargetResolved: face ? void 0 : (target) => {
3380
3621
  emit(updateHeader(target, dryRun, reportOptions));
3381
3622
  emit("");
@@ -3385,7 +3626,7 @@ function runUpdate(args, json, face = false, shippedTarget) {
3385
3626
  const seconds = (Date.now() - mark) / 1e3;
3386
3627
  mark = Date.now();
3387
3628
  if (arms++) stream();
3388
- emit(face ? checklistLine(arm, seconds, reportOptions) : progressiveRow(arm, reportOptions));
3629
+ emit(face ? checklistLine(arm, seconds, face, reportOptions) : progressiveRow(arm, reportOptions));
3389
3630
  }
3390
3631
  });
3391
3632
  } finally {
@@ -3398,7 +3639,7 @@ function runUpdate(args, json, face = false, shippedTarget) {
3398
3639
  else process.stdout.write(formatReconcileReport(summary, reportOptions));
3399
3640
  return { summary, rendered: true };
3400
3641
  }
3401
- function main(args = process.argv.slice(2)) {
3642
+ async function main(args = process.argv.slice(2)) {
3402
3643
  if (args.includes("--version")) {
3403
3644
  process.stdout.write(ownVersion() + "\n");
3404
3645
  return 0;
@@ -3415,25 +3656,24 @@ function main(args = process.argv.slice(2)) {
3415
3656
  return status.failures.length ? 1 : 0;
3416
3657
  }
3417
3658
  if (command === "update") {
3418
- const face = openFace(args, json);
3419
- const { summary, rendered } = runUpdate(args, json, face);
3659
+ const dryRun = args.includes("--dry-run");
3660
+ const face = openFace(args, json, dryRun);
3661
+ const { summary, rendered } = await runUpdate(args, json, face);
3420
3662
  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"));
3663
+ if (face) finishFace(face, summary, null, dryRun);
3664
+ else if (!rendered) printReconcile(summary, json, dryRun);
3423
3665
  return summary.exit;
3424
3666
  }
3425
3667
  if (command === "install") {
3426
- const face = openFace(args, json);
3427
3668
  const dryRun = args.includes("--dry-run");
3669
+ const face = openFace(args, json, dryRun);
3428
3670
  const shippedTarget = shippedTargetFrom(args);
3429
- const { summary, rendered } = runUpdate(args, json, face, shippedTarget);
3671
+ const { summary, rendered } = await runUpdate(args, json, face, shippedTarget);
3430
3672
  if (summary.reexec) {
3431
3673
  const handedOff = handOff(summary.reexec, ["update", ...args.filter((arg) => arg !== "install" && arg !== "--shipped" && arg !== shippedTarget)]);
3432
3674
  const scheduled = installTask();
3433
3675
  if (!json) {
3434
- process.stdout.write(scheduled.ok ? ` Auto-update on \u2014 hourly task "MMI Fleet Updater".
3435
- ` : ` Auto-update NOT enabled \u2014 ${scheduled.detail}
3436
- `);
3676
+ process.stdout.write(autoupdateLine(scheduled) + "\n");
3437
3677
  } else {
3438
3678
  process.stdout.write(JSON.stringify({ convergence: summary, autoupdate: scheduled }, null, 2) + "\n");
3439
3679
  }
@@ -3443,12 +3683,10 @@ function main(args = process.argv.slice(2)) {
3443
3683
  if (json) {
3444
3684
  process.stdout.write(JSON.stringify({ convergence: summary, autoupdate: schedule }, null, 2) + "\n");
3445
3685
  } else if (face) {
3446
- finishFace(summary, schedule, dryRun);
3686
+ finishFace(face, summary, schedule, dryRun);
3447
3687
  } else {
3448
3688
  if (!rendered) printReconcile(summary, false, dryRun);
3449
- process.stdout.write(schedule.ok ? ` Auto-update on \u2014 hourly task "MMI Fleet Updater".
3450
- ` : ` Auto-update NOT enabled \u2014 ${schedule.detail}
3451
- `);
3689
+ process.stdout.write(autoupdateLine(schedule) + "\n");
3452
3690
  }
3453
3691
  return Math.max(summary.exit, schedule.ok ? 0 : 1);
3454
3692
  }
@@ -3468,17 +3706,25 @@ function main(args = process.argv.slice(2)) {
3468
3706
  return 1;
3469
3707
  }
3470
3708
  if (typeof require !== "undefined" && require.main === module) {
3471
- process.exit(main());
3709
+ main().then((code) => process.exit(code)).catch((error) => {
3710
+ process.stderr.write(`mmi-hub: ${error?.stack ?? error}
3711
+ `);
3712
+ process.exit(1);
3713
+ });
3472
3714
  }
3473
3715
  // Annotate the CommonJS export names for ESM import in node:
3474
3716
  0 && (module.exports = {
3475
3717
  ACCENT,
3718
+ FACE_PRODUCT,
3476
3719
  MM_GREEN,
3477
3720
  RESET,
3478
3721
  accent,
3479
3722
  acquireSharedDocLock,
3723
+ attentionRows,
3724
+ autoupdateLine,
3480
3725
  brandLine,
3481
3726
  buildWatcherCommand,
3727
+ checkAtomicity,
3482
3728
  checklistLine,
3483
3729
  claudeArm,
3484
3730
  claudeHostEvidence,
@@ -3518,7 +3764,6 @@ if (typeof require !== "undefined" && require.main === module) {
3518
3764
  progressiveRow,
3519
3765
  readLaunchTarget,
3520
3766
  readPending,
3521
- receipt,
3522
3767
  receiptText,
3523
3768
  reconcile,
3524
3769
  reconcileCursorUserHooks,
@@ -3533,15 +3778,13 @@ if (typeof require !== "undefined" && require.main === module) {
3533
3778
  sharedDocLockTimeoutReceipt,
3534
3779
  shippedTargetFrom,
3535
3780
  shouldHandOff,
3536
- signOff,
3537
- startSpinner,
3538
3781
  surfaceArmPlan,
3539
3782
  systemdServiceUnit,
3540
3783
  systemdTimerUnit,
3541
3784
  taskXml,
3542
3785
  updateHeader,
3786
+ updaterFace,
3543
3787
  watcherLockPath,
3544
- welcome,
3545
3788
  withSharedDocLock,
3546
3789
  wrapWords,
3547
3790
  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.9",
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.1"
30
+ },
28
31
  "devDependencies": {
29
32
  "@types/node": "^22.0.0",
30
33
  "esbuild": "^0.28.1",