@tokenoftrust/cli 1.4.0-rc.10 → 1.4.0-rc.12

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.
package/README.md CHANGED
@@ -9,7 +9,8 @@ tot clone # list the stores you can build on
9
9
  tot clone <tenant> my-store # mirrors `git clone`; dir defaults to <tenant>
10
10
  cd my-store
11
11
  tot dev # run it locally with save→reload — no Docker needed
12
- tot submit # (coming) submit it for preview
12
+ tot preview # bundle your edits into a compliance-reviewed preview
13
+ tot ship # promote a reconciled preview live (diff + one confirm)
13
14
  ```
14
15
 
15
16
  **Prerequisites: Node.js and an invite. Nothing else.** `tot dev` downloads the
@@ -23,9 +24,11 @@ be fetched.)
23
24
  | Command | Status | What it does |
24
25
  | --- | --- | --- |
25
26
  | `tot clone [<tenant>] [<dir>]` | **built** | Clone a store you're entitled to build on (mirrors `git clone`), with an authenticated remote configured. Dir defaults to `<tenant>`. No arg → list your stores. |
26
- | `tot validate` | next | Lint your store before you submit. |
27
+ | `tot validate` | next | Lint your store before you preview. |
27
28
  | `tot dev` | **built** | Run your store locally with save→reload — NATIVELY (no Docker; falls back to it with `--docker` or automatically if the native artifact isn't available). |
28
- | `tot submit` | next | Submit your store for preview (pushes the preview ref, reports the reconcile/compliance verdict + preview URL). |
29
+ | `tot preview` | **built** | Bundle your edits into a compliance-reviewed preview (validates, auto-commits the known content trees, pushes the preview ref, opens/updates a candidate PR, reports the reconcile/compliance verdict + preview URL). `tot submit` / `tot deploy` still work as teaching aliases for this same flow. |
30
+ | `tot ship` | **built** | Promote a reconciled preview live: always shows a diff-vs-live and asks for one `[y/N]` confirm (no `--yes`, refuses outside a terminal); records an approval request if you're not authorised to ship yourself. |
31
+ | `tot pr [list\|view\|close]` | **built** | See and manage the candidate PRs `tot preview` opens (`gh pr`-shaped). |
29
32
  | `tot doctor` | built | Check this machine is ready and show which context `tot` detected. |
30
33
 
31
34
  ## Context-aware
@@ -42,7 +45,7 @@ The same `tot` does the right thing wherever you run it (walks up like `git`):
42
45
 
43
46
  `tot` talks to the Token of Trust MCP (default `https://mcp.tokenoftrust.com`, override with `--mcp` or `MCP_BASE_URL`). It is **single-plane**: the only identity is **you**, signed in against the MCP over OAuth.
44
47
 
45
- - Run `tot login` once — it opens your browser (or falls back to a device code on a headless box), you sign in as yourself, and the session is cached at `~/.tot/credentials.json` and refreshed silently. Every later command (`tot clone`, `tot start`, `tot submit`, …) runs as you, with no re-auth. Entitlement is derived server-side from your ToT memberships.
48
+ - Run `tot login` once — it opens your browser (or falls back to a device code on a headless box), you sign in as yourself, and the session is cached at `~/.tot/credentials.json` and refreshed silently. Every later command (`tot clone`, `tot start`, `tot preview`, `tot ship`, …) runs as you, with no re-auth. Entitlement is derived server-side from your ToT memberships.
46
49
  - Not signed in? On a terminal, `tot start` / `tot clone` **offer to sign you in right there** and continue in-flow — no "run `tot login`, then re-run".
47
50
  - The old operator env-triple (`TOT_API_KEY` / `TOT_SECRET_KEY` / `TOT_APP_DOMAIN`) **no longer signs the CLI in** — tot-mcp went OAuth-first on 2026-07-23. If those vars are set, `tot` prints a one-line advisory and uses your `tot login` session anyway; it never reads them for auth.
48
51
 
package/bin/tot.mjs CHANGED
@@ -14,7 +14,9 @@
14
14
  * tot validate lint your store before you submit ← built
15
15
  * tot dev run your store locally with save→reload ← built (monorepo: host astro; standalone: runs the published runner image)
16
16
  * tot preview push your store to a reviewable preview ← built (validate + push preview ref; MCP preview_status read-back). `submit`/`deploy` are teaching aliases.
17
- * tot ship promote a reconciled preview live ← stub (registered; real ship logic lands in a later unit)
17
+ * tot ship promote a reconciled preview live ← built (diff-vs-live + y/N confirm change_accept; refuses non-TTY / unreconciled)
18
+ * tot accept / tot merge merge a PR into main (operator verb) ← built (plan + y/N confirm → the SAME change_accept ship uses; no deploy; refuses non-TTY without --yes)
19
+ * tot rollback [<version>] instant re-point to a prior live version ← built (u3 promotion_status/promotion_rollback seam; diff + y/N confirm; refuses non-TTY / ineligible)
18
20
  * tot pr list / view / close your candidate PRs ← built (candidate_status/candidate_close; gh-pr-shaped)
19
21
  * tot doctor check this machine is ready
20
22
  * tot ideas copy-paste AI prompts that reliably wow
@@ -62,7 +64,11 @@ tot — Token of Trust developer CLI
62
64
  tot validate lint your store before you submit
63
65
  tot dev run your store locally with save→reload
64
66
  tot preview push your store to a reviewable preview
65
- tot ship promote a reconciled preview live (coming soon)
67
+ tot ship promote a reconciled preview live (diff → confirm → ship)
68
+ tot accept / tot merge merge a PR into main — operator verb, no deploy (plan → confirm → merge)
69
+ tot rollback [<version>] instant re-point to a prior live version (list → confirm → rollback)
70
+ tot retire evict a candidate PR's preview to reclaim space — operator verb, rebuildable (plan → confirm → evict)
71
+ tot go-live cut the apex domain over to the storefront (readiness → confirm → cutover)
66
72
  tot pr list / view / close your candidate PRs
67
73
  tot doctor check this machine is ready
68
74
  tot ideas copy-paste AI prompts that reliably wow
@@ -168,6 +174,33 @@ async function dispatch(cmd, rest, ctx) {
168
174
  return run(rest, ctx);
169
175
  }
170
176
 
177
+ // `accept` merges a PR into main (no deploy) — a DISTINCT operator verb from
178
+ // `ship`, backed by the SAME change_accept pipeline (see accept.mjs's header).
179
+ // `merge` is a first-class alias, not a teaching nudge: both dispatch straight
180
+ // to the same command.
181
+ if (cmd === "accept" || cmd === "merge") {
182
+ const { run } = await import("../src/commands/accept.mjs");
183
+ return run(rest, ctx);
184
+ }
185
+
186
+ if (cmd === "rollback") {
187
+ const { run } = await import("../src/commands/rollback.mjs");
188
+ return run(rest, ctx);
189
+ }
190
+
191
+ // `retire` evicts a candidate PR's hosted preview to reclaim space (unit U7) —
192
+ // a DISTINCT operator verb from `accept`/reject: retire touches no change
193
+ // lifecycle and is reversible-by-rebuild (`tot preview build`). See retire.mjs.
194
+ if (cmd === "retire") {
195
+ const { run } = await import("../src/commands/retire.mjs");
196
+ return run(rest, ctx);
197
+ }
198
+
199
+ if (cmd === "go-live") {
200
+ const { run } = await import("../src/commands/go-live.mjs");
201
+ return run(rest, ctx);
202
+ }
203
+
171
204
  if (cmd === "pr") {
172
205
  const { run } = await import("../src/commands/pr.mjs");
173
206
  return run(rest, ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "1.4.0-rc.10",
3
+ "version": "1.4.0-rc.12",
4
4
  "description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Token of Trust",
@@ -13,8 +13,14 @@
13
13
  * - a terminal-roll (the active candidate was merged/closed) records the fresh
14
14
  * candidate it rolled to, so you're never wedged submitting to a dead PR.
15
15
  *
16
- * ONE file, `~/.tot/candidates.json`, a map keyed by `<mcpUrl>::<repo>` a
17
- * different MCP or repo is a different candidate namespace. Same atomic-write
16
+ * ONE file, `~/.tot/candidates.json`, a map keyed by `<mcpUrl>::<repo>` on the
17
+ * DEFAULT branch and `<mcpUrl>::<repo>::<branch>` on any other (u4 branch-bound
18
+ * candidates): a different MCP, repo, OR non-default git branch is a different
19
+ * candidate namespace, so a feature branch gets its OWN candidate PR instead of
20
+ * fighting main's over the same handle. The default branch deliberately keeps the
21
+ * OLD branch-less key so existing devs' state is byte-identical (zero migration),
22
+ * and a branch-scoped read that misses FALLS BACK to that old key so state written
23
+ * before the rekey (or by the default branch) is never orphaned. Same atomic-write
18
24
  * discipline as last-tenant.mjs (0600 in a 0700 dir, write-tmp-then-rename).
19
25
  * Dependency-free (node:fs/os/path). `TOT_HOME` overrides home (tests).
20
26
  */
@@ -31,11 +37,38 @@ export function defaultCandidateStatePath(env = process.env) {
31
37
  return join(home, ".tot", "candidates.json");
32
38
  }
33
39
 
34
- /** Namespace key for one (MCP, repo) candidate pointer. */
35
- function stateKey(mcpUrl, repo) {
40
+ /**
41
+ * Branch names that are treated as the repo's DEFAULT — their candidates keep the
42
+ * OLD branch-less key (zero migration). A null/empty/detached ("HEAD") branch is
43
+ * treated as default too, so an environment where the branch can't be resolved
44
+ * degrades to exactly today's behavior rather than minting a spurious namespace.
45
+ */
46
+ export const DEFAULT_BRANCHES = new Set(["main", "master"]);
47
+
48
+ /** Whether `branch` should use the OLD branch-less candidate key. Pure. */
49
+ export function isDefaultBranch(branch) {
50
+ return !branch || branch === "HEAD" || DEFAULT_BRANCHES.has(branch);
51
+ }
52
+
53
+ /** The legacy (branch-less) namespace key — today's exact `<mcpUrl>::<repo>`. */
54
+ function legacyStateKey(mcpUrl, repo) {
36
55
  return `${mcpUrl}::${repo}`;
37
56
  }
38
57
 
58
+ /**
59
+ * Namespace key for one (MCP, repo, branch) candidate pointer. The DEFAULT branch
60
+ * keeps the legacy `<mcpUrl>::<repo>` key byte-for-byte (zero migration); any other
61
+ * branch gets its own `<mcpUrl>::<repo>::<branch>` namespace. Pure.
62
+ */
63
+ function stateKey(mcpUrl, repo, branch) {
64
+ return isDefaultBranch(branch) ? legacyStateKey(mcpUrl, repo) : `${mcpUrl}::${repo}::${branch}`;
65
+ }
66
+
67
+ /** Extract a usable changeId from a stored record, or null. Pure. */
68
+ function recordChangeId(rec) {
69
+ return rec && typeof rec.changeId === "string" && rec.changeId ? rec.changeId : null;
70
+ }
71
+
39
72
  function readMap(filePath) {
40
73
  try {
41
74
  const parsed = JSON.parse(readFileSync(filePath, "utf8"));
@@ -54,26 +87,33 @@ function writeMap(filePath, map) {
54
87
  }
55
88
 
56
89
  /**
57
- * The remembered active changeId for `(mcpUrl, repo)`, or null when there isn't
58
- * one (absent/unreadable/malformed) — a miss means "use the stable default".
59
- * Never throws.
90
+ * The remembered active changeId for `(mcpUrl, repo, branch)`, or null when there
91
+ * isn't one (absent/unreadable/malformed) — a miss means "use the stable default".
92
+ * On a non-default branch whose branch-scoped key misses, FALLS BACK to the legacy
93
+ * branch-less key so state written before the rekey (or by the default branch)
94
+ * isn't orphaned. Never throws.
60
95
  */
61
- export function readActiveChangeId(filePath, { mcpUrl, repo }) {
62
- const rec = readMap(filePath)[stateKey(mcpUrl, repo)];
63
- return rec && typeof rec.changeId === "string" && rec.changeId ? rec.changeId : null;
96
+ export function readActiveChangeId(filePath, { mcpUrl, repo, branch }) {
97
+ const map = readMap(filePath);
98
+ const primary = recordChangeId(map[stateKey(mcpUrl, repo, branch)]);
99
+ if (primary) return primary;
100
+ // Legacy fallback: a branch-scoped miss reads the old branch-less key (a no-op
101
+ // when we're already on the default branch, which IS the legacy key).
102
+ if (!isDefaultBranch(branch)) return recordChangeId(map[legacyStateKey(mcpUrl, repo)]);
103
+ return null;
64
104
  }
65
105
 
66
- /** Remember `changeId` as the active candidate for `(mcpUrl, repo)`, atomically. */
67
- export function writeActiveChangeId(filePath, { mcpUrl, repo, changeId }) {
106
+ /** Remember `changeId` as the active candidate for `(mcpUrl, repo, branch)`, atomically. */
107
+ export function writeActiveChangeId(filePath, { mcpUrl, repo, branch, changeId }) {
68
108
  const map = readMap(filePath);
69
- map[stateKey(mcpUrl, repo)] = { changeId, updatedAt: Date.now() };
109
+ map[stateKey(mcpUrl, repo, branch)] = { changeId, updatedAt: Date.now() };
70
110
  writeMap(filePath, map);
71
111
  }
72
112
 
73
- /** Forget the active candidate for `(mcpUrl, repo)` (e.g. after closing it). */
74
- export function clearActiveChangeId(filePath, { mcpUrl, repo }) {
113
+ /** Forget the active candidate for `(mcpUrl, repo, branch)` (e.g. after closing it). */
114
+ export function clearActiveChangeId(filePath, { mcpUrl, repo, branch }) {
75
115
  const map = readMap(filePath);
76
- const key = stateKey(mcpUrl, repo);
116
+ const key = stateKey(mcpUrl, repo, branch);
77
117
  if (key in map) {
78
118
  delete map[key];
79
119
  writeMap(filePath, map);
@@ -0,0 +1,247 @@
1
+ /**
2
+ * `tot accept --tenant <t> --pr <N>` (alias: `tot merge`) — OPERATOR verb: MERGE
3
+ * a PR into main. DISTINCT from `tot ship`: per board decision
4
+ * `operator-verb-and-hosting-model`, accepting a PR merges it into main; main
5
+ * becomes default-hosted on preview + live only after the NEXT `tot ship` /
6
+ * deploy — accept itself does not deploy. (`tot ship` run by the developer in
7
+ * their own checkout stays accept-THEN-deploy in one gated step — see
8
+ * `ship-context-dependent-semantics` in `../plan.mjs`; this verb is the
9
+ * explicit, deploy-free half of that, for an operator who just wants the merge.)
10
+ *
11
+ * TRANSPORT — reuses the EXISTING accept pipeline `tot ship` already drives over
12
+ * MCP (merge → reconcile → promote → verify): the SAME `change_accept` tool,
13
+ * the SAME session/auth pattern (`createMcpClient` + `establishSession`), and
14
+ * the SAME result normaliser/poll (`normalizeChangeResult` / `pollChangeShipped`,
15
+ * imported straight from `./ship.mjs` rather than re-implemented). No new
16
+ * transport, no new wire contract.
17
+ *
18
+ * TARGET RESOLUTION — `--pr N --tenant t` names the PR, but there is no
19
+ * PR→change-record lookup on the wire, and even if there were, this CLI's own
20
+ * identity may not have cross-tenant forge read access to resolve one for a PR
21
+ * that isn't the developer's own (`resolveChangeRecordId` in ship.mjs only works
22
+ * because it reads the DEVELOPER'S OWN local git HEAD — an operator targeting an
23
+ * arbitrary PR has no local checkout of it to read). So this verb requires the
24
+ * change-RECORD id explicitly via `--change-id` (the `chg_<uuid>` a prior
25
+ * change_open/change_ready minted — e.g. from the PR author's own `tot ship`
26
+ * review step, or the `/admin` confirm flow) and an optional `--head-sha`
27
+ * (passed through as `expectedHeadSha` — an optimistic-concurrency guard so
28
+ * accept refuses if the PR moved since the operator looked at it).
29
+ *
30
+ * // u9: auto-resolve PR→changeId via forge PR-read (candidate_status), so an
31
+ * // operator who DOES have forge access can omit --change-id and pass --pr
32
+ * // alone. Not implemented here — deliberately out of scope for this unit.
33
+ *
34
+ * HUMAN GATE — merging to main is a decision a human makes at the keyboard, so
35
+ * this verb ALWAYS states the EXACT plan (shared `planForAction`, unit U10:
36
+ * which PR, which tenant, "merge into main", plus a note that live/preview
37
+ * don't move until the next ship/deploy) and requires an explicit confirm.
38
+ * `--yes` is still an explicit affirmative supplied on the command line — there
39
+ * is deliberately NO default-yes, and a non-TTY without `--yes` is refused
40
+ * rather than silently proceeding (mirrors `tot ship`'s non-TTY refusal).
41
+ *
42
+ * Dependency-free (the MCP client + the shared plan module).
43
+ */
44
+ import { createMcpClient } from "../mcp.mjs";
45
+ import { establishSession, AuthUnavailableError } from "../auth.mjs";
46
+ import { fail } from "../errors.mjs";
47
+ import { planForAction, printPlanAndConfirm } from "../plan.mjs";
48
+ import { normalizeChangeResult, pollChangeShipped } from "./ship.mjs";
49
+
50
+ const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
51
+
52
+ const USAGE = `tot accept — merge a PR into main (alias: tot merge)
53
+
54
+ tot accept --tenant <t> --pr <N> --change-id <chg_...>
55
+ tot merge --tenant <t> --pr <N> --change-id <chg_...> (same command)
56
+
57
+ Merges the given PR into main via the existing accept pipeline (merge →
58
+ reconcile → promote → verify) — the SAME gate \`tot ship\` uses. Distinct
59
+ from \`tot ship\`: accepting does NOT deploy. Main becomes default-hosted on
60
+ preview + live only after the next \`tot ship\` / deploy.
61
+
62
+ Merge-to-main is a human decision: this ALWAYS prints the exact plan and asks
63
+ for an explicit confirm. There is no default-yes; a non-TTY without --yes is
64
+ refused rather than silently proceeding.
65
+
66
+ Options:
67
+ --tenant <appDomain> Target tenant (e.g. tokenoftrust.com). Defaults to the
68
+ current checkout's tenant when run inside one.
69
+ --pr <N> PR number to merge (for the plan text / labeling).
70
+ --change-id <id> The change-record id (chg_...) to accept. REQUIRED —
71
+ there is no PR→changeId auto-resolution yet (u9); pass
72
+ the id a prior change_open/change_ready minted (e.g.
73
+ from the author's own \`tot ship\` review step, or the
74
+ /admin confirm flow).
75
+ --head-sha <sha> Optional expected PR head sha (expectedHeadSha) — an
76
+ optimistic-concurrency guard against a PR that moved.
77
+ --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
78
+ --identity <id> sign in as a specific identity for this accept
79
+ --yes, -y Skip the interactive confirm (still an explicit human
80
+ affirmative — there is no default-yes).
81
+ --help, -h Show this help.`;
82
+
83
+ /** Parse `tot accept` / `tot merge` argv. Pure — unit-testable. */
84
+ export function parseAcceptArgs(argv) {
85
+ const a = {
86
+ tenant: null,
87
+ pr: null,
88
+ changeId: null,
89
+ headSha: null,
90
+ mcp: null,
91
+ identity: null,
92
+ yes: false,
93
+ help: false,
94
+ };
95
+ for (let i = 0; i < argv.length; i++) {
96
+ const t = argv[i];
97
+ if (t === "--tenant") a.tenant = argv[++i];
98
+ else if (t === "--pr") a.pr = argv[++i];
99
+ else if (t === "--change-id") a.changeId = argv[++i];
100
+ else if (t === "--head-sha" || t === "--head") a.headSha = argv[++i];
101
+ else if (t === "--mcp") a.mcp = argv[++i];
102
+ else if (t === "--identity") a.identity = argv[++i];
103
+ else if (t === "--yes" || t === "-y") a.yes = true;
104
+ else if (t === "--help" || t === "-h") a.help = true;
105
+ }
106
+ return a;
107
+ }
108
+
109
+ /** Report the accept result in house style. Pure given its inputs. */
110
+ function reportAccepted(accept, status, { tenant, pr, changeId }) {
111
+ const label = pr != null ? `PR #${pr}` : changeId;
112
+ const merged = Boolean(status?.shipped || accept?.shipped);
113
+ if (merged) {
114
+ console.log(`\n ✓ merged ${label} into main for ${tenant}.`);
115
+ console.log(
116
+ " → next: `tot ship` (or an operator `tot ship`/deploy) to promote main to preview + live.",
117
+ );
118
+ return 0;
119
+ }
120
+ // The accept committed but the terminal state hasn't been observed yet (the
121
+ // pipeline may still be landing reconcile/promote/verify) — report honestly.
122
+ console.log(`\n ~ accept committed for ${label} on ${tenant}; the merge is landing now.`);
123
+ console.log(" → next: re-check shortly, then `tot ship` / deploy to go live.");
124
+ return 0;
125
+ }
126
+
127
+ /**
128
+ * @param {string[]} argv
129
+ * @param {any} ctx
130
+ */
131
+ export async function run(argv, ctx) {
132
+ const env = process.env;
133
+ const args = parseAcceptArgs(argv);
134
+ if (args.help) {
135
+ console.log(USAGE);
136
+ return 0;
137
+ }
138
+
139
+ const tenant = (args.tenant || ctx?.tenant || "").trim();
140
+ if (!tenant) {
141
+ console.error(
142
+ fail(
143
+ "no target tenant.",
144
+ "pass --tenant <appDomain> (e.g. --tenant tokenoftrust.com), or run inside a store checkout.",
145
+ ),
146
+ );
147
+ return 2;
148
+ }
149
+
150
+ const prRaw = args.pr;
151
+ const pr = prRaw != null && `${prRaw}`.trim() && Number.isFinite(Number(prRaw)) ? Number(prRaw) : null;
152
+ if (pr == null && !args.changeId) {
153
+ console.error(
154
+ fail("no PR or change to accept.", "pass --pr <N> (the PR number to merge) and --change-id <chg_...>."),
155
+ );
156
+ return 2;
157
+ }
158
+
159
+ const changeId = (args.changeId || "").trim();
160
+ if (!changeId) {
161
+ // u9: auto-resolve PR→changeId via forge PR-read (candidate_status) — until
162
+ // then an operator targeting a specific PR must supply the change-record id
163
+ // directly (there's no cross-tenant forge lookup this CLI can do for a PR
164
+ // that isn't the caller's own checkout).
165
+ console.error(
166
+ fail(
167
+ `no --change-id for PR #${pr} — can't auto-resolve it yet.`,
168
+ "pass --change-id <chg_...> (the change record a prior review step opened/readied), " +
169
+ "or run `tot ship` from the PR author's own checkout instead.",
170
+ ),
171
+ );
172
+ return 2;
173
+ }
174
+
175
+ const headSha = (args.headSha || "").trim() || null;
176
+
177
+ // Self-declaring: state the EXACT plan before acting (the shared plan module,
178
+ // unit U10) plus the accept-specific caveat that main doesn't go live/preview
179
+ // until the next ship/deploy.
180
+ const planLines = [
181
+ ...planForAction({ action: "accept", tenant, pr, changeId, headSha }),
182
+ " note: main becomes default-hosted on preview + live only after the next `tot ship` / deploy.",
183
+ ];
184
+ const { confirmed, reason } = await printPlanAndConfirm(planLines, {
185
+ yes: args.yes,
186
+ question: "Merge this into main?",
187
+ });
188
+ if (!confirmed) {
189
+ if (reason === "non-tty") {
190
+ console.error(
191
+ fail(
192
+ "refusing to merge without confirmation on a non-TTY.",
193
+ "re-run with --yes (an explicit human affirmative), or from an interactive terminal.",
194
+ ),
195
+ );
196
+ return 2;
197
+ }
198
+ console.log("Aborted — nothing was merged.");
199
+ return 1;
200
+ }
201
+
202
+ const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
203
+ const client = createMcpClient(baseUrl);
204
+ try {
205
+ await establishSession(client, { env, prefer: args.identity || undefined });
206
+ // Bind the active tenant so change_accept/change_status read the right scope.
207
+ await client.callTool("client_switch", { tenant });
208
+
209
+ let accept;
210
+ try {
211
+ accept = normalizeChangeResult(
212
+ await client.callTool("change_accept", {
213
+ id: changeId,
214
+ tenant,
215
+ dryRun: false,
216
+ ...(headSha ? { expectedHeadSha: headSha } : {}),
217
+ // Stable per (changeId, head): a re-run after a blip returns the
218
+ // original accept instead of double-merging.
219
+ idempotencyKey: `accept-${changeId}-${headSha || pr || "pr"}`,
220
+ }),
221
+ );
222
+ } catch (e) {
223
+ console.error(
224
+ fail(
225
+ `the accept pipeline refused to merge this change: ${String(e?.message || e)}`,
226
+ "check its review/reconcile status, then re-run `tot accept`",
227
+ ),
228
+ );
229
+ return 1;
230
+ }
231
+
232
+ const status = await pollChangeShipped(client, { id: changeId, tenant });
233
+ return reportAccepted(accept, status, { tenant, pr, changeId });
234
+ } catch (e) {
235
+ if (e instanceof AuthUnavailableError) {
236
+ console.error(fail("sign in to accept a change", e.hint || "run `tot login`, then re-run `tot accept`"));
237
+ return 1;
238
+ }
239
+ console.error(
240
+ fail(
241
+ `couldn't reach the accept service: ${String(e?.message || e)}`,
242
+ "check your connection and that you're signed in, then re-run",
243
+ ),
244
+ );
245
+ return 1;
246
+ }
247
+ }