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

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,33 +744,102 @@ 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
+ * Deliberately does NOT print `result.url` — that's the INTERNAL forge (Gitea) PR
761
+ * link, plumbing a developer never needs to see (DZ, 2026-08-15); the product
762
+ * surface is the shareable /preview/<tenant>/pr/<N> URL printed right after
763
+ * (shareablePrUrl). The forge URL still rides the --json payload for tooling. */
764
+ function reportCandidate(result, changeId, { quiet = false } = {}) {
765
+ if (quiet) return;
618
766
  if (result && typeof result.prNumber === "number") {
619
767
  console.log(`\n ✓ candidate ${result.changeId || changeId} — PR #${result.prNumber} (${result.state || "open"})`);
620
- if (result.url) console.log(` ${result.url}`);
621
768
  return;
622
769
  }
623
770
  const msg = result?.message || (result?.raw && String(result.raw)) || JSON.stringify(result ?? null);
624
771
  console.log(` ~ PR-backed candidate not opened: ${msg}`);
625
772
  }
626
773
 
774
+ /**
775
+ * Build the `--json` result object (P2 item 14): candidate id, PR, head SHA,
776
+ * shareable preview URL, and reconcile/compliance evidence — so an
777
+ * LLM/automation caller can consume structured data instead of scraping
778
+ * human-readable stdout. `ok` mirrors the process exit code (0 ⇒ true) so a
779
+ * caller can branch on one field.
780
+ *
781
+ * Also carries the honest-dispatch triad (the "never dispatched" fix) so
782
+ * automation gets the SAME truth the human-readable path does, never a
783
+ * prettier lie: `dispatched` (was a webhook delivery ever observed for this
784
+ * commit?), `notDispatched` (the permanent-dead-end tag from
785
+ * pollPreviewStatus — re-running will not help), and `delivery` (the raw
786
+ * observability triad, or null when nothing was ever seen). All three
787
+ * default to their "nothing known yet" value when `status` is absent/older,
788
+ * so a caller can branch on `notDispatched` unconditionally without a
789
+ * presence check.
790
+ *
791
+ * `previewPrUrl` (P2 item — the immediate Vercel-style shareable link, see
792
+ * shareablePrUrl) is threaded through separately from `previewUrl`: the
793
+ * latter is the server-minted, reconcile-confirmed link (null until reconcile
794
+ * actually lands); the former is composed client-side the instant the
795
+ * candidate PR opens and may point at a preview that's still building.
796
+ * Defaults to null when no numeric PR number was known at result-build time.
797
+ * Pure — unit-tested.
798
+ * @param {{ ok: boolean, ref?: string|null, commit?: string|null, changeId?: string|null,
799
+ * candidate?: {prNumber?: number, number?: number, state?: string, url?: string}|null,
800
+ * status?: {status?: string, reconcile?: object|null, compliance?: object|null,
801
+ * previewUrl?: string|null, shipped?: object|null, dispatched?: boolean|null,
802
+ * notDispatched?: boolean, delivery?: object|null}|null,
803
+ * previewPrUrl?: string|null, error?: string|null, note?: string|null }} input
804
+ */
805
+ export function buildJsonResult({ ok, ref = null, commit = null, changeId = null, candidate = null, status = null, previewPrUrl = null, error = null, note = null }) {
806
+ return {
807
+ ok,
808
+ ref,
809
+ commit,
810
+ changeId,
811
+ candidate: candidate
812
+ ? { number: candidate.prNumber ?? candidate.number ?? null, state: candidate.state ?? null, url: candidate.url ?? null }
813
+ : null,
814
+ status: status?.status ?? null,
815
+ reconcile: status?.reconcile ?? null,
816
+ compliance: status?.compliance ?? null,
817
+ previewUrl: status?.previewUrl ?? null,
818
+ shipped: status?.shipped ?? null,
819
+ dispatched: status?.dispatched ?? null,
820
+ notDispatched: status?.notDispatched ?? false,
821
+ delivery: status?.delivery ?? null,
822
+ previewPrUrl,
823
+ ...(error ? { error } : {}),
824
+ ...(note ? { note } : {}),
825
+ };
826
+ }
827
+
828
+ /** Print the `--json` result as one pretty-printed object on stdout — a no-op
829
+ * unless `args.json` was passed, so call sites can invoke it unconditionally. */
830
+ function emitJson(args, payload) {
831
+ if (args.json) console.log(JSON.stringify(payload, null, 2));
832
+ }
833
+
627
834
  /**
628
835
  * The preview flow — validate, push the preview ref, open/update the PR-backed
629
836
  * candidate, and stream back the reconcile/compliance/preview result. Reached by
630
837
  * `tot preview` and, as teaching aliases, `tot submit` / `tot deploy` (preview.mjs
631
838
  * wraps this and adds the verb-teaching hints). `verb` only brands the user-facing
632
839
  * copy (usage + the not-in-checkout error) with whatever the developer typed.
840
+ * `--json` (args.json) suppresses the human-readable stdout narration in favor of
841
+ * one structured result object at the end (see buildJsonResult) — stderr
842
+ * diagnostics (fail(), `~ …` progress lines) still print either way.
633
843
  * @param {string[]} argv @param {any} ctx @param {{ verb?: string }} [opts]
634
844
  */
635
845
  export async function run(argv, ctx, { verb = "preview" } = {}) {
@@ -639,13 +849,31 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
639
849
  console.log(renderUsage(verb));
640
850
  return 0;
641
851
  }
852
+ if (args.summary && args.summaryFile) {
853
+ const msg = "--summary and --summary-file are mutually exclusive";
854
+ console.error(fail(msg, "pass one or the other"));
855
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
856
+ return 2;
857
+ }
858
+ if (args.summaryFile) {
859
+ let raw;
860
+ try {
861
+ raw = readSummaryFileContent(args.summaryFile);
862
+ } catch (e) {
863
+ const msg = `couldn't read --summary-file ${args.summaryFile}: ${String(e?.message || e)}`;
864
+ console.error(fail(msg, `check the path (or pass "-" to read stdin)`));
865
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
866
+ return 2;
867
+ }
868
+ // Feeds --summary's body unchanged from here on (buildChangeSummary etc.) —
869
+ // --summary-file is purely an alternate SOURCE for the same string, per the
870
+ // "preserve -m/--summary unchanged" requirement.
871
+ args.summary = summaryFromStructuredText(raw);
872
+ }
642
873
  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
- );
874
+ const msg = `\`tot ${verb}\` runs from inside a tenant checkout`;
875
+ console.error(fail(msg, "tot clone <tenant> <dir> (then `cd` in, commit your work, and re-run)"));
876
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
649
877
  return 2;
650
878
  }
651
879
  const workspace = ctx.workspacePath;
@@ -661,12 +889,9 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
661
889
  try {
662
890
  auto = autoCommitKnownTrees(git, { message: args.message, noCommit: args.noCommit });
663
891
  } 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
- );
892
+ const msg = `auto-commit failed: ${String(e?.stderr || e?.message || e)}`;
893
+ console.error(fail(msg, "commit your content manually (git add / git commit), or re-run with --no-commit"));
894
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
670
895
  return 1;
671
896
  }
672
897
  if (auto.refused) {
@@ -679,6 +904,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
679
904
  for (const p of auto.unknown) console.error(` ✗ out of scope: ${p}`);
680
905
  console.error(`\n \`tot ${verb}\` auto-commits only: ${[...KNOWN_CONTENT_TREES, ...KNOWN_CONTENT_FILES].join(", ")}`);
681
906
  if (auto.known.length) console.error(` (in scope, would have been committed: ${auto.known.join(", ")})`);
907
+ emitJson(args, buildJsonResult({ ok: false, error: `${auto.unknown.length} change(s) outside the store content trees` }));
682
908
  return 1;
683
909
  }
684
910
  if (auto.committed) {
@@ -696,6 +922,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
696
922
  fail(`${errs.length} validation error(s)`, "fix these (below), or re-run with --skip-validate") + "\n",
697
923
  );
698
924
  for (const f of errs) console.error(` ✗ [${f.rule}] ${f.file} — ${f.message}`);
925
+ emitJson(args, buildJsonResult({ ok: false, error: `${errs.length} validation error(s)` }));
699
926
  return 1;
700
927
  }
701
928
  console.error("~ validated (no errors)");
@@ -706,7 +933,9 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
706
933
  try {
707
934
  commit = git(["rev-parse", "HEAD"]).trim();
708
935
  } catch {
709
- console.error(fail("no commits here yet", "git add <files> && git commit -m '…', then re-run"));
936
+ const msg = "no commits here yet";
937
+ console.error(fail(msg, "git add <files> && git commit -m '…', then re-run"));
938
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
710
939
  return 1;
711
940
  }
712
941
  const short = commit.slice(0, 9);
@@ -778,22 +1007,21 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
778
1007
  const out = git(["push", "-f", "origin", `HEAD:refs/heads/${ref}`]);
779
1008
  if (out.trim()) console.error(redactUrl(out.trim()));
780
1009
  } 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
- );
1010
+ const msg = `push failed: ${redactUrl(String(pushErr.stderr || pushErr.message || pushErr))}`;
1011
+ console.error(fail(msg, "check your commit and that the checkout's remote is reachable, then re-run"));
1012
+ emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: msg }));
787
1013
  return 1;
788
1014
  }
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)})`);
1015
+ if (!args.json) console.log(`\n+ submitted ${short} to ${ref}.`);
1016
+ printChangeSummary(changeSummary, { quiet: args.json });
1017
+ const note = e instanceof AuthUnavailableError
1018
+ ? `sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"}`
1019
+ : `couldn't reach Token of Trust for the result read-back: ${String(e?.message || e)}`;
1020
+ if (!args.json) {
1021
+ console.log(` (${note})`);
1022
+ console.log(` Your push is in; the preview updates once reconcile completes.`);
795
1023
  }
796
- console.log(` Your push is in; the preview updates once reconcile completes.`);
1024
+ emitJson(args, buildJsonResult({ ok: true, ref, commit, changeId, note }));
797
1025
  return 0;
798
1026
  }
799
1027
 
@@ -830,16 +1058,13 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
830
1058
  const { out } = await pushPreviewRef(git, mintRemote, { ref });
831
1059
  if (out && out.trim()) console.error(redactUrl(out.trim()));
832
1060
  } 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
- );
1061
+ const msg = `push failed: ${redactUrl(String(e.stderr || e.message || e))}`;
1062
+ console.error(fail(msg, "check your commit and that the checkout's remote is reachable, then re-run"));
1063
+ emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: msg }));
839
1064
  return 1;
840
1065
  }
841
- console.log(`\n+ submitted ${short} to ${ref}.`);
842
- printChangeSummary(changeSummary);
1066
+ if (!args.json) console.log(`\n+ submitted ${short} to ${ref}.`);
1067
+ printChangeSummary(changeSummary, { quiet: args.json });
843
1068
 
844
1069
  // 2b + 3. open/update the PR-backed candidate, then report reconcile +
845
1070
  // compliance + preview URL from the MCP — reusing the session established above.
@@ -858,14 +1083,29 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
858
1083
  // above.)
859
1084
  const readBlob = (path) => execFileSync("git", ["-C", workspace, "show", `HEAD:${path}`], { stdio: ["ignore", "pipe", "pipe"] });
860
1085
 
861
- let candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob });
1086
+ let candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet: args.json });
862
1087
 
863
1088
  if (candidate && isTerminalCandidateState(candidate.state)) {
864
1089
  const rolled = mintFreshChangeId(stableId);
865
- console.log(` ~ candidate ${changeId} is ${candidate.state} — opening a fresh candidate PR instead.`);
1090
+ if (!args.json) console.log(` ~ candidate ${changeId} is ${candidate.state} — opening a fresh candidate PR instead.`);
866
1091
  changeId = rolled;
867
1092
  persist = true;
868
- candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob });
1093
+ candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet: args.json });
1094
+ }
1095
+
1096
+ // Immediate shareable URL (Vercel-style: "the URL exists before the build
1097
+ // does"). A non-terminal candidate with a real PR number means a preview
1098
+ // WILL be built at a deterministic route — so hand the developer that link
1099
+ // right now, before reconcile even starts, rather than making them wait for
1100
+ // the server-minted `previewUrl` (formatShareableUrlBlock) that only shows
1101
+ // up once reconcile actually completes. Honest framing: it's printed as
1102
+ // "building", never as "ready".
1103
+ const previewPrUrl =
1104
+ candidate && !isTerminalCandidateState(candidate.state) && typeof candidate.prNumber === "number"
1105
+ ? shareablePrUrl(env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL, tenant, candidate.prNumber)
1106
+ : null;
1107
+ if (previewPrUrl && !args.json) {
1108
+ console.log(`\n ▸ Your preview will appear at:\n ${previewPrUrl}\n (building — this link goes live once reconcile completes)`);
869
1109
  }
870
1110
 
871
1111
  // Remember the active candidate only on a real, non-terminal open (best-effort;
@@ -885,35 +1125,51 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
885
1125
  // as live, not as a growing wall of "(1)…(8)" lines. The counter keeps
886
1126
  // ticking on its own 90ms timer even while a single poll long-polls for
887
1127
  // waitMs, so elapsed time is real wall-clock, not the attempt count.
1128
+ // --json is for automation: no interactive spinner (stays silent — the
1129
+ // JSON result carries the same status at the end).
1130
+ // Honest opening label (the incident this fixes: a job may never actually
1131
+ // get dispatched — see pollPreviewStatus's notDispatched short-circuit — so
1132
+ // the INITIAL text must not assert a reconcile job exists before one has
1133
+ // been observed). Once a tick confirms `s.dispatched === true` the 45s
1134
+ // stage text ("still reconciling…") IS truthful and is left as-is below.
888
1135
  let phase = "reconcile";
889
- progress = startProgress(`reconcile running for ${short}…`, {
890
- stages: [{ afterMs: 45_000, text: `still reconciling ${short} (larger changes take longer)` }],
891
- });
1136
+ if (!args.json) {
1137
+ progress = startProgress(`waiting for reconcile of ${short}…`, {
1138
+ stages: [{ afterMs: 45_000, text: `still reconciling ${short}… (larger changes take longer)` }],
1139
+ });
1140
+ }
892
1141
  status = await pollPreviewStatus(client, commit, {
893
1142
  ...(args.watch ? WATCH_POLL : DEFAULT_POLL),
894
1143
  onTick: (s) => {
895
1144
  // Reconcile is done but we're still waiting on a ship decision (--watch):
896
1145
  // swap the label so the single line reflects the new phase, timer resets.
897
- if (s.status === "reconciled" && !s.shipped && phase !== "ship") {
1146
+ if (!args.json && s.status === "reconciled" && !s.shipped && phase !== "ship") {
898
1147
  phase = "ship";
899
1148
  progress.stop();
900
1149
  progress = startProgress(`reconciled ${short} — waiting for a ship decision…`);
901
1150
  }
902
1151
  },
903
1152
  });
904
- progress.stop();
905
- progress = null;
1153
+ if (progress) {
1154
+ progress.stop();
1155
+ progress = null;
1156
+ }
906
1157
  }
907
- reportStatus(status, tenant, { open: !args.noOpen });
1158
+ // --json also skips the browser auto-open (open: !args.noOpen && !args.json)
1159
+ // — automation doesn't want a browser popping up.
1160
+ reportStatus(status, tenant, { open: !args.noOpen && !args.json, quiet: args.json, commit, ref, verb });
1161
+ emitJson(args, buildJsonResult({ ok: status?.status !== "failed", ref, commit, changeId, candidate, status, previewPrUrl }));
908
1162
  return status?.status === "failed" ? 1 : 0;
909
1163
  } catch (e) {
910
1164
  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)})`);
1165
+ const note = e instanceof AuthUnavailableError
1166
+ ? `sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"}`
1167
+ : `reconcile is running — the result read-back isn't available yet: ${String(e?.message || e)}`;
1168
+ if (!args.json) {
1169
+ console.log(` (${note})`);
1170
+ console.log(` Your push is in; the preview updates once reconcile completes.`);
915
1171
  }
916
- console.log(` Your push is in; the preview updates once reconcile completes.`);
1172
+ emitJson(args, buildJsonResult({ ok: true, ref, commit, changeId, note }));
917
1173
  return 0;
918
1174
  }
919
1175
  }
@@ -921,20 +1177,31 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
921
1177
  /**
922
1178
  * Normalize a `preview_status` tool response to the contract shape the CLI reports
923
1179
  * 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.
1180
+ * shipped, delivery, dispatched }. A live tool always returns status
1181
+ * pending|reconciled|failed; anything else (an older MCP without the tool's flat
1182
+ * fields) normalizes to "unknown" so the CLI degrades visibly instead of pretending
1183
+ * it reconciled. `shipped` (E1b) is null until change_accept ships this exact commit.
1184
+ *
1185
+ * `delivery` is the MCP's reconcile-observability triad for the Gitea webhook that
1186
+ * fired on this commit ({ target, actual, evidence, drift? }) — or null when NO
1187
+ * webhook delivery was observed for this (tenant, commit). `dispatched` distills that
1188
+ * to a boolean: a `pending` status with `dispatched === false` means the reconcile
1189
+ * was NEVER DISPATCHED (no webhook fired — e.g. an unregistered hook, or a commit
1190
+ * read under a different tenant scope than it was pushed to), which is a permanent
1191
+ * dead-end the CLI must not report as "still reconciling". Pure — unit-tested.
928
1192
  */
929
1193
  export function normalizePreviewStatus(r) {
930
1194
  const status = r?.status;
931
1195
  const known = status === "pending" || status === "reconciled" || status === "failed";
1196
+ const delivery = r?.delivery ?? null;
932
1197
  return {
933
1198
  status: known ? status : "unknown",
934
1199
  reconcile: r?.reconcile ?? null,
935
1200
  compliance: r?.compliance ?? null,
936
1201
  previewUrl: r?.previewUrl ?? null,
937
1202
  shipped: r?.shipped ?? null,
1203
+ delivery,
1204
+ dispatched: delivery != null,
938
1205
  raw: r,
939
1206
  };
940
1207
  }
@@ -951,29 +1218,54 @@ export function normalizePreviewStatus(r) {
951
1218
  * the pre-E2 fixed-interval poll — no version check needed, the fallback is
952
1219
  * automatic. Stops as soon as status resolves to "failed"/"unknown" (a failed
953
1220
  * reconcile can't ship), or resolves to "reconciled" AND (not untilShipped, or
954
- * already shipped). Injectable delay/attempts/waitMs for tests.
1221
+ * already shipped).
1222
+ *
1223
+ * NEVER-DISPATCHED short-circuit (the "still reconciling forever" fix): a `pending`
1224
+ * status with NO delivery ever observed for this commit means no reconcile job was
1225
+ * ever dispatched (unregistered webhook, or a tenant-scope mismatch on the read) —
1226
+ * it will never resolve. Rather than walk the whole (~8 min under --watch) budget
1227
+ * lying about progress, once we're past a short startup grace (`notDispatchedGraceMs`
1228
+ * — long enough for a real delivery record to land after the push) with the delivery
1229
+ * still absent, we stop and return the honest state tagged `notDispatched: true`. If
1230
+ * a delivery IS seen we keep polling as before (dispatched, just slow), and a plain
1231
+ * timeout while still pending is tagged `notDispatched` only when a delivery was never
1232
+ * observed. Injectable delay/attempts/waitMs/grace + a `now` clock for tests.
955
1233
  * @param {{callTool:Function}} client
956
1234
  * @param {string} commit
957
- * @param {{ attempts?: number, delayMs?: number, waitMs?: number, untilShipped?: boolean, onTick?: (s:object,i:number)=>void }} [opts]
1235
+ * @param {{ attempts?: number, delayMs?: number, waitMs?: number, untilShipped?: boolean,
1236
+ * notDispatchedGraceMs?: number, now?: () => number, onTick?: (s:object,i:number)=>void }} [opts]
958
1237
  */
959
1238
  export async function pollPreviewStatus(
960
1239
  client,
961
1240
  commit,
962
- { attempts = 8, delayMs = 2500, waitMs = delayMs, untilShipped = false, onTick } = {},
1241
+ { attempts = 8, delayMs = 2500, waitMs = delayMs, untilShipped = false, notDispatchedGraceMs = 15_000, now = Date.now, onTick } = {},
963
1242
  ) {
964
1243
  let last = null;
1244
+ let everDispatched = false;
1245
+ const pollStart = now();
965
1246
  for (let i = 0; i < attempts; i++) {
966
- const startedAt = Date.now();
1247
+ const startedAt = now();
967
1248
  const args = waitMs ? { commit, waitMs } : { commit };
968
1249
  last = normalizePreviewStatus(await client.callTool("preview_status", args));
1250
+ if (last.dispatched) everDispatched = true;
1251
+ last.everDispatched = everDispatched;
969
1252
  if (onTick) onTick(last, i);
1253
+ // Never-dispatched dead-end: still pending, no delivery has EVER been observed
1254
+ // for this commit, and we're past the startup grace — the reconcile will never
1255
+ // arrive. Return honestly instead of continuing to show "still reconciling".
1256
+ if (last.status === "pending" && !everDispatched && now() - pollStart >= notDispatchedGraceMs) {
1257
+ return { ...last, notDispatched: true };
1258
+ }
970
1259
  const stillWatchingForShip = untilShipped && last.status === "reconciled" && !last.shipped;
971
1260
  if (last.status !== "pending" && !stillWatchingForShip) return last;
972
1261
  if (i < attempts - 1) {
973
- const remaining = delayMs - (Date.now() - startedAt);
1262
+ const remaining = delayMs - (now() - startedAt);
974
1263
  if (remaining > 0) await delay(remaining);
975
1264
  }
976
1265
  }
1266
+ // Budget exhausted. A still-pending result that never saw a delivery is a
1267
+ // never-dispatched dead-end (honest), not "still working".
1268
+ if (last) return { ...last, everDispatched, notDispatched: last.status === "pending" && !everDispatched };
977
1269
  return last;
978
1270
  }
979
1271
 
@@ -993,6 +1285,30 @@ function reportComplianceCheck(c) {
993
1285
  if (c.hint) console.log(` → fix: ${c.hint}`);
994
1286
  }
995
1287
 
1288
+ /**
1289
+ * The IMMEDIATE shareable preview URL (Vercel-style: "the URL exists before the
1290
+ * build does") — composed client-side, deterministically, from the tenant + PR
1291
+ * number the candidate_open call just returned, so a developer gets a link to
1292
+ * paste to a reviewer the INSTANT the candidate opens, not minutes later once
1293
+ * reconcile finishes and the MCP mints `previewUrl` server-side (that's
1294
+ * formatShareableUrlBlock's job, above — the two are deliberately redundant:
1295
+ * this one is available immediately but "building", that one is authoritative
1296
+ * once reconcile actually lands). Same route shape as the server-minted one
1297
+ * (`/preview/<tenant>/pr/<N>`) by construction — see
1298
+ * docs/architecture/preview-candidate-workflow.md — so the link doesn't change
1299
+ * out from under the reviewer once the build completes; it just starts
1300
+ * resolving.
1301
+ * Trims a trailing slash off `base` so a `TOT_STOREFRONT_URL` set WITH or
1302
+ * without one composes identically. Pure — unit-tested.
1303
+ * @param {string} base storefront origin, e.g. https://storefront.tokenoftrust.store
1304
+ * @param {string} tenant
1305
+ * @param {number} prNumber
1306
+ * @returns {string}
1307
+ */
1308
+ export function shareablePrUrl(base, tenant, prNumber) {
1309
+ return `${String(base).replace(/\/+$/, "")}/preview/${tenant}/pr/${prNumber}`;
1310
+ }
1311
+
996
1312
  /**
997
1313
  * Build the printed lines for the headline "share this with your reviewer" block —
998
1314
  * the whole point of U14: on a successful preview, the SHAREABLE deep link
@@ -1025,41 +1341,91 @@ export function formatShareableUrlBlock(s, tenant) {
1025
1341
  return [];
1026
1342
  }
1027
1343
 
1344
+ /**
1345
+ * The honest "no reconcile job was dispatched" block — printed when a preview stays
1346
+ * `pending` with no webhook delivery ever observed for the commit (pollPreviewStatus
1347
+ * tagged it `notDispatched`). This replaces the old "reconcile still running — check
1348
+ * back / re-submit" lie for the dead-end case: re-submitting cannot help, so we say
1349
+ * what actually happened and what to do, and never recommend another submit. Pure —
1350
+ * unit-tested. `verb` brands the copy with whatever the developer typed.
1351
+ * @param {{ commit?: string|null, ref?: string|null }} ctx
1352
+ * @param {string} tenant @param {string} [verb]
1353
+ * @returns {string[]}
1354
+ */
1355
+ export function formatNotDispatchedBlock({ commit = null, ref = null } = {}, tenant, verb = "preview") {
1356
+ const short = commit ? commit.slice(0, 9) : "(unknown commit)";
1357
+ return [
1358
+ `\n ⚠ No reconcile was dispatched for ${short} on ${tenant}.`,
1359
+ ` Your push landed${ref ? ` on ${ref}` : ""}, but nothing picked it up to build a preview —`,
1360
+ ` re-running \`tot ${verb}\` will NOT change that. This usually means one of:`,
1361
+ ` • the store's reconcile webhook isn't registered yet (an operator must (re-)provision it), or`,
1362
+ ` • your session is scoped to a different store than the one you pushed.`,
1363
+ ` Next:`,
1364
+ ` • \`tot grants\` — confirm ${tenant} is active for you;`,
1365
+ ` • check the preview dashboard for ${tenant} (it will read "Last reconcile: never" until a job runs);`,
1366
+ ` • if it stays "never", share this with support: commit ${short}, tenant ${tenant}${ref ? `, ref ${ref}` : ""}.`,
1367
+ ];
1368
+ }
1369
+
1028
1370
  /**
1029
1371
  * 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).
1372
+ * preview URL, open it in the browser (unless opts.open === false). `quiet`
1373
+ * (--json) suppresses ALL printing here — the browser open still runs unless
1374
+ * the caller also passes `open: false` (run() passes `open: false` under
1375
+ * --json — automation doesn't want a browser popping up). `commit`/`ref`/`verb`
1376
+ * feed the honest never-dispatched block.
1031
1377
  */
1032
- function reportStatus(s, tenant, { open = true } = {}) {
1378
+ function reportStatus(s, tenant, { open = true, quiet = false, commit = null, ref = null, verb = "preview" } = {}) {
1033
1379
  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
- );
1380
+ if (!quiet) {
1381
+ console.log(
1382
+ ` (this MCP doesn't return the per-commit reconcile result yet your push is in;\n` +
1383
+ ` the preview updates once reconcile runs. Check the preview dashboard.)`,
1384
+ );
1385
+ }
1038
1386
  return;
1039
1387
  }
1040
- if (s.status === "pending") {
1041
- console.log(` reconcile still running for ${tenant} — check back shortly (re-run \`tot submit --no-wait\`).`);
1388
+ // Never-dispatched dead-end — the honest replacement for false "still reconciling".
1389
+ if (s.notDispatched) {
1390
+ if (!quiet) for (const line of formatNotDispatchedBlock({ commit, ref }, tenant, verb)) console.log(line);
1042
1391
  return;
1043
1392
  }
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 ?? ""}`);
1393
+ if (s.status === "pending") {
1394
+ if (!quiet) {
1395
+ // Dispatched but not yet reported (real slow reconcile) vs. no job seen yet on
1396
+ // a --no-wait snapshot — say which, and never claim progress we can't see.
1397
+ if (s.dispatched === false) {
1398
+ console.log(` no reconcile job seen yet for ${tenant} if it doesn't appear shortly, run \`tot grants\` / check the dashboard.`);
1399
+ } else {
1400
+ console.log(` reconcile still running for ${tenant} — check back shortly (re-run \`tot submit --no-wait\`).`);
1401
+ if (s.delivery?.drift) {
1402
+ console.log(` ⚠ a reconcile report exists for a DIFFERENT commit than you pushed — possible tenant-scope mismatch (\`tot grants\` to check your active store).`);
1403
+ }
1404
+ }
1050
1405
  }
1406
+ return;
1051
1407
  }
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.`);
1408
+ if (!quiet) {
1409
+ const rc = s.reconcile;
1410
+ if (rc) {
1411
+ if (rc.ok) console.log(` ✓ reconcile ok`);
1412
+ else {
1413
+ console.log(` reconcile failed:`);
1414
+ for (const e of rc.errors ?? []) console.log(` [${e.rule ?? "?"}] ${e.message ?? ""}`);
1415
+ }
1416
+ }
1417
+ if (s.compliance?.verdict) {
1418
+ console.log(` compliance: ${s.compliance.verdict}${s.compliance.detail ? ` — ${s.compliance.detail}` : ""}`);
1419
+ for (const c of s.compliance.checks ?? []) reportComplianceCheck(c);
1420
+ }
1421
+ if (s.shipped) {
1422
+ console.log(`\n ✓ shipped — change ${s.shipped.changeId} accepted at ${s.shipped.shippedAt}`);
1423
+ } else if (s.status === "reconciled") {
1424
+ console.log(`\n ~ not yet shipped — a reviewer still needs to run change_accept.`);
1425
+ }
1426
+ for (const line of formatShareableUrlBlock(s, tenant)) console.log(line);
1060
1427
  }
1061
- for (const line of formatShareableUrlBlock(s, tenant)) console.log(line);
1062
1428
  if (s.previewUrl && open && s.status === "reconciled" && openBrowser(s.previewUrl)) {
1063
- console.log(" (opened in your browser)");
1429
+ if (!quiet) console.log(" (opened in your browser)");
1064
1430
  }
1065
1431
  }