@tokenoftrust/cli 1.4.0-rc.15 → 1.4.0-rc.16

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.
@@ -40,6 +40,7 @@
40
40
  * Dependency-free (global fetch + `git`).
41
41
  */
42
42
  import { execFileSync } from "node:child_process";
43
+ import { readFileSync } from "node:fs";
43
44
  import { createHash } from "node:crypto";
44
45
  import { setTimeout as delay } from "node:timers/promises";
45
46
  import { createMcpClient } from "../mcp.mjs";
@@ -60,6 +61,13 @@ import {
60
61
 
61
62
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
62
63
 
64
+ // The public storefront origin a shareable preview PR URL is composed against
65
+ // (see shareablePrUrl, below) — the SAME default `tot ship` uses (ship.mjs's
66
+ // DEFAULT_STOREFRONT_URL), so a preview link and a ship link always agree on
67
+ // which storefront they point at even though submit.mjs and ship.mjs never
68
+ // import from each other.
69
+ const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
70
+
63
71
  // The git ref prefix an isolated candidate push lands under (b03 — stop
64
72
  // force-pushing the SHARED `preview` ref). ONE constant so a rename is trivial —
65
73
  // provisionally coordinated with the MCP-side candidate_open resolution (b02/b04),
@@ -77,7 +85,7 @@ export function parseArgs(argv) {
77
85
  // `ref: null` — an explicit `--ref` always wins; otherwise the push target is
78
86
  // derived per-run as YOUR OWN isolated candidate ref (resolvePushRef, below),
79
87
  // never a fixed shared default.
80
- const a = { mcp: null, identity: null, ref: null, skipValidate: false, noWait: false, watch: false, noOpen: false, noCommit: false, message: null, summary: null, new: false, help: false };
88
+ const a = { mcp: null, identity: null, ref: null, skipValidate: false, noWait: false, watch: false, noOpen: false, noCommit: false, message: null, summary: null, summaryFile: null, json: false, new: false, help: false };
81
89
  for (let i = 0; i < argv.length; i++) {
82
90
  const t = argv[i];
83
91
  if (t === "--mcp") a.mcp = argv[++i];
@@ -85,6 +93,8 @@ export function parseArgs(argv) {
85
93
  else if (t === "--ref") a.ref = argv[++i];
86
94
  else if (t === "-m" || t === "--message") a.message = argv[++i];
87
95
  else if (t === "--summary") a.summary = argv[++i];
96
+ else if (t === "--summary-file") a.summaryFile = argv[++i];
97
+ else if (t === "--json") a.json = true;
88
98
  else if (t === "--skip-validate") a.skipValidate = true;
89
99
  else if (t === "--no-commit") a.noCommit = true;
90
100
  else if (t === "--no-wait") a.noWait = true;
@@ -113,8 +123,16 @@ export function renderUsage(verb = "preview") {
113
123
  tot ${verb} --ref <name> push ref (default: your own isolated candidate ref — see \`tot pr\`)
114
124
  tot ${verb} -m "<title>" one-line summary of what changed (the approver sees this)
115
125
  tot ${verb} --summary "<text>" longer description to accompany the title
126
+ tot ${verb} --summary-file <path> structured summary from a file — JSON
127
+ {intent,effect,verification,risk} or the labeled
128
+ Intent / User-visible effect / Verification /
129
+ Risk-rollback text block; pass "-" to read stdin
130
+ (mutually exclusive with --summary)
116
131
  tot ${verb} --no-wait push and exit without polling for the reconcile result
117
132
  tot ${verb} --no-open don't open the preview URL in the browser on success
133
+ tot ${verb} --json machine-readable result on stdout (candidate id, PR,
134
+ head SHA, preview URL, reconcile/compliance evidence)
135
+ — implies --no-open, no spinner
118
136
  tot ${verb} --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
119
137
 
120
138
  By default a re-run UPDATES your open candidate PR (like pushing more commits
@@ -161,14 +179,135 @@ export function buildChangeSummary({ message, summary, headSubject = "", statLin
161
179
 
162
180
  /** Print the change summary block — the SAME title/body carried into `candidate_open`
163
181
  * (below) as the PR title/description, so what the approver reads in the PR matches
164
- * what's printed here. */
165
- function printChangeSummary({ title, body, autoTitle }) {
182
+ * what's printed here. `quiet` (--json) suppresses the human-readable print — the
183
+ * same data reaches the caller via the JSON result instead (see buildJsonResult). */
184
+ function printChangeSummary({ title, body, autoTitle }, { quiet = false } = {}) {
185
+ if (quiet) return;
166
186
  console.log(`\n Change summary (for the approver / the change record):`);
167
187
  console.log(` ${title}`);
168
188
  for (const l of body) console.log(` ${l}`);
169
189
  if (autoTitle) console.log(` (auto-generated from git — pass -m "…" / --summary "…" to refine)`);
170
190
  }
171
191
 
192
+ // ─── structured candidate summary (--summary-file / stdin, P2 item 14) ──────────
193
+
194
+ /**
195
+ * The four fields the candidate body prefers (see
196
+ * docs/architecture/branch-lifecycle-and-integration-preview.md §"Change
197
+ * descriptions and AI assistance") — `key` is the JSON key, `label` the
198
+ * canonical text-block heading, `match` the label spellings
199
+ * parseLabeledSummary recognizes for that field (case-insensitive).
200
+ */
201
+ const STRUCTURED_FIELDS = [
202
+ { key: "intent", label: "Intent", match: /^intent$/i },
203
+ { key: "effect", label: "User-visible effect", match: /^(user-visible effect|effect)$/i },
204
+ { key: "verification", label: "Verification", match: /^verification$/i },
205
+ { key: "risk", label: "Risk / rollback", match: /^(risk\s*\/?\s*rollback|risk-rollback|risk)$/i },
206
+ ];
207
+
208
+ /**
209
+ * Parse the labeled four-field text block (`Intent: …` / `User-visible effect: …`
210
+ * / `Verification: …` / `Risk / rollback: …`) into `{intent, effect, verification,
211
+ * risk}` — a field's value continues across following lines until the next
212
+ * recognized label, so multi-line prose under one label is preserved. Unlabeled
213
+ * leading text (and anything before the first recognized label) is dropped —
214
+ * callers fall back to treating the whole file as freeform body when nothing
215
+ * matches at all. Pure — unit-tested.
216
+ * @param {string} text
217
+ * @returns {{intent?: string, effect?: string, verification?: string, risk?: string}}
218
+ */
219
+ export function parseLabeledSummary(text) {
220
+ const fields = {};
221
+ let current = null;
222
+ let buf = [];
223
+ const flush = () => {
224
+ if (current) fields[current] = buf.join("\n").trim();
225
+ buf = [];
226
+ };
227
+ for (const line of String(text).split(/\r?\n/)) {
228
+ const m = /^([A-Za-z][A-Za-z /-]*?)\s*:\s*(.*)$/.exec(line);
229
+ const field = m && STRUCTURED_FIELDS.find((f) => f.match.test(m[1].trim()));
230
+ if (field) {
231
+ flush();
232
+ current = field.key;
233
+ buf = m[2] ? [m[2]] : [];
234
+ } else if (current) {
235
+ buf.push(line);
236
+ }
237
+ }
238
+ flush();
239
+ return fields;
240
+ }
241
+
242
+ /**
243
+ * Parse `--summary-file` content as JSON — `{intent, effect, verification, risk}`
244
+ * (extra keys ignored, each value trimmed, blank/non-string values dropped).
245
+ * Returns null when the text isn't a JSON object at all, so the caller falls
246
+ * back to the labeled-text parser rather than treating a JSON parse error as
247
+ * "no fields". Pure — unit-tested.
248
+ * @param {string} text
249
+ * @returns {{intent?: string, effect?: string, verification?: string, risk?: string}|null}
250
+ */
251
+ export function parseJsonSummary(text) {
252
+ let obj;
253
+ try {
254
+ obj = JSON.parse(text);
255
+ } catch {
256
+ return null;
257
+ }
258
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) return null;
259
+ const fields = {};
260
+ for (const { key } of STRUCTURED_FIELDS) {
261
+ if (typeof obj[key] === "string" && obj[key].trim()) fields[key] = obj[key].trim();
262
+ }
263
+ return fields;
264
+ }
265
+
266
+ /**
267
+ * Render recognized structured fields back to the canonical labeled block — the
268
+ * SAME shape the contract prescribes — so a JSON or labeled-text `--summary-file`
269
+ * produces an identical PR/candidate body to a human writing
270
+ * `--summary "Intent: …"` by hand. Only fields actually present are emitted.
271
+ * Pure — unit-tested.
272
+ * @param {{intent?: string, effect?: string, verification?: string, risk?: string}} fields
273
+ * @returns {string[]}
274
+ */
275
+ export function formatStructuredSummary(fields) {
276
+ return STRUCTURED_FIELDS.filter(({ key }) => fields[key]).map(({ key, label }) => `${label}: ${fields[key]}`);
277
+ }
278
+
279
+ /**
280
+ * Resolve `--summary-file`/stdin content to `--summary`'s body text (so it
281
+ * flows through buildChangeSummary/candidate_open exactly like an inline
282
+ * `--summary`, per the "preserve -m/--summary unchanged" requirement — this is
283
+ * purely an alternate SOURCE for the same body string). JSON wins if the
284
+ * content parses as an object with at least one recognized field; else the
285
+ * labeled text block; else — no recognized structure at all — the raw content
286
+ * is used verbatim as freeform summary text, so automation isn't forced into
287
+ * the four-field shape. Pure — unit-tested.
288
+ * @param {string} text
289
+ * @returns {string}
290
+ */
291
+ export function summaryFromStructuredText(text) {
292
+ const trimmed = String(text ?? "").trim();
293
+ if (!trimmed) return "";
294
+ const json = parseJsonSummary(trimmed);
295
+ if (json && Object.keys(json).length) return formatStructuredSummary(json).join("\n");
296
+ const labeled = parseLabeledSummary(trimmed);
297
+ if (Object.keys(labeled).length) return formatStructuredSummary(labeled).join("\n");
298
+ return trimmed;
299
+ }
300
+
301
+ /** Read `--summary-file <path>` content — `"-"` reads stdin (fd 0), the same
302
+ * dash-means-stdin convention `tot app` uses (see app/dev.mjs readInput), so
303
+ * an LLM/automation caller can pipe the structured summary in without a temp
304
+ * file. Throws on a real read failure (missing file, permissions) — the
305
+ * caller reports it. */
306
+ export function readSummaryFileContent(pathOrDash) {
307
+ if (pathOrDash === "-") return readFileSync(0, "utf8");
308
+ return readFileSync(pathOrDash, "utf8");
309
+ }
310
+
172
311
  // ─── auto-commit the known content trees (unit u2) ───────────────────────────────
173
312
 
174
313
  /**
@@ -580,17 +719,19 @@ export function resolvePushRef({ ref, changeId }) {
580
719
  * landed or the reconcile/compliance read-back that follows.
581
720
  * @param {ReturnType<import("../mcp.mjs").createMcpClient>} client
582
721
  * @param {{ repo: string|null, changeId: string, changeSummary: {title:string, body:string[]},
583
- * patchEntries: {status:string, path:string, from?:string}[], readBlob: (path:string)=>Buffer }} opts
722
+ * patchEntries: {status:string, path:string, from?:string}[], readBlob: (path:string)=>Buffer,
723
+ * quiet?: boolean }} opts `quiet` (--json) suppresses the human print; the same
724
+ * result is still returned for the caller's JSON payload.
584
725
  */
585
- export async function submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob }) {
726
+ export async function submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet = false }) {
586
727
  if (!repo) {
587
- console.log(` ~ couldn't derive the forge repo from the checkout's remote — skipping the PR-backed candidate.`);
728
+ if (!quiet) console.log(` ~ couldn't derive the forge repo from the checkout's remote — skipping the PR-backed candidate.`);
588
729
  return null;
589
730
  }
590
731
  try {
591
732
  const patch = buildFilePatch(patchEntries, readBlob);
592
733
  if (patch.length === 0) {
593
- console.log(` ~ no file changes to open a PR-backed candidate for.`);
734
+ if (!quiet) console.log(` ~ no file changes to open a PR-backed candidate for.`);
594
735
  return null;
595
736
  }
596
737
  const result = await client.callTool("candidate_open", {
@@ -603,18 +744,21 @@ export async function submitCandidate(client, { repo, changeId, changeSummary, p
603
744
  body: changeSummary.body.length ? changeSummary.body.join("\n").slice(0, 4000) : undefined,
604
745
  patch,
605
746
  });
606
- reportCandidate(result, changeId);
747
+ reportCandidate(result, changeId, { quiet });
607
748
  return result;
608
749
  } catch (e) {
609
- console.log(` ~ couldn't open/update the PR-backed candidate: ${String(e?.message || e)}`);
610
- console.log(` (best-effort your push is still in; this doesn't block reconcile.)`);
750
+ if (!quiet) {
751
+ console.log(` ~ couldn't open/update the PR-backed candidate: ${String(e?.message || e)}`);
752
+ console.log(` (best-effort — your push is still in; this doesn't block reconcile.)`);
753
+ }
611
754
  return null;
612
755
  }
613
756
  }
614
757
 
615
758
  /** Print the candidate_open result: the PR the approver reviews, or the MCP's own
616
- * refusal message when it couldn't open/update one. */
617
- function reportCandidate(result, changeId) {
759
+ * refusal message when it couldn't open/update one. `quiet` (--json) suppresses it. */
760
+ function reportCandidate(result, changeId, { quiet = false } = {}) {
761
+ if (quiet) return;
618
762
  if (result && typeof result.prNumber === "number") {
619
763
  console.log(`\n ✓ candidate ${result.changeId || changeId} — PR #${result.prNumber} (${result.state || "open"})`);
620
764
  if (result.url) console.log(` ${result.url}`);
@@ -624,12 +768,75 @@ function reportCandidate(result, changeId) {
624
768
  console.log(` ~ PR-backed candidate not opened: ${msg}`);
625
769
  }
626
770
 
771
+ /**
772
+ * Build the `--json` result object (P2 item 14): candidate id, PR, head SHA,
773
+ * shareable preview URL, and reconcile/compliance evidence — so an
774
+ * LLM/automation caller can consume structured data instead of scraping
775
+ * human-readable stdout. `ok` mirrors the process exit code (0 ⇒ true) so a
776
+ * caller can branch on one field.
777
+ *
778
+ * Also carries the honest-dispatch triad (the "never dispatched" fix) so
779
+ * automation gets the SAME truth the human-readable path does, never a
780
+ * prettier lie: `dispatched` (was a webhook delivery ever observed for this
781
+ * commit?), `notDispatched` (the permanent-dead-end tag from
782
+ * pollPreviewStatus — re-running will not help), and `delivery` (the raw
783
+ * observability triad, or null when nothing was ever seen). All three
784
+ * default to their "nothing known yet" value when `status` is absent/older,
785
+ * so a caller can branch on `notDispatched` unconditionally without a
786
+ * presence check.
787
+ *
788
+ * `previewPrUrl` (P2 item — the immediate Vercel-style shareable link, see
789
+ * shareablePrUrl) is threaded through separately from `previewUrl`: the
790
+ * latter is the server-minted, reconcile-confirmed link (null until reconcile
791
+ * actually lands); the former is composed client-side the instant the
792
+ * candidate PR opens and may point at a preview that's still building.
793
+ * Defaults to null when no numeric PR number was known at result-build time.
794
+ * Pure — unit-tested.
795
+ * @param {{ ok: boolean, ref?: string|null, commit?: string|null, changeId?: string|null,
796
+ * candidate?: {prNumber?: number, number?: number, state?: string, url?: string}|null,
797
+ * status?: {status?: string, reconcile?: object|null, compliance?: object|null,
798
+ * previewUrl?: string|null, shipped?: object|null, dispatched?: boolean|null,
799
+ * notDispatched?: boolean, delivery?: object|null}|null,
800
+ * previewPrUrl?: string|null, error?: string|null, note?: string|null }} input
801
+ */
802
+ export function buildJsonResult({ ok, ref = null, commit = null, changeId = null, candidate = null, status = null, previewPrUrl = null, error = null, note = null }) {
803
+ return {
804
+ ok,
805
+ ref,
806
+ commit,
807
+ changeId,
808
+ candidate: candidate
809
+ ? { number: candidate.prNumber ?? candidate.number ?? null, state: candidate.state ?? null, url: candidate.url ?? null }
810
+ : null,
811
+ status: status?.status ?? null,
812
+ reconcile: status?.reconcile ?? null,
813
+ compliance: status?.compliance ?? null,
814
+ previewUrl: status?.previewUrl ?? null,
815
+ shipped: status?.shipped ?? null,
816
+ dispatched: status?.dispatched ?? null,
817
+ notDispatched: status?.notDispatched ?? false,
818
+ delivery: status?.delivery ?? null,
819
+ previewPrUrl,
820
+ ...(error ? { error } : {}),
821
+ ...(note ? { note } : {}),
822
+ };
823
+ }
824
+
825
+ /** Print the `--json` result as one pretty-printed object on stdout — a no-op
826
+ * unless `args.json` was passed, so call sites can invoke it unconditionally. */
827
+ function emitJson(args, payload) {
828
+ if (args.json) console.log(JSON.stringify(payload, null, 2));
829
+ }
830
+
627
831
  /**
628
832
  * The preview flow — validate, push the preview ref, open/update the PR-backed
629
833
  * candidate, and stream back the reconcile/compliance/preview result. Reached by
630
834
  * `tot preview` and, as teaching aliases, `tot submit` / `tot deploy` (preview.mjs
631
835
  * wraps this and adds the verb-teaching hints). `verb` only brands the user-facing
632
836
  * copy (usage + the not-in-checkout error) with whatever the developer typed.
837
+ * `--json` (args.json) suppresses the human-readable stdout narration in favor of
838
+ * one structured result object at the end (see buildJsonResult) — stderr
839
+ * diagnostics (fail(), `~ …` progress lines) still print either way.
633
840
  * @param {string[]} argv @param {any} ctx @param {{ verb?: string }} [opts]
634
841
  */
635
842
  export async function run(argv, ctx, { verb = "preview" } = {}) {
@@ -639,13 +846,31 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
639
846
  console.log(renderUsage(verb));
640
847
  return 0;
641
848
  }
849
+ if (args.summary && args.summaryFile) {
850
+ const msg = "--summary and --summary-file are mutually exclusive";
851
+ console.error(fail(msg, "pass one or the other"));
852
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
853
+ return 2;
854
+ }
855
+ if (args.summaryFile) {
856
+ let raw;
857
+ try {
858
+ raw = readSummaryFileContent(args.summaryFile);
859
+ } catch (e) {
860
+ const msg = `couldn't read --summary-file ${args.summaryFile}: ${String(e?.message || e)}`;
861
+ console.error(fail(msg, `check the path (or pass "-" to read stdin)`));
862
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
863
+ return 2;
864
+ }
865
+ // Feeds --summary's body unchanged from here on (buildChangeSummary etc.) —
866
+ // --summary-file is purely an alternate SOURCE for the same string, per the
867
+ // "preserve -m/--summary unchanged" requirement.
868
+ args.summary = summaryFromStructuredText(raw);
869
+ }
642
870
  if (ctx.mode !== "checkout") {
643
- console.error(
644
- fail(
645
- `\`tot ${verb}\` runs from inside a tenant checkout`,
646
- "tot clone <tenant> <dir> (then `cd` in, commit your work, and re-run)",
647
- ),
648
- );
871
+ const msg = `\`tot ${verb}\` runs from inside a tenant checkout`;
872
+ console.error(fail(msg, "tot clone <tenant> <dir> (then `cd` in, commit your work, and re-run)"));
873
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
649
874
  return 2;
650
875
  }
651
876
  const workspace = ctx.workspacePath;
@@ -661,12 +886,9 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
661
886
  try {
662
887
  auto = autoCommitKnownTrees(git, { message: args.message, noCommit: args.noCommit });
663
888
  } catch (e) {
664
- console.error(
665
- fail(
666
- `auto-commit failed: ${String(e?.stderr || e?.message || e)}`,
667
- "commit your content manually (git add / git commit), or re-run with --no-commit",
668
- ),
669
- );
889
+ const msg = `auto-commit failed: ${String(e?.stderr || e?.message || e)}`;
890
+ console.error(fail(msg, "commit your content manually (git add / git commit), or re-run with --no-commit"));
891
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
670
892
  return 1;
671
893
  }
672
894
  if (auto.refused) {
@@ -679,6 +901,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
679
901
  for (const p of auto.unknown) console.error(` ✗ out of scope: ${p}`);
680
902
  console.error(`\n \`tot ${verb}\` auto-commits only: ${[...KNOWN_CONTENT_TREES, ...KNOWN_CONTENT_FILES].join(", ")}`);
681
903
  if (auto.known.length) console.error(` (in scope, would have been committed: ${auto.known.join(", ")})`);
904
+ emitJson(args, buildJsonResult({ ok: false, error: `${auto.unknown.length} change(s) outside the store content trees` }));
682
905
  return 1;
683
906
  }
684
907
  if (auto.committed) {
@@ -696,6 +919,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
696
919
  fail(`${errs.length} validation error(s)`, "fix these (below), or re-run with --skip-validate") + "\n",
697
920
  );
698
921
  for (const f of errs) console.error(` ✗ [${f.rule}] ${f.file} — ${f.message}`);
922
+ emitJson(args, buildJsonResult({ ok: false, error: `${errs.length} validation error(s)` }));
699
923
  return 1;
700
924
  }
701
925
  console.error("~ validated (no errors)");
@@ -706,7 +930,9 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
706
930
  try {
707
931
  commit = git(["rev-parse", "HEAD"]).trim();
708
932
  } catch {
709
- console.error(fail("no commits here yet", "git add <files> && git commit -m '…', then re-run"));
933
+ const msg = "no commits here yet";
934
+ console.error(fail(msg, "git add <files> && git commit -m '…', then re-run"));
935
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
710
936
  return 1;
711
937
  }
712
938
  const short = commit.slice(0, 9);
@@ -778,22 +1004,21 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
778
1004
  const out = git(["push", "-f", "origin", `HEAD:refs/heads/${ref}`]);
779
1005
  if (out.trim()) console.error(redactUrl(out.trim()));
780
1006
  } catch (pushErr) {
781
- console.error(
782
- fail(
783
- `push failed: ${redactUrl(String(pushErr.stderr || pushErr.message || pushErr))}`,
784
- "check your commit and that the checkout's remote is reachable, then re-run",
785
- ),
786
- );
1007
+ const msg = `push failed: ${redactUrl(String(pushErr.stderr || pushErr.message || pushErr))}`;
1008
+ console.error(fail(msg, "check your commit and that the checkout's remote is reachable, then re-run"));
1009
+ emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: msg }));
787
1010
  return 1;
788
1011
  }
789
- console.log(`\n+ submitted ${short} to ${ref}.`);
790
- printChangeSummary(changeSummary);
791
- if (e instanceof AuthUnavailableError) {
792
- console.log(` (sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"})`);
793
- } else {
794
- console.log(` (couldn't reach Token of Trust for the result read-back: ${String(e?.message || e)})`);
1012
+ if (!args.json) console.log(`\n+ submitted ${short} to ${ref}.`);
1013
+ printChangeSummary(changeSummary, { quiet: args.json });
1014
+ const note = e instanceof AuthUnavailableError
1015
+ ? `sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"}`
1016
+ : `couldn't reach Token of Trust for the result read-back: ${String(e?.message || e)}`;
1017
+ if (!args.json) {
1018
+ console.log(` (${note})`);
1019
+ console.log(` Your push is in; the preview updates once reconcile completes.`);
795
1020
  }
796
- console.log(` Your push is in; the preview updates once reconcile completes.`);
1021
+ emitJson(args, buildJsonResult({ ok: true, ref, commit, changeId, note }));
797
1022
  return 0;
798
1023
  }
799
1024
 
@@ -830,16 +1055,13 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
830
1055
  const { out } = await pushPreviewRef(git, mintRemote, { ref });
831
1056
  if (out && out.trim()) console.error(redactUrl(out.trim()));
832
1057
  } catch (e) {
833
- console.error(
834
- fail(
835
- `push failed: ${redactUrl(String(e.stderr || e.message || e))}`,
836
- "check your commit and that the checkout's remote is reachable, then re-run",
837
- ),
838
- );
1058
+ const msg = `push failed: ${redactUrl(String(e.stderr || e.message || e))}`;
1059
+ console.error(fail(msg, "check your commit and that the checkout's remote is reachable, then re-run"));
1060
+ emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: msg }));
839
1061
  return 1;
840
1062
  }
841
- console.log(`\n+ submitted ${short} to ${ref}.`);
842
- printChangeSummary(changeSummary);
1063
+ if (!args.json) console.log(`\n+ submitted ${short} to ${ref}.`);
1064
+ printChangeSummary(changeSummary, { quiet: args.json });
843
1065
 
844
1066
  // 2b + 3. open/update the PR-backed candidate, then report reconcile +
845
1067
  // compliance + preview URL from the MCP — reusing the session established above.
@@ -858,14 +1080,29 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
858
1080
  // above.)
859
1081
  const readBlob = (path) => execFileSync("git", ["-C", workspace, "show", `HEAD:${path}`], { stdio: ["ignore", "pipe", "pipe"] });
860
1082
 
861
- let candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob });
1083
+ let candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet: args.json });
862
1084
 
863
1085
  if (candidate && isTerminalCandidateState(candidate.state)) {
864
1086
  const rolled = mintFreshChangeId(stableId);
865
- console.log(` ~ candidate ${changeId} is ${candidate.state} — opening a fresh candidate PR instead.`);
1087
+ if (!args.json) console.log(` ~ candidate ${changeId} is ${candidate.state} — opening a fresh candidate PR instead.`);
866
1088
  changeId = rolled;
867
1089
  persist = true;
868
- candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob });
1090
+ candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet: args.json });
1091
+ }
1092
+
1093
+ // Immediate shareable URL (Vercel-style: "the URL exists before the build
1094
+ // does"). A non-terminal candidate with a real PR number means a preview
1095
+ // WILL be built at a deterministic route — so hand the developer that link
1096
+ // right now, before reconcile even starts, rather than making them wait for
1097
+ // the server-minted `previewUrl` (formatShareableUrlBlock) that only shows
1098
+ // up once reconcile actually completes. Honest framing: it's printed as
1099
+ // "building", never as "ready".
1100
+ const previewPrUrl =
1101
+ candidate && !isTerminalCandidateState(candidate.state) && typeof candidate.prNumber === "number"
1102
+ ? shareablePrUrl(env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL, tenant, candidate.prNumber)
1103
+ : null;
1104
+ if (previewPrUrl && !args.json) {
1105
+ console.log(`\n ▸ Your preview will appear at:\n ${previewPrUrl}\n (building — this link goes live once reconcile completes)`);
869
1106
  }
870
1107
 
871
1108
  // Remember the active candidate only on a real, non-terminal open (best-effort;
@@ -885,35 +1122,51 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
885
1122
  // as live, not as a growing wall of "(1)…(8)" lines. The counter keeps
886
1123
  // ticking on its own 90ms timer even while a single poll long-polls for
887
1124
  // waitMs, so elapsed time is real wall-clock, not the attempt count.
1125
+ // --json is for automation: no interactive spinner (stays silent — the
1126
+ // JSON result carries the same status at the end).
1127
+ // Honest opening label (the incident this fixes: a job may never actually
1128
+ // get dispatched — see pollPreviewStatus's notDispatched short-circuit — so
1129
+ // the INITIAL text must not assert a reconcile job exists before one has
1130
+ // been observed). Once a tick confirms `s.dispatched === true` the 45s
1131
+ // stage text ("still reconciling…") IS truthful and is left as-is below.
888
1132
  let phase = "reconcile";
889
- progress = startProgress(`reconcile running for ${short}…`, {
890
- stages: [{ afterMs: 45_000, text: `still reconciling ${short} (larger changes take longer)` }],
891
- });
1133
+ if (!args.json) {
1134
+ progress = startProgress(`waiting for reconcile of ${short}…`, {
1135
+ stages: [{ afterMs: 45_000, text: `still reconciling ${short}… (larger changes take longer)` }],
1136
+ });
1137
+ }
892
1138
  status = await pollPreviewStatus(client, commit, {
893
1139
  ...(args.watch ? WATCH_POLL : DEFAULT_POLL),
894
1140
  onTick: (s) => {
895
1141
  // Reconcile is done but we're still waiting on a ship decision (--watch):
896
1142
  // swap the label so the single line reflects the new phase, timer resets.
897
- if (s.status === "reconciled" && !s.shipped && phase !== "ship") {
1143
+ if (!args.json && s.status === "reconciled" && !s.shipped && phase !== "ship") {
898
1144
  phase = "ship";
899
1145
  progress.stop();
900
1146
  progress = startProgress(`reconciled ${short} — waiting for a ship decision…`);
901
1147
  }
902
1148
  },
903
1149
  });
904
- progress.stop();
905
- progress = null;
1150
+ if (progress) {
1151
+ progress.stop();
1152
+ progress = null;
1153
+ }
906
1154
  }
907
- reportStatus(status, tenant, { open: !args.noOpen });
1155
+ // --json also skips the browser auto-open (open: !args.noOpen && !args.json)
1156
+ // — automation doesn't want a browser popping up.
1157
+ reportStatus(status, tenant, { open: !args.noOpen && !args.json, quiet: args.json, commit, ref, verb });
1158
+ emitJson(args, buildJsonResult({ ok: status?.status !== "failed", ref, commit, changeId, candidate, status, previewPrUrl }));
908
1159
  return status?.status === "failed" ? 1 : 0;
909
1160
  } catch (e) {
910
1161
  progress?.stop();
911
- if (e instanceof AuthUnavailableError) {
912
- console.log(` (sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"})`);
913
- } else {
914
- console.log(` (reconcile is running — the result read-back isn't available yet: ${String(e?.message || e)})`);
1162
+ const note = e instanceof AuthUnavailableError
1163
+ ? `sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"}`
1164
+ : `reconcile is running — the result read-back isn't available yet: ${String(e?.message || e)}`;
1165
+ if (!args.json) {
1166
+ console.log(` (${note})`);
1167
+ console.log(` Your push is in; the preview updates once reconcile completes.`);
915
1168
  }
916
- console.log(` Your push is in; the preview updates once reconcile completes.`);
1169
+ emitJson(args, buildJsonResult({ ok: true, ref, commit, changeId, note }));
917
1170
  return 0;
918
1171
  }
919
1172
  }
@@ -921,20 +1174,31 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
921
1174
  /**
922
1175
  * Normalize a `preview_status` tool response to the contract shape the CLI reports
923
1176
  * on: { status, reconcile:{ok,errors}, compliance:{verdict,detail}, previewUrl,
924
- * shipped }. A live tool always returns status pending|reconciled|failed; anything
925
- * else (an older MCP without the tool's flat fields) normalizes to "unknown" so the
926
- * CLI degrades visibly instead of pretending it reconciled. `shipped` (E1b) is
927
- * null until change_accept ships this exact commit. Pure — unit-tested.
1177
+ * shipped, delivery, dispatched }. A live tool always returns status
1178
+ * pending|reconciled|failed; anything else (an older MCP without the tool's flat
1179
+ * fields) normalizes to "unknown" so the CLI degrades visibly instead of pretending
1180
+ * it reconciled. `shipped` (E1b) is null until change_accept ships this exact commit.
1181
+ *
1182
+ * `delivery` is the MCP's reconcile-observability triad for the Gitea webhook that
1183
+ * fired on this commit ({ target, actual, evidence, drift? }) — or null when NO
1184
+ * webhook delivery was observed for this (tenant, commit). `dispatched` distills that
1185
+ * to a boolean: a `pending` status with `dispatched === false` means the reconcile
1186
+ * was NEVER DISPATCHED (no webhook fired — e.g. an unregistered hook, or a commit
1187
+ * read under a different tenant scope than it was pushed to), which is a permanent
1188
+ * dead-end the CLI must not report as "still reconciling". Pure — unit-tested.
928
1189
  */
929
1190
  export function normalizePreviewStatus(r) {
930
1191
  const status = r?.status;
931
1192
  const known = status === "pending" || status === "reconciled" || status === "failed";
1193
+ const delivery = r?.delivery ?? null;
932
1194
  return {
933
1195
  status: known ? status : "unknown",
934
1196
  reconcile: r?.reconcile ?? null,
935
1197
  compliance: r?.compliance ?? null,
936
1198
  previewUrl: r?.previewUrl ?? null,
937
1199
  shipped: r?.shipped ?? null,
1200
+ delivery,
1201
+ dispatched: delivery != null,
938
1202
  raw: r,
939
1203
  };
940
1204
  }
@@ -951,29 +1215,54 @@ export function normalizePreviewStatus(r) {
951
1215
  * the pre-E2 fixed-interval poll — no version check needed, the fallback is
952
1216
  * automatic. Stops as soon as status resolves to "failed"/"unknown" (a failed
953
1217
  * reconcile can't ship), or resolves to "reconciled" AND (not untilShipped, or
954
- * already shipped). Injectable delay/attempts/waitMs for tests.
1218
+ * already shipped).
1219
+ *
1220
+ * NEVER-DISPATCHED short-circuit (the "still reconciling forever" fix): a `pending`
1221
+ * status with NO delivery ever observed for this commit means no reconcile job was
1222
+ * ever dispatched (unregistered webhook, or a tenant-scope mismatch on the read) —
1223
+ * it will never resolve. Rather than walk the whole (~8 min under --watch) budget
1224
+ * lying about progress, once we're past a short startup grace (`notDispatchedGraceMs`
1225
+ * — long enough for a real delivery record to land after the push) with the delivery
1226
+ * still absent, we stop and return the honest state tagged `notDispatched: true`. If
1227
+ * a delivery IS seen we keep polling as before (dispatched, just slow), and a plain
1228
+ * timeout while still pending is tagged `notDispatched` only when a delivery was never
1229
+ * observed. Injectable delay/attempts/waitMs/grace + a `now` clock for tests.
955
1230
  * @param {{callTool:Function}} client
956
1231
  * @param {string} commit
957
- * @param {{ attempts?: number, delayMs?: number, waitMs?: number, untilShipped?: boolean, onTick?: (s:object,i:number)=>void }} [opts]
1232
+ * @param {{ attempts?: number, delayMs?: number, waitMs?: number, untilShipped?: boolean,
1233
+ * notDispatchedGraceMs?: number, now?: () => number, onTick?: (s:object,i:number)=>void }} [opts]
958
1234
  */
959
1235
  export async function pollPreviewStatus(
960
1236
  client,
961
1237
  commit,
962
- { attempts = 8, delayMs = 2500, waitMs = delayMs, untilShipped = false, onTick } = {},
1238
+ { attempts = 8, delayMs = 2500, waitMs = delayMs, untilShipped = false, notDispatchedGraceMs = 15_000, now = Date.now, onTick } = {},
963
1239
  ) {
964
1240
  let last = null;
1241
+ let everDispatched = false;
1242
+ const pollStart = now();
965
1243
  for (let i = 0; i < attempts; i++) {
966
- const startedAt = Date.now();
1244
+ const startedAt = now();
967
1245
  const args = waitMs ? { commit, waitMs } : { commit };
968
1246
  last = normalizePreviewStatus(await client.callTool("preview_status", args));
1247
+ if (last.dispatched) everDispatched = true;
1248
+ last.everDispatched = everDispatched;
969
1249
  if (onTick) onTick(last, i);
1250
+ // Never-dispatched dead-end: still pending, no delivery has EVER been observed
1251
+ // for this commit, and we're past the startup grace — the reconcile will never
1252
+ // arrive. Return honestly instead of continuing to show "still reconciling".
1253
+ if (last.status === "pending" && !everDispatched && now() - pollStart >= notDispatchedGraceMs) {
1254
+ return { ...last, notDispatched: true };
1255
+ }
970
1256
  const stillWatchingForShip = untilShipped && last.status === "reconciled" && !last.shipped;
971
1257
  if (last.status !== "pending" && !stillWatchingForShip) return last;
972
1258
  if (i < attempts - 1) {
973
- const remaining = delayMs - (Date.now() - startedAt);
1259
+ const remaining = delayMs - (now() - startedAt);
974
1260
  if (remaining > 0) await delay(remaining);
975
1261
  }
976
1262
  }
1263
+ // Budget exhausted. A still-pending result that never saw a delivery is a
1264
+ // never-dispatched dead-end (honest), not "still working".
1265
+ if (last) return { ...last, everDispatched, notDispatched: last.status === "pending" && !everDispatched };
977
1266
  return last;
978
1267
  }
979
1268
 
@@ -993,6 +1282,30 @@ function reportComplianceCheck(c) {
993
1282
  if (c.hint) console.log(` → fix: ${c.hint}`);
994
1283
  }
995
1284
 
1285
+ /**
1286
+ * The IMMEDIATE shareable preview URL (Vercel-style: "the URL exists before the
1287
+ * build does") — composed client-side, deterministically, from the tenant + PR
1288
+ * number the candidate_open call just returned, so a developer gets a link to
1289
+ * paste to a reviewer the INSTANT the candidate opens, not minutes later once
1290
+ * reconcile finishes and the MCP mints `previewUrl` server-side (that's
1291
+ * formatShareableUrlBlock's job, above — the two are deliberately redundant:
1292
+ * this one is available immediately but "building", that one is authoritative
1293
+ * once reconcile actually lands). Same route shape as the server-minted one
1294
+ * (`/preview/<tenant>/pr/<N>`) by construction — see
1295
+ * docs/architecture/preview-candidate-workflow.md — so the link doesn't change
1296
+ * out from under the reviewer once the build completes; it just starts
1297
+ * resolving.
1298
+ * Trims a trailing slash off `base` so a `TOT_STOREFRONT_URL` set WITH or
1299
+ * without one composes identically. Pure — unit-tested.
1300
+ * @param {string} base storefront origin, e.g. https://storefront.tokenoftrust.store
1301
+ * @param {string} tenant
1302
+ * @param {number} prNumber
1303
+ * @returns {string}
1304
+ */
1305
+ export function shareablePrUrl(base, tenant, prNumber) {
1306
+ return `${String(base).replace(/\/+$/, "")}/preview/${tenant}/pr/${prNumber}`;
1307
+ }
1308
+
996
1309
  /**
997
1310
  * Build the printed lines for the headline "share this with your reviewer" block —
998
1311
  * the whole point of U14: on a successful preview, the SHAREABLE deep link
@@ -1025,41 +1338,91 @@ export function formatShareableUrlBlock(s, tenant) {
1025
1338
  return [];
1026
1339
  }
1027
1340
 
1341
+ /**
1342
+ * The honest "no reconcile job was dispatched" block — printed when a preview stays
1343
+ * `pending` with no webhook delivery ever observed for the commit (pollPreviewStatus
1344
+ * tagged it `notDispatched`). This replaces the old "reconcile still running — check
1345
+ * back / re-submit" lie for the dead-end case: re-submitting cannot help, so we say
1346
+ * what actually happened and what to do, and never recommend another submit. Pure —
1347
+ * unit-tested. `verb` brands the copy with whatever the developer typed.
1348
+ * @param {{ commit?: string|null, ref?: string|null }} ctx
1349
+ * @param {string} tenant @param {string} [verb]
1350
+ * @returns {string[]}
1351
+ */
1352
+ export function formatNotDispatchedBlock({ commit = null, ref = null } = {}, tenant, verb = "preview") {
1353
+ const short = commit ? commit.slice(0, 9) : "(unknown commit)";
1354
+ return [
1355
+ `\n ⚠ No reconcile was dispatched for ${short} on ${tenant}.`,
1356
+ ` Your push landed${ref ? ` on ${ref}` : ""}, but nothing picked it up to build a preview —`,
1357
+ ` re-running \`tot ${verb}\` will NOT change that. This usually means one of:`,
1358
+ ` • the store's reconcile webhook isn't registered yet (an operator must (re-)provision it), or`,
1359
+ ` • your session is scoped to a different store than the one you pushed.`,
1360
+ ` Next:`,
1361
+ ` • \`tot grants\` — confirm ${tenant} is active for you;`,
1362
+ ` • check the preview dashboard for ${tenant} (it will read "Last reconcile: never" until a job runs);`,
1363
+ ` • if it stays "never", share this with support: commit ${short}, tenant ${tenant}${ref ? `, ref ${ref}` : ""}.`,
1364
+ ];
1365
+ }
1366
+
1028
1367
  /**
1029
1368
  * Print the reconcile/compliance/preview result and, on a clean reconcile with a
1030
- * preview URL, open it in the browser (unless opts.open === false).
1369
+ * preview URL, open it in the browser (unless opts.open === false). `quiet`
1370
+ * (--json) suppresses ALL printing here — the browser open still runs unless
1371
+ * the caller also passes `open: false` (run() passes `open: false` under
1372
+ * --json — automation doesn't want a browser popping up). `commit`/`ref`/`verb`
1373
+ * feed the honest never-dispatched block.
1031
1374
  */
1032
- function reportStatus(s, tenant, { open = true } = {}) {
1375
+ function reportStatus(s, tenant, { open = true, quiet = false, commit = null, ref = null, verb = "preview" } = {}) {
1033
1376
  if (!s || s.status === "unknown") {
1034
- console.log(
1035
- ` (this MCP doesn't return the per-commit reconcile result yet — your push is in;\n` +
1036
- ` the preview updates once reconcile runs. Check the preview dashboard.)`,
1037
- );
1377
+ if (!quiet) {
1378
+ console.log(
1379
+ ` (this MCP doesn't return the per-commit reconcile result yet your push is in;\n` +
1380
+ ` the preview updates once reconcile runs. Check the preview dashboard.)`,
1381
+ );
1382
+ }
1038
1383
  return;
1039
1384
  }
1040
- if (s.status === "pending") {
1041
- console.log(` reconcile still running for ${tenant} — check back shortly (re-run \`tot submit --no-wait\`).`);
1385
+ // Never-dispatched dead-end — the honest replacement for false "still reconciling".
1386
+ if (s.notDispatched) {
1387
+ if (!quiet) for (const line of formatNotDispatchedBlock({ commit, ref }, tenant, verb)) console.log(line);
1042
1388
  return;
1043
1389
  }
1044
- const rc = s.reconcile;
1045
- if (rc) {
1046
- if (rc.ok) console.log(` ✓ reconcile ok`);
1047
- else {
1048
- console.log(` ✗ reconcile failed:`);
1049
- for (const e of rc.errors ?? []) console.log(` [${e.rule ?? "?"}] ${e.message ?? ""}`);
1390
+ if (s.status === "pending") {
1391
+ if (!quiet) {
1392
+ // Dispatched but not yet reported (real slow reconcile) vs. no job seen yet on
1393
+ // a --no-wait snapshot — say which, and never claim progress we can't see.
1394
+ if (s.dispatched === false) {
1395
+ console.log(` no reconcile job seen yet for ${tenant} if it doesn't appear shortly, run \`tot grants\` / check the dashboard.`);
1396
+ } else {
1397
+ console.log(` reconcile still running for ${tenant} — check back shortly (re-run \`tot submit --no-wait\`).`);
1398
+ if (s.delivery?.drift) {
1399
+ console.log(` ⚠ a reconcile report exists for a DIFFERENT commit than you pushed — possible tenant-scope mismatch (\`tot grants\` to check your active store).`);
1400
+ }
1401
+ }
1050
1402
  }
1403
+ return;
1051
1404
  }
1052
- if (s.compliance?.verdict) {
1053
- console.log(` compliance: ${s.compliance.verdict}${s.compliance.detail ? ` — ${s.compliance.detail}` : ""}`);
1054
- for (const c of s.compliance.checks ?? []) reportComplianceCheck(c);
1055
- }
1056
- if (s.shipped) {
1057
- console.log(`\n shipped — change ${s.shipped.changeId} accepted at ${s.shipped.shippedAt}`);
1058
- } else if (s.status === "reconciled") {
1059
- console.log(`\n ~ not yet shipped — a reviewer still needs to run change_accept.`);
1405
+ if (!quiet) {
1406
+ const rc = s.reconcile;
1407
+ if (rc) {
1408
+ if (rc.ok) console.log(` ✓ reconcile ok`);
1409
+ else {
1410
+ console.log(` reconcile failed:`);
1411
+ for (const e of rc.errors ?? []) console.log(` [${e.rule ?? "?"}] ${e.message ?? ""}`);
1412
+ }
1413
+ }
1414
+ if (s.compliance?.verdict) {
1415
+ console.log(` compliance: ${s.compliance.verdict}${s.compliance.detail ? ` — ${s.compliance.detail}` : ""}`);
1416
+ for (const c of s.compliance.checks ?? []) reportComplianceCheck(c);
1417
+ }
1418
+ if (s.shipped) {
1419
+ console.log(`\n ✓ shipped — change ${s.shipped.changeId} accepted at ${s.shipped.shippedAt}`);
1420
+ } else if (s.status === "reconciled") {
1421
+ console.log(`\n ~ not yet shipped — a reviewer still needs to run change_accept.`);
1422
+ }
1423
+ for (const line of formatShareableUrlBlock(s, tenant)) console.log(line);
1060
1424
  }
1061
- for (const line of formatShareableUrlBlock(s, tenant)) console.log(line);
1062
1425
  if (s.previewUrl && open && s.status === "reconciled" && openBrowser(s.previewUrl)) {
1063
- console.log(" (opened in your browser)");
1426
+ if (!quiet) console.log(" (opened in your browser)");
1064
1427
  }
1065
1428
  }