@mutmutco/hub 4.4.6 → 4.4.8

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