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

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "1.4.0-rc.12",
3
+ "version": "1.4.0-rc.14",
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",
@@ -16,12 +16,22 @@
16
16
  * checkout's origin remote, exactly as `tot submit` does) and reads over the MCP
17
17
  * `candidate_status` / `candidate_close` tools. Dependency-free (global fetch +
18
18
  * `git`).
19
+ *
20
+ * OPERATOR MODE (unit U17). `tot pr list --tenant <t>` lists a tenant's OPEN
21
+ * candidate queue WITHOUT a checkout — the read-only companion to `tot ship --pr <N>
22
+ * --tenant <t>` (U16). It reuses U16's exact transport: the SAME `GET /api/changes`
23
+ * HTTP endpoint + operator-secret Bearer auth (`resolveOperatorSecret`,
24
+ * `normalizeChangesQueue` from ship.mjs). `view`/`close` stay developer-only.
19
25
  */
20
26
  import { execFileSync } from "node:child_process";
21
27
  import { createMcpClient } from "../mcp.mjs";
22
28
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
23
29
  import { fail } from "../errors.mjs";
24
30
  import { repoNameFromRemote, currentBranch } from "./submit.mjs";
31
+ // U17 reuses U16's operator-secret transport helpers verbatim (same env precedence,
32
+ // same queue normalisation) so the operator `tot pr list` and `tot ship --pr` speak
33
+ // one wire, not two.
34
+ import { resolveOperatorSecret, normalizeChangesQueue } from "./ship.mjs";
25
35
  import {
26
36
  defaultCandidateStatePath,
27
37
  readActiveChangeId,
@@ -29,29 +39,55 @@ import {
29
39
  } from "../candidate-state.mjs";
30
40
 
31
41
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
42
+ const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
32
43
  const SUBCOMMANDS = ["list", "view", "close"];
33
44
 
34
- const USAGE = `tot pr — see and manage your candidate PRs
45
+ const USAGE = `tot pr — see and manage candidate PRs
35
46
 
36
47
  tot pr [list] list your open candidate PRs for this store
48
+ tot pr list --tenant <t> OPERATOR: list a tenant's open candidate PRs (no checkout)
37
49
  tot pr view <N|id> show one candidate PR (by PR number or changeId)
38
50
  tot pr close <N|id> close (reject) a candidate PR without merging
39
51
  tot pr close <N|id> --reason "<why>" record why it was closed (audit note)
40
52
  tot pr --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
41
53
 
42
- A candidate PR is what \`tot submit\` opens for review. A re-submit updates your
43
- open one by default; \`tot submit --new\` forks another. Use these to see and
44
- manage them.`;
54
+ DEVELOPER (default) run inside your OWN store checkout: lists/manages the
55
+ candidates \`tot submit\` opens. A re-submit updates your open one by default;
56
+ \`tot submit --new\` forks another.
57
+
58
+ OPERATOR — \`tot pr list --tenant <appDomain>\` lists ANY tenant's open candidate
59
+ queue WITHOUT a checkout (the read-only companion to \`tot ship --pr <N> --tenant\`).
60
+ It reads \`GET /api/changes\` with an operator secret:
61
+
62
+ tot pr list --tenant <t> list <t>'s open candidate PRs
63
+ tot pr list --url <origin> storefront origin (default: env TOT_STOREFRONT_URL)
64
+ tot pr list --secret <s> operator secret (prefer the env vars below)
65
+
66
+ Operator secret (from env, first found): PREVIEW_RECONCILE_SECRET,
67
+ GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET (or pass --secret).`;
45
68
 
46
- /** Parse `tot pr` argv into { sub, target, reason, mcp, identity, help }. Pure. */
69
+ /** Parse `tot pr` argv into { sub, target, reason, mcp, identity, tenant, url, secret, help }. Pure. */
47
70
  export function parsePrArgs(argv) {
48
- const a = { sub: null, target: null, reason: null, mcp: null, identity: null, help: false };
71
+ const a = {
72
+ sub: null,
73
+ target: null,
74
+ reason: null,
75
+ mcp: null,
76
+ identity: null,
77
+ tenant: null,
78
+ url: null,
79
+ secret: null,
80
+ help: false,
81
+ };
49
82
  const positional = [];
50
83
  for (let i = 0; i < argv.length; i++) {
51
84
  const t = argv[i];
52
85
  if (t === "--mcp") a.mcp = argv[++i];
53
86
  else if (t === "--identity") a.identity = argv[++i];
54
87
  else if (t === "--reason") a.reason = argv[++i];
88
+ else if (t === "--tenant") a.tenant = argv[++i];
89
+ else if (t === "--url") a.url = argv[++i];
90
+ else if (t === "--secret") a.secret = argv[++i];
55
91
  else if (t === "--help" || t === "-h") a.help = true;
56
92
  else positional.push(t);
57
93
  }
@@ -98,6 +134,118 @@ export function formatCandidateLine(c, { active = false } = {}) {
98
134
  return ` PR ${pr} ${branch} ${c.changeId} [${c.state ?? "?"}]${urlPart}${activePart}`;
99
135
  }
100
136
 
137
+ // ─── Operator queue listing (unit U17) ──────────────────────────────────────────────
138
+ //
139
+ // The read-only companion to `tot ship --pr <N> --tenant <t>` (U16): list a tenant's
140
+ // OPEN candidate queue WITHOUT a checkout. It reuses U16's exact transport — the SAME
141
+ // `GET /api/changes` endpoint (operator accept queue) + Bearer operator-secret auth
142
+ // (`resolveOperatorSecret` / `normalizeChangesQueue` from ship.mjs) — so a listing and
143
+ // a ship read one wire. NO merge/deploy/side-effect; pure listing.
144
+
145
+ /**
146
+ * One-line summary of an OPEN operator-queue candidate for `tot pr list --tenant`.
147
+ * Surfaces PR # (or `#—`), the review-environment status, the changeId, the short
148
+ * head sha, and the preview URL — the fields an operator needs to pick a PR to ship.
149
+ * Adapts `formatCandidateLine`'s house style to the `GET /api/changes` shape (`status`
150
+ * not `state`; `previewUrl`; a `headSha` to short-render). Pure — unit-tested.
151
+ * @param {{prNumber?:number|null, status?:string|null, changeId:string,
152
+ * headSha?:string|null, previewUrl?:string|null}} c
153
+ * @returns {string}
154
+ */
155
+ export function formatOperatorCandidateLine(c) {
156
+ const pr = typeof c.prNumber === "number" ? `#${c.prNumber}` : "#—";
157
+ const status = c.status ?? "?";
158
+ const head = c.headSha ? c.headSha.slice(0, 8) : "(no head)";
159
+ const urlPart = c.previewUrl ? ` ${c.previewUrl}` : "";
160
+ return ` PR ${pr} [${status}] ${c.changeId} ${head}${urlPart}`;
161
+ }
162
+
163
+ /**
164
+ * Sort an OPEN candidate queue by PR number DESC (newest PR first); candidates
165
+ * without a PR number sort last, then stably by changeId. Pure — unit-tested.
166
+ * @param {ReturnType<typeof normalizeChangesQueue>} changes
167
+ */
168
+ export function sortQueueByPrDesc(changes) {
169
+ return [...(changes || [])].sort((a, b) => {
170
+ const ap = typeof a.prNumber === "number" ? a.prNumber : -Infinity;
171
+ const bp = typeof b.prNumber === "number" ? b.prNumber : -Infinity;
172
+ if (ap !== bp) return bp - ap;
173
+ return String(a.changeId).localeCompare(String(b.changeId));
174
+ });
175
+ }
176
+
177
+ /**
178
+ * The OPERATOR `tot pr list --tenant <t>` flow: fetch the tenant's OPEN candidate
179
+ * queue over `GET /api/changes` (Bearer operator secret + `X-Tot-Owner` +
180
+ * `x-tot-capability: ship-on-behalf`, mirroring U16) and print each candidate one per
181
+ * line, sorted by PR number desc. Fail-closed: no secret → honest refusal (exit 2)
182
+ * BEFORE any network. `fetch` is injected so it's unit-tested with no live network.
183
+ *
184
+ * @param {{ tenant:string, secret:string, storefrontUrl?:string|null }} params
185
+ * @param {{ fetch?:typeof fetch }} [deps]
186
+ * @returns {Promise<number>} process exit code
187
+ */
188
+ export async function runPrListOperator({ tenant, secret, storefrontUrl = null }, deps = {}) {
189
+ const fetchImpl = deps.fetch || globalThis.fetch;
190
+ const base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
191
+
192
+ if (!secret) {
193
+ console.error(
194
+ fail(
195
+ "listing a tenant's queue is an OPERATOR action — it needs an operator secret",
196
+ "set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / TOT_OPERATOR_SECRET), or pass --secret",
197
+ ),
198
+ );
199
+ return 2;
200
+ }
201
+
202
+ const authHeaders = {
203
+ authorization: `Bearer ${secret}`,
204
+ "x-tot-owner": tenant,
205
+ "x-tot-capability": "ship-on-behalf",
206
+ };
207
+
208
+ let res;
209
+ try {
210
+ res = await fetchImpl(`${base}/api/changes`, { method: "GET", headers: authHeaders });
211
+ } catch (e) {
212
+ console.error(
213
+ fail(`couldn't reach the candidate queue at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
214
+ );
215
+ return 1;
216
+ }
217
+
218
+ let data = {};
219
+ try {
220
+ data = await res.json();
221
+ } catch {
222
+ /* non-JSON / empty body */
223
+ }
224
+ if (!res.ok) {
225
+ const msg = data?.error || `HTTP ${res.status}`;
226
+ console.error(
227
+ fail(
228
+ `the candidate queue refused the request: ${msg}`,
229
+ res.status === 401 || res.status === 403
230
+ ? "check the operator secret and that it's authorised for this tenant"
231
+ : "check --tenant / --url, then re-run",
232
+ ),
233
+ );
234
+ return 1;
235
+ }
236
+
237
+ const changes = sortQueueByPrDesc(normalizeChangesQueue(data));
238
+ if (!changes.length) {
239
+ console.log(`No open candidate PRs for ${tenant}.`);
240
+ return 0;
241
+ }
242
+ console.log(`Open PRs for ${tenant}:`);
243
+ for (const c of changes) {
244
+ console.log(formatOperatorCandidateLine(c));
245
+ }
246
+ return 0;
247
+ }
248
+
101
249
  /** @param {string[]} argv @param {any} ctx */
102
250
  export async function run(argv, ctx) {
103
251
  const env = process.env;
@@ -110,9 +258,34 @@ export async function run(argv, ctx) {
110
258
  console.error(fail(`unknown subcommand: \`tot pr ${args.sub}\``, "tot pr list | view <N> | close <N>"));
111
259
  return 2;
112
260
  }
261
+
262
+ // OPERATOR MODE (U17): `--tenant <t>` lists that tenant's OPEN candidate queue with
263
+ // NO checkout, over the same HTTP transport `tot ship --pr` uses. Only `list` has an
264
+ // operator path today — `view`/`close` stay developer-only (checkout-bound).
265
+ if (args.tenant && `${args.tenant}`.trim()) {
266
+ if (args.sub !== "list") {
267
+ console.error(
268
+ fail(
269
+ `\`tot pr ${args.sub} --tenant\` isn't wired — only \`tot pr list --tenant\` has an operator path`,
270
+ "use `tot pr list --tenant <t>` to list, then act from a checkout",
271
+ ),
272
+ );
273
+ return 2;
274
+ }
275
+ const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
276
+ return await runPrListOperator({
277
+ tenant: `${args.tenant}`.trim(),
278
+ secret: resolveOperatorSecret(args.secret, env),
279
+ storefrontUrl,
280
+ });
281
+ }
282
+
113
283
  if (ctx.mode !== "checkout") {
114
284
  console.error(
115
- fail("`tot pr` runs from inside a tenant checkout", "tot clone <tenant> <dir> (then `cd` in and re-run)"),
285
+ fail(
286
+ "`tot pr` runs from inside a tenant checkout",
287
+ "tot clone <tenant> <dir> (then `cd` in and re-run), or `tot pr list --tenant <t>` for operator mode",
288
+ ),
116
289
  );
117
290
  return 2;
118
291
  }
@@ -86,8 +86,10 @@ const USAGE = `tot ship — deploy main → preview + live (context-dependent)
86
86
  tot ship ship the preview you last pushed with \`tot preview\`
87
87
  tot ship <N> DEVELOPER: ship PR #N from your OWN checkout
88
88
  tot ship pr/<N> (same — also accepts \`pr#N\`, \`#N\`)
89
- tot ship --pr <N> --tenant <t> OPERATOR: deploy a PR that isn't your checkout
89
+ tot ship --pr <N> --tenant <t> OPERATOR: ship ANY built PR (not just your checkout)
90
90
  tot ship --change-id <chg_…> target an explicit change record (operator targeting)
91
+ tot ship --secret <s> operator secret (prefer the env vars below)
92
+ tot ship --url <origin> storefront origin (default: env TOT_STOREFRONT_URL)
91
93
  tot ship --yes confirm non-interactively (skip the [y/N] prompt)
92
94
  tot ship --identity <id> sign in as a specific identity for this ship
93
95
  tot ship --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
@@ -98,8 +100,14 @@ const USAGE = `tot ship — deploy main → preview + live (context-dependent)
98
100
  this branch/PR into main) AND THEN DEPLOY (main → preview + live). A POSITIONAL
99
101
  \`tot ship 4\` / \`tot ship pr/4\` names the PR to ship — same developer semantics,
100
102
  the tenant is inferred from the checkout.
101
- • OPERATOR — target a \`--pr N --tenant t\` that ISN'T your checkout: ship = DEPLOY
102
- only (the merge is the separate \`tot accept\` verb).
103
+ • OPERATOR — target a PR that ISN'T your checkout (\`tot ship 4\` with no local
104
+ candidate, or an explicit \`--pr N --tenant t\`): ship resolves PR N → the built
105
+ candidate from the SHARED queue (GET /api/changes) and ships it via the atomic
106
+ accept (POST /api/changes/accept). This needs an OPERATOR SECRET (env below).
107
+ If PR N has no built candidate, build it first with \`tot preview build\`.
108
+
109
+ Operator secret (from env, first found): PREVIEW_RECONCILE_SECRET,
110
+ GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET (or pass --secret).
103
111
 
104
112
  It NEVER silently guesses: it prints the EXACT plan for THAT context and confirms
105
113
  first (\`--yes\` to confirm non-interactively; a non-TTY without \`--yes\` refuses).
@@ -136,6 +144,7 @@ export function parseShipArgs(argv) {
136
144
  changeId: null,
137
145
  headSha: null,
138
146
  url: null,
147
+ secret: null,
139
148
  help: false,
140
149
  };
141
150
  for (let i = 0; i < argv.length; i++) {
@@ -149,6 +158,7 @@ export function parseShipArgs(argv) {
149
158
  else if (t === "--change-id") a.changeId = argv[++i];
150
159
  else if (t === "--head-sha" || t === "--head") a.headSha = argv[++i];
151
160
  else if (t === "--url") a.url = argv[++i];
161
+ else if (t === "--secret") a.secret = argv[++i];
152
162
  else if (t === "--help" || t === "-h") a.help = true;
153
163
  else if (!t.startsWith("-") && a.pr == null) {
154
164
  // A POSITIONAL PR reference (`tot ship 4` / `tot ship pr/4` / `tot ship #4`).
@@ -545,7 +555,11 @@ export async function pollChangeShipped(client, { id, tenant }, { attempts = 6,
545
555
  * changeSummary?:{ title?:string, body?:string[] }, expectedPr?:number|string|null }} params
546
556
  * @param {{ confirmPlan?:typeof printPlanAndConfirm,
547
557
  * poll?:typeof pollChangeShipped, openUrl?:(u:string)=>boolean, progress?:boolean,
548
- * resolveRecord?:typeof resolveChangeRecordId }} [deps]
558
+ * resolveRecord?:typeof resolveChangeRecordId,
559
+ * shipByPrFallback?:()=>Promise<number> }} [deps]
560
+ * shipByPrFallback (u16) — when a POSITIONAL `tot ship <N>` finds NO candidate for
561
+ * this checkout, invoke this instead of the "no active candidate" dead-end: it ships
562
+ * the built PR from the shared queue (the operator-by-PR path).
549
563
  * @returns {Promise<number>} process exit code
550
564
  */
551
565
  export async function runShip(client, { tenant, changeId, repo, git, noOpen, yes = false, storefrontUrl = null, changeSummary, expectedPr = null }, deps = {}) {
@@ -597,6 +611,16 @@ export async function runShip(client, { tenant, changeId, repo, git, noOpen, yes
597
611
  : NO_REVIEW;
598
612
  const gate = shipReadiness(candidate, review);
599
613
 
614
+ // u16: `tot ship <N>` in a checkout that has NO candidate matching this PR (the PR
615
+ // isn't yours) — fall through to the OPERATOR-by-PR queue path (resolve PR→changeId
616
+ // from GET /api/changes, ship via POST /api/changes/accept) instead of dead-ending on
617
+ // "no active candidate". Only fires for a POSITIONAL PR (expectedPr) with the fallback
618
+ // wired (run() supplies it). The U15 different-PR guard (step 1a) still REFUSES — this
619
+ // is the distinct "nothing to ship from THIS checkout" case, not a mismatch.
620
+ if (gate.kind === "no-candidate" && expectedPr != null && deps.shipByPrFallback) {
621
+ return await deps.shipByPrFallback();
622
+ }
623
+
600
624
  if (gate.kind === "unauthorized") {
601
625
  return await handleUnauthorized(client, { id, tenant, approvers: review.approvers });
602
626
  }
@@ -732,6 +756,302 @@ export function liveUrlFor(tenant) {
732
756
  return t ? `https://${t}` : null;
733
757
  }
734
758
 
759
+ // ─── Operator ship-by-PR via the SHARED candidate queue (unit U16) ──────────────────
760
+ //
761
+ // The real user report: `tot ship 4` for a PR the operator did NOT author fails
762
+ // `✗ no active candidate to ship for this checkout` — U15's developer path resolves
763
+ // the candidate from the LOCAL git checkout, so it can't ship a PR that isn't yours,
764
+ // and U15's operator path needed an explicit `--change-id` (it couldn't resolve
765
+ // PR→changeId — the `// u9:` gap).
766
+ //
767
+ // The insight this unit builds on (NO forge PR-read needed): a BUILT PR already has a
768
+ // `ReviewEnvironment` in the candidate index, and `GET /api/changes` (the operator
769
+ // accept queue) returns the tenant's OPEN candidates — each with `changeId` +
770
+ // `prNumber` + `headSha`. So PR N → changeId is resolvable from the QUEUE. The ship is
771
+ // then the SAME atomic merge→main→reconcile→promote-live the `/admin` Accept button
772
+ // drives — `POST /api/changes/accept` with `{ repo:<appDomain>, changeId,
773
+ // expectedHeadSha }` — NOT the MCP `change_accept` (that's chg_-record-id-keyed +
774
+ // checkout-bound). The go-live HUMAN GATE stays the confirm.
775
+ //
776
+ // AUTH (investigated): `resolveOwnerSession` (apps/storefront/src/lib/grants/session.ts)
777
+ // accepts a SIGNED-IN storefront `tot_session` cookie (path 1) OR the Bearer operator
778
+ // secret + `X-Tot-Owner` (path 2). The CLI holds NO storefront cookie — its OAuth login
779
+ // is with the MCP, a different trust boundary — so the signed-in/OAuth path can't apply
780
+ // here. The Bearer operator-secret path is the CLI's ONLY route, exactly mirroring
781
+ // `tot preview build`'s transport (Bearer + `X-Tot-Owner` + `x-tot-capability`).
782
+
783
+ const OPERATOR_SECRET_ENV = ["PREVIEW_RECONCILE_SECRET", "GRANTS_ADMIN_SECRET", "TOT_OPERATOR_SECRET"];
784
+
785
+ /**
786
+ * Resolve the operator secret from an explicit `--secret` or the env (first found),
787
+ * matching `tot preview build`'s precedence. Pure given its env argument.
788
+ * @param {string|null|undefined} explicit
789
+ * @param {NodeJS.ProcessEnv} env
790
+ * @returns {string}
791
+ */
792
+ export function resolveOperatorSecret(explicit, env = {}) {
793
+ if (explicit && `${explicit}`.trim()) return `${explicit}`.trim();
794
+ for (const k of OPERATOR_SECRET_ENV) {
795
+ const v = env[k];
796
+ if (v && `${v}`.trim()) return `${v}`.trim();
797
+ }
798
+ return "";
799
+ }
800
+
801
+ /**
802
+ * Normalise a `GET /api/changes` body to the OPEN candidate list. The endpoint
803
+ * answers `{ owner, capability, current, changes }` where `changes` is the open
804
+ * `ReviewEnvironment` queue; read defensively (tolerate a bare array or a `{ changes }`
805
+ * wrapper) and keep only records carrying a `changeId`. Pure — unit-tested.
806
+ * @param {any} data
807
+ * @returns {Array<{changeId:string, prNumber?:number|null, headSha?:string|null, previewUrl?:string|null}>}
808
+ */
809
+ export function normalizeChangesQueue(data) {
810
+ const list = Array.isArray(data)
811
+ ? data
812
+ : data && Array.isArray(data.changes)
813
+ ? data.changes
814
+ : [];
815
+ return list.filter((c) => c && typeof c === "object" && typeof c.changeId === "string");
816
+ }
817
+
818
+ /**
819
+ * Find the OPEN candidate whose forge PR number === `pr` and return the descriptor
820
+ * the by-PR ship needs (`{ changeId, headSha, prNumber, previewUrl }`), else null when
821
+ * no built candidate matches. Pure — unit-tested.
822
+ * @param {ReturnType<typeof normalizeChangesQueue>} changes
823
+ * @param {number|string} pr
824
+ * @returns {{changeId:string, headSha:string|null, prNumber:number, previewUrl:string|null}|null}
825
+ */
826
+ export function findCandidateByPr(changes, pr) {
827
+ const want = Number(pr);
828
+ if (!Number.isFinite(want)) return null;
829
+ for (const c of changes || []) {
830
+ if (c.prNumber != null && Number(c.prNumber) === want) {
831
+ return {
832
+ changeId: c.changeId,
833
+ headSha: c.headSha ?? null,
834
+ prNumber: want,
835
+ previewUrl: c.previewUrl ?? null,
836
+ };
837
+ }
838
+ }
839
+ return null;
840
+ }
841
+
842
+ /**
843
+ * Normalise a `POST /api/changes/accept` body to { shipped, pending, state, sha,
844
+ * message, error }. The atomic-accept endpoint reports an HONEST terminal `state`:
845
+ * `shipped` (and `live:true`) ONLY when the target is verified live; `publish_pending`
846
+ * = merged + dispatched but NOT yet verified (never a live claim). Pure — unit-tested.
847
+ * @param {any} data
848
+ */
849
+ export function normalizeAcceptResponse(data) {
850
+ const o = data && typeof data === "object" ? data : {};
851
+ const state = typeof o.state === "string" ? o.state : null;
852
+ const shipped = state === "shipped" || o.live === true;
853
+ return {
854
+ shipped,
855
+ pending: !shipped && (state === "publish_pending" || o.merged === true),
856
+ state,
857
+ sha: typeof o.sha === "string" ? o.sha : null,
858
+ message: typeof o.message === "string" ? o.message : null,
859
+ error: typeof o.error === "string" ? o.error : null,
860
+ raw: data,
861
+ };
862
+ }
863
+
864
+ /**
865
+ * The OPERATOR ship-by-PR flow (unit U16). Ships ANY built PR — not just your
866
+ * checkout — with NO forge PR-read:
867
+ *
868
+ * 1. RESOLVE PR N → { changeId, headSha } by reading the tenant's OPEN candidate
869
+ * queue (`GET /api/changes`) and matching `prNumber === N`. If N isn't in the
870
+ * queue the PR has no built candidate → instruct `tot preview build` first (we
871
+ * NEVER invent a changeId).
872
+ * 2. PLAN + CONFIRM — the go-live HUMAN GATE. `POST /api/changes/accept` is the
873
+ * atomic merge→main + reconcile + promote-live, so the plan states BOTH (the
874
+ * developer-context wording, mirroring the `/admin` Accept plan). `--yes` confirms
875
+ * non-interactively; a non-TTY without `--yes` REFUSES — nothing merges/goes live
876
+ * without an explicit yes.
877
+ * 3. SHIP via `POST /api/changes/accept` `{ repo:<tenant appDomain>, changeId,
878
+ * expectedHeadSha }` — the SAME endpoint the `/admin` Accept button uses.
879
+ *
880
+ * Transport mirrors `tot preview build` exactly (Bearer operator secret + `X-Tot-Owner`
881
+ * + `x-tot-capability: ship-on-behalf`). `fetch`/confirm/openUrl/progress are injected
882
+ * so it's unit-tested with no network/TTY.
883
+ *
884
+ * @param {{ tenant:string, pr:number|string|null, secret:string, storefrontUrl?:string|null,
885
+ * noOpen?:boolean, yes?:boolean }} params
886
+ * @param {{ fetch?:typeof fetch, confirmPlan?:typeof printPlanAndConfirm,
887
+ * openUrl?:(u:string)=>boolean, progress?:boolean }} [deps]
888
+ * @returns {Promise<number>} process exit code
889
+ */
890
+ export async function runShipByPr(
891
+ { tenant, pr, secret, storefrontUrl = null, noOpen = false, yes = false },
892
+ deps = {},
893
+ ) {
894
+ const fetchImpl = deps.fetch || globalThis.fetch;
895
+ const confirmPlan = deps.confirmPlan || printPlanAndConfirm;
896
+ const base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
897
+
898
+ const want = pr != null && `${pr}`.trim() && Number.isFinite(Number(pr)) ? Number(pr) : null;
899
+ if (want == null) {
900
+ console.error(
901
+ fail(
902
+ "operator ship needs a PR number to resolve the built candidate",
903
+ "pass a PR — `tot ship <N>` or `tot ship --pr <N> --tenant <appDomain>`",
904
+ ),
905
+ );
906
+ return 2;
907
+ }
908
+ if (!secret) {
909
+ console.error(
910
+ fail(
911
+ "shipping a PR that isn't your checkout is an OPERATOR action — it needs an operator secret",
912
+ "set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / TOT_OPERATOR_SECRET), or pass --secret",
913
+ ),
914
+ );
915
+ return 2;
916
+ }
917
+
918
+ const authHeaders = {
919
+ authorization: `Bearer ${secret}`,
920
+ "x-tot-owner": tenant,
921
+ "x-tot-capability": "ship-on-behalf",
922
+ };
923
+
924
+ // 1. Resolve PR N → { changeId, headSha } from the tenant's OPEN candidate queue.
925
+ let queueRes;
926
+ try {
927
+ queueRes = await fetchImpl(`${base}/api/changes`, { method: "GET", headers: authHeaders });
928
+ } catch (e) {
929
+ console.error(
930
+ fail(`couldn't reach the candidate queue at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
931
+ );
932
+ return 1;
933
+ }
934
+ const queueData = await readJsonSafe(queueRes);
935
+ if (!queueRes.ok) {
936
+ const msg = queueData?.error || `HTTP ${queueRes.status}`;
937
+ console.error(
938
+ fail(
939
+ `the candidate queue refused the request: ${msg}`,
940
+ queueRes.status === 401 || queueRes.status === 403
941
+ ? "check the operator secret and that it's authorised for this tenant"
942
+ : "check --tenant / --url, then re-run",
943
+ ),
944
+ );
945
+ return 1;
946
+ }
947
+
948
+ const candidate = findCandidateByPr(normalizeChangesQueue(queueData), want);
949
+ if (!candidate) {
950
+ const previewUrl = previewUrlFor(base, tenant, want);
951
+ console.error(
952
+ fail(
953
+ `PR #${want} has no built candidate in ${tenant}'s queue — there's nothing to ship`,
954
+ `build it first: \`tot preview build --tenant ${tenant} --pr ${want}\`, then \`tot ship ${want} --tenant ${tenant}\``,
955
+ ),
956
+ );
957
+ if (previewUrl) console.error(` Once built it appears at: ${previewUrl}`);
958
+ return 1;
959
+ }
960
+
961
+ // 2. Plan + confirm — the go-live HUMAN GATE. `POST /api/changes/accept` is the
962
+ // ATOMIC merge→main + promote-live, so the plan narrates BOTH (developer-context
963
+ // wording — the same "merge … then deploy" the /admin Accept plan renders).
964
+ const previewUrl = candidate.previewUrl || previewUrlFor(base, tenant, want);
965
+ const planLines = planForAction({
966
+ action: "ship",
967
+ context: "developer",
968
+ tenant,
969
+ pr: want,
970
+ changeId: candidate.changeId,
971
+ headSha: candidate.headSha,
972
+ targets: { preview: previewUrl, live: liveUrlFor(tenant) },
973
+ });
974
+ const { confirmed, reason } = await confirmPlan(planLines, {
975
+ yes,
976
+ question: `Ship PR #${want} live to ${tenant} (merge → main, then go live)?`,
977
+ });
978
+ if (!confirmed) {
979
+ if (reason === "non-tty") {
980
+ console.error(
981
+ fail(
982
+ "`tot ship` needs an interactive terminal to confirm this live change",
983
+ "re-run from a terminal, or pass --yes to confirm non-interactively",
984
+ ),
985
+ );
986
+ return 2;
987
+ }
988
+ console.log(" Ship cancelled — nothing changed.");
989
+ return 0;
990
+ }
991
+
992
+ // 3. Accept it via the SAME endpoint the /admin Accept button uses (repo=appDomain).
993
+ const progress = deps.progress === false ? null : startProgress("shipping…");
994
+ let acceptRes;
995
+ try {
996
+ acceptRes = await fetchImpl(`${base}/api/changes/accept`, {
997
+ method: "POST",
998
+ headers: { "content-type": "application/json", ...authHeaders },
999
+ body: JSON.stringify({
1000
+ repo: tenant,
1001
+ changeId: candidate.changeId,
1002
+ ...(candidate.headSha ? { expectedHeadSha: candidate.headSha } : {}),
1003
+ }),
1004
+ });
1005
+ } catch (e) {
1006
+ progress?.stop();
1007
+ console.error(
1008
+ fail(`couldn't reach the ship endpoint at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
1009
+ );
1010
+ return 1;
1011
+ }
1012
+ const acceptData = await readJsonSafe(acceptRes);
1013
+ progress?.stop();
1014
+
1015
+ const result = normalizeAcceptResponse(acceptData);
1016
+ if (!acceptRes.ok && !result.shipped && !result.pending) {
1017
+ const why = result.error || result.message || `HTTP ${acceptRes.status}`;
1018
+ console.error(
1019
+ fail(
1020
+ `the ship gate refused this change (HTTP ${acceptRes.status}): ${why}`,
1021
+ "check the PR reconciled cleanly (rebuild with `tot preview build`), then re-run",
1022
+ ),
1023
+ );
1024
+ return 1;
1025
+ }
1026
+ return reportShippedByPr(result, { tenant, pr: want, previewUrl, noOpen, openUrl: deps.openUrl });
1027
+ }
1028
+
1029
+ /** Read a fetch Response body as JSON, tolerating a non-JSON/empty body. */
1030
+ async function readJsonSafe(res) {
1031
+ try {
1032
+ return await res.json();
1033
+ } catch {
1034
+ return {};
1035
+ }
1036
+ }
1037
+
1038
+ /** Report the by-PR ship result — honest terminal state (never a false "shipped live"). */
1039
+ function reportShippedByPr(result, { tenant, pr, previewUrl, noOpen, openUrl }) {
1040
+ const liveUrl = liveUrlFor(tenant);
1041
+ if (result.shipped) {
1042
+ console.log(`\n ✓ shipped PR #${pr} live to ${tenant}.`);
1043
+ if (liveUrl) {
1044
+ console.log(` Live: ${liveUrl}`);
1045
+ if (!noOpen && openUrl && openUrl(liveUrl)) console.log(" (opened in your browser)");
1046
+ }
1047
+ return 0;
1048
+ }
1049
+ // Merged + dispatched but the target isn't verified live yet — honest in-flight.
1050
+ console.log(`\n ~ ship accepted for PR #${pr} on ${tenant}; it's going live now (not yet verified).`);
1051
+ if (previewUrl) console.log(` Track it here: ${previewUrl}`);
1052
+ return 0;
1053
+ }
1054
+
735
1055
  // ─── Operator ship (DEPLOY-only) ─────────────────────────────────────────────────────
736
1056
 
737
1057
  /**
@@ -866,6 +1186,26 @@ async function runOperatorFlow(args, ctx, env) {
866
1186
  }
867
1187
  const pr = args.pr != null && `${args.pr}`.trim() && Number.isFinite(Number(args.pr)) ? Number(args.pr) : args.pr;
868
1188
  const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
1189
+
1190
+ // u16: with NO explicit change-record id, ship ANY built PR via the SHARED candidate
1191
+ // queue — resolve PR→changeId from GET /api/changes and ship via POST
1192
+ // /api/changes/accept (operator secret transport). This is the path that ships a PR
1193
+ // the operator did NOT author. The explicit `--change-id` path below stays the MCP
1194
+ // change_accept targeting (backward-compatible; distinct chg_-record namespace).
1195
+ if (!args.changeId) {
1196
+ return await runShipByPr(
1197
+ {
1198
+ tenant,
1199
+ pr,
1200
+ secret: resolveOperatorSecret(args.secret, env),
1201
+ storefrontUrl,
1202
+ noOpen: args.noOpen,
1203
+ yes: args.yes,
1204
+ },
1205
+ { openUrl: (u) => openBrowser(u) },
1206
+ );
1207
+ }
1208
+
869
1209
  const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
870
1210
  const client = createMcpClient(baseUrl);
871
1211
  try {
@@ -981,6 +1321,7 @@ export async function run(argv, ctx) {
981
1321
  const headSubject = gitSafe(["log", "-1", "--format=%s"]).trim();
982
1322
  const changeSummary = buildChangeSummary({ message: headSubject, headSubject });
983
1323
 
1324
+ const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
984
1325
  return await runShip(
985
1326
  client,
986
1327
  {
@@ -990,13 +1331,34 @@ export async function run(argv, ctx) {
990
1331
  git,
991
1332
  noOpen: args.noOpen,
992
1333
  yes: args.yes,
993
- storefrontUrl: args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL,
1334
+ storefrontUrl,
994
1335
  changeSummary,
995
1336
  // A positional `tot ship 4` asserts WHICH PR — passed as a safety guard so
996
1337
  // ship refuses if this checkout's active candidate is a different PR.
997
1338
  expectedPr: args.prPositional ? args.pr : null,
998
1339
  },
999
- { openUrl: (u) => openBrowser(u) },
1340
+ {
1341
+ openUrl: (u) => openBrowser(u),
1342
+ // u16: when `tot ship <N>` names a PR that ISN'T this checkout's candidate (no
1343
+ // local candidate resolves), fall through to the operator-by-PR queue path — so
1344
+ // `tot ship 4` ships someone else's built PR too, using this checkout's tenant.
1345
+ ...(args.prPositional
1346
+ ? {
1347
+ shipByPrFallback: () =>
1348
+ runShipByPr(
1349
+ {
1350
+ tenant,
1351
+ pr: args.pr,
1352
+ secret: resolveOperatorSecret(args.secret, env),
1353
+ storefrontUrl,
1354
+ noOpen: args.noOpen,
1355
+ yes: args.yes,
1356
+ },
1357
+ { openUrl: (u) => openBrowser(u) },
1358
+ ),
1359
+ }
1360
+ : {}),
1361
+ },
1000
1362
  );
1001
1363
  } catch (e) {
1002
1364
  if (e instanceof AuthUnavailableError) {
@@ -3,7 +3,9 @@
3
3
  *
4
4
  * From inside a tenant checkout:
5
5
  * 1. validate locally and refuse on errors (fail fast before anything leaves the machine),
6
- * 2. push your committed work to the tenant repo's `preview` ref (triggers reconcile),
6
+ * 2. push your committed work to YOUR OWN isolated candidate ref `candidate/<changeId>`
7
+ * (b03 — no shared `preview` ref is ever force-pushed; an explicit `--ref` can still
8
+ * target a literal ref name for back-compat) — which triggers reconcile,
7
9
  * 2b. open/update a PR-BACKED CANDIDATE for the same committed diff (g1b's
8
10
  * `candidate_open`, unit c1 — the local-dev-loop half of the "PR-Backed Hosted
9
11
  * Review Loop" milestone, symmetric with the hosted s6 draft-as-PR path), and
@@ -57,10 +59,25 @@ import {
57
59
  } from "../candidate-state.mjs";
58
60
 
59
61
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
60
- const DEFAULT_REF = "preview";
62
+
63
+ // The git ref prefix an isolated candidate push lands under (b03 — stop
64
+ // force-pushing the SHARED `preview` ref). ONE constant so a rename is trivial —
65
+ // provisionally coordinated with the MCP-side candidate_open resolution (b02/b04),
66
+ // which already names its PR-backed branch `candidate/<changeId>` (see
67
+ // submitCandidate, below): the raw git push here and the PR-backed candidate it
68
+ // opens always target the SAME branch, never two.
69
+ export const CANDIDATE_REF_PREFIX = "candidate/";
70
+
71
+ /** The isolated git ref a candidate's preview push lands under. Pure. */
72
+ export function candidateRefFor(changeId) {
73
+ return `${CANDIDATE_REF_PREFIX}${changeId}`;
74
+ }
61
75
 
62
76
  export function parseArgs(argv) {
63
- const a = { mcp: null, identity: null, ref: DEFAULT_REF, skipValidate: false, noWait: false, watch: false, noOpen: false, noCommit: false, message: null, summary: null, new: false, help: false };
77
+ // `ref: null` an explicit `--ref` always wins; otherwise the push target is
78
+ // derived per-run as YOUR OWN isolated candidate ref (resolvePushRef, below),
79
+ // 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 };
64
81
  for (let i = 0; i < argv.length; i++) {
65
82
  const t = argv[i];
66
83
  if (t === "--mcp") a.mcp = argv[++i];
@@ -93,7 +110,7 @@ export function renderUsage(verb = "preview") {
93
110
  tot ${verb} --watch stay attached through reconcile + compliance + accept (long-poll)
94
111
  tot ${verb} --skip-validate push without the local lint (not recommended)
95
112
  tot ${verb} --no-commit don't auto-commit a dirty tree — preview only what's already committed
96
- tot ${verb} --ref <name> push ref (default: ${DEFAULT_REF})
113
+ tot ${verb} --ref <name> push ref (default: your own isolated candidate ref — see \`tot pr\`)
97
114
  tot ${verb} -m "<title>" one-line summary of what changed (the approver sees this)
98
115
  tot ${verb} --summary "<text>" longer description to accompany the title
99
116
  tot ${verb} --no-wait push and exit without polling for the reconcile result
@@ -513,6 +530,44 @@ export function actorKeyFor(session) {
513
530
  return session?.email || session?.token || "developer";
514
531
  }
515
532
 
533
+ /**
534
+ * Which candidate this submit lands on (gh-pr-like) — decided UP FRONT, before any
535
+ * network call, because it also determines the isolated git ref we push to
536
+ * (resolvePushRef, below): a re-submit updates the SAME candidate/ref by default;
537
+ * `--new` forks a fresh one.
538
+ * --new → fork a FRESH candidate id;
539
+ * otherwise → the remembered active candidate (from a prior --new / terminal
540
+ * roll), else the STABLE per-dev-per-tenant(-per-branch) default.
541
+ * `persist` reports whether the choice diverges from the stable default, so the
542
+ * caller knows whether to remember it as the new active pointer. `mint` is
543
+ * injected (defaults to mintFreshChangeId) so this is pure/deterministic in tests.
544
+ * Pure — unit-tested.
545
+ * @param {{ tenant: string, actorKey: string, branch?: string|null, active?: string|null,
546
+ * isNew?: boolean, mint?: (baseId: string) => string }} opts
547
+ * @returns {{ changeId: string, stableId: string, persist: boolean }}
548
+ */
549
+ export function chooseChangeId({ tenant, actorKey, branch = null, active = null, isNew = false, mint = mintFreshChangeId }) {
550
+ const stableId = deriveChangeId(tenant, actorKey, branch);
551
+ const changeId = isNew ? mint(stableId) : (active || stableId);
552
+ const persist = isNew || (!!active && active !== stableId);
553
+ return { changeId, stableId, persist };
554
+ }
555
+
556
+ /**
557
+ * The ref `tot preview`/`tot submit` pushes to (b03 — stop force-pushing the
558
+ * SHARED `preview` ref). An explicit `--ref` always wins — the escape hatch /
559
+ * back-compat path (e.g. `--ref preview` reproduces the old shared-ref push for
560
+ * any tooling that still reads it by that literal name); otherwise it's YOUR OWN
561
+ * isolated candidate ref, so two developers — or the same developer on two
562
+ * branches — never force-push over each other or each other's preview. Pure —
563
+ * unit-tested.
564
+ * @param {{ ref?: string|null, changeId: string }} opts
565
+ * @returns {string}
566
+ */
567
+ export function resolvePushRef({ ref, changeId }) {
568
+ return ref || candidateRefFor(changeId);
569
+ }
570
+
516
571
  /**
517
572
  * Open/update the PR-backed candidate for this submit (g1b `candidate_open`,
518
573
  * unit c1 — the local-dev-loop half of the "PR-Backed Hosted Review Loop"
@@ -663,6 +718,9 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
663
718
  // same diff (name-status, so candidate_open also knows adds/deletes/renames)
664
719
  // doubles as the source of the PR-backed candidate's file patch (step 2b, below)
665
720
  // — one git read, two consumers, so the PR always matches what's printed here.
721
+ // Parameterized on `ref` (b03 — isolated candidate refs): the diff base is YOUR
722
+ // candidate ref's own tracking ref, not a shared one, so the summary always reads
723
+ // "vs your own last push" once the push ref is known (computed just below).
666
724
  const gitSafe = (cargs) => {
667
725
  try {
668
726
  return git(cargs);
@@ -670,18 +728,21 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
670
728
  return "";
671
729
  }
672
730
  };
673
- const headSubject = gitSafe(["log", "-1", "--format=%s"]).trim();
674
- const trackingRef = `refs/remotes/origin/${args.ref}`;
675
- const base = gitSafe(["rev-parse", "--verify", "--quiet", trackingRef]).trim()
676
- ? trackingRef
677
- : gitSafe(["rev-parse", "--verify", "--quiet", "HEAD~1"]).trim()
678
- ? "HEAD~1"
679
- : "";
680
- const statusCmd = base ? ["diff", "--name-status", `${base}..HEAD`] : ["show", "--name-status", "--format=", "HEAD"];
681
- const patchEntries = parseNameStatus(gitSafe(statusCmd));
682
- const files = patchEntries.map((e) => e.path);
683
- const statLine = base ? gitSafe(["diff", "--shortstat", `${base}..HEAD`]).trim() : "";
684
- const changeSummary = buildChangeSummary({ message: args.message, summary: args.summary, headSubject, statLine, files });
731
+ function buildSummaryAndPatch(ref) {
732
+ const headSubject = gitSafe(["log", "-1", "--format=%s"]).trim();
733
+ const trackingRef = `refs/remotes/origin/${ref}`;
734
+ const base = gitSafe(["rev-parse", "--verify", "--quiet", trackingRef]).trim()
735
+ ? trackingRef
736
+ : gitSafe(["rev-parse", "--verify", "--quiet", "HEAD~1"]).trim()
737
+ ? "HEAD~1"
738
+ : "";
739
+ const statusCmd = base ? ["diff", "--name-status", `${base}..HEAD`] : ["show", "--name-status", "--format=", "HEAD"];
740
+ const patchEntries = parseNameStatus(gitSafe(statusCmd));
741
+ const files = patchEntries.map((e) => e.path);
742
+ const statLine = base ? gitSafe(["diff", "--shortstat", `${base}..HEAD`]).trim() : "";
743
+ const changeSummary = buildChangeSummary({ message: args.message, summary: args.summary, headSubject, statLine, files });
744
+ return { changeSummary, patchEntries };
745
+ }
685
746
 
686
747
  // The MCP session is needed BOTH to mint a fresh forge push credential (decision
687
748
  // B — right below) and for the candidate/preview read-back after, so establish it
@@ -690,6 +751,15 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
690
751
  const client = createMcpClient(baseUrl);
691
752
  const repo = repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim());
692
753
 
754
+ // Branch-bound (u4) + the isolated candidate ref (b03): resolved BEFORE the push,
755
+ // since the push target itself depends on it — so the raw git push and the
756
+ // PR-backed candidate (step 2b, below) always land on the SAME branch. `active`/
757
+ // `statePath` are local filesystem reads (candidate-state.mjs) — no session
758
+ // needed — so they're available even on the no-session fallback path below.
759
+ const branch = currentBranch(gitSafe);
760
+ const statePath = defaultCandidateStatePath(env);
761
+ const active = repo ? readActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch }) : null;
762
+
693
763
  let session;
694
764
  try {
695
765
  session = await establishSession(client, { env, prefer: args.identity || undefined });
@@ -697,10 +767,15 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
697
767
  // Not signed in / MCP unreachable — we can't mint a fresh credential, so fall
698
768
  // back to pushing over the checkout's EXISTING embedded remote (pre-B behavior:
699
769
  // no worse than before) and skip the read-back that needs a session. The push
700
- // still lands if that embedded token is live.
701
- console.error(`~ pushing ${short} ${args.ref} (origin)`);
770
+ // still lands if that embedded token is live. actorKeyFor(null) degrades to the
771
+ // generic "developer" key still isolated PER BRANCH (never the shared ref),
772
+ // just not per-developer until sign-in succeeds.
773
+ const { changeId } = chooseChangeId({ tenant, actorKey: actorKeyFor(null), branch, active, isNew: args.new });
774
+ const ref = resolvePushRef({ ref: args.ref, changeId });
775
+ const { changeSummary } = buildSummaryAndPatch(ref);
776
+ console.error(`~ pushing ${short} → ${ref} (origin)`);
702
777
  try {
703
- const out = git(["push", "-f", "origin", `HEAD:refs/heads/${args.ref}`]);
778
+ const out = git(["push", "-f", "origin", `HEAD:refs/heads/${ref}`]);
704
779
  if (out.trim()) console.error(redactUrl(out.trim()));
705
780
  } catch (pushErr) {
706
781
  console.error(
@@ -711,7 +786,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
711
786
  );
712
787
  return 1;
713
788
  }
714
- console.log(`\n+ submitted ${short} to ${args.ref}.`);
789
+ console.log(`\n+ submitted ${short} to ${ref}.`);
715
790
  printChangeSummary(changeSummary);
716
791
  if (e instanceof AuthUnavailableError) {
717
792
  console.log(` (sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"})`);
@@ -722,6 +797,15 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
722
797
  return 0;
723
798
  }
724
799
 
800
+ // Which candidate (and therefore which isolated ref, b03) this submit targets —
801
+ // decided now, with a real session, so the SAME id backs both the raw git push
802
+ // (right below) and the PR-backed candidate (step 2b): the two never point at
803
+ // different branches. See chooseChangeId's doc for the --new / active-pointer
804
+ // rules.
805
+ let { changeId, stableId, persist } = chooseChangeId({ tenant, actorKey: actorKeyFor(session), branch, active, isNew: args.new });
806
+ const ref = resolvePushRef({ ref: args.ref, changeId });
807
+ const { changeSummary, patchEntries } = buildSummaryAndPatch(ref);
808
+
725
809
  // 2. push the preview ref with a FRESHLY-MINTED, short-lived forge credential
726
810
  // (decision B). The token baked into `.git/config` at clone time expires within
727
811
  // hours, so we re-mint right before the push and hand it to git ephemerally
@@ -741,9 +825,9 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
741
825
  }
742
826
  };
743
827
 
744
- console.error(`~ pushing ${short} → ${args.ref} (origin, fresh credential)`);
828
+ console.error(`~ pushing ${short} → ${ref} (origin, fresh credential)`);
745
829
  try {
746
- const { out } = await pushPreviewRef(git, mintRemote, { ref: args.ref });
830
+ const { out } = await pushPreviewRef(git, mintRemote, { ref });
747
831
  if (out && out.trim()) console.error(redactUrl(out.trim()));
748
832
  } catch (e) {
749
833
  console.error(
@@ -754,7 +838,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
754
838
  );
755
839
  return 1;
756
840
  }
757
- console.log(`\n+ submitted ${short} to ${args.ref}.`);
841
+ console.log(`\n+ submitted ${short} to ${ref}.`);
758
842
  printChangeSummary(changeSummary);
759
843
 
760
844
  // 2b + 3. open/update the PR-backed candidate, then report reconcile +
@@ -768,25 +852,11 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
768
852
  // 2b. PR-backed candidate (g1b candidate_open, unit c1) — best-effort: a
769
853
  // failure here (older MCP, VC not configured, preview-access capability) is
770
854
  // reported and swallowed, never blocking the preview push that already landed.
771
- //
772
- // Which candidate this submit lands on (gh-pr-like):
773
- // --new → fork a FRESH candidate and remember it as active;
774
- // otherwise → the remembered active candidate (from a prior --new /
775
- // roll), else the STABLE per-dev-per-tenant default.
776
- // If the chosen candidate turns out to be merged/closed, roll to a fresh one
777
- // so a re-submit is never wedged on a dead PR. (`repo` was derived above.)
855
+ // `changeId`/`stableId`/`persist` were already decided above (they picked the
856
+ // push ref too); if the chosen candidate turns out to be merged/closed, roll to
857
+ // a fresh one so a re-submit is never wedged on a dead PR. (`repo` was derived
858
+ // above.)
778
859
  const readBlob = (path) => execFileSync("git", ["-C", workspace, "show", `HEAD:${path}`], { stdio: ["ignore", "pipe", "pipe"] });
779
- const statePath = defaultCandidateStatePath(env);
780
- // Branch-bound (u4): the candidate handle + active-pointer namespace fold in the
781
- // current git branch on a non-default branch, so a feature branch gets its OWN
782
- // candidate; the default branch keeps today's exact id (zero migration).
783
- const branch = currentBranch(gitSafe);
784
- const stableId = deriveChangeId(tenant, actorKeyFor(session), branch);
785
- const active = repo ? readActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch }) : null;
786
- let changeId = args.new ? mintFreshChangeId(stableId) : (active || stableId);
787
- // Persist when we diverge from the stable default (a --new fork, or a
788
- // previously-remembered active pointer) so the next plain submit follows it.
789
- let persist = args.new || (!!active && active !== stableId);
790
860
 
791
861
  let candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob });
792
862