pi-straw-boss 0.58.3 → 0.59.0

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.
@@ -1923,9 +1923,25 @@ function allyPort(link) {
1923
1923
  async taken(id, identity2) {
1924
1924
  await link()?.holder?.taken(id, identity2);
1925
1925
  },
1926
+ delivered(id, run3) {
1927
+ link()?.holder?.delivered(id, run3);
1928
+ },
1929
+ // A device is taken as gone only while the box can tell who is in the team: the policy file reads (a refused one answers `reply` for everybody, g346-F003) and the holder reads the roster. A team the unattended policy
1930
+ // no longer names has no holder run and never will: nobody is in it, so its controllers are gone (g346-F005b).
1931
+ known(team) {
1932
+ const service = link();
1933
+ if (!service) return false;
1934
+ const { mode, config } = service.policyLook();
1935
+ if (mode === "refused") return false;
1936
+ if (mode === "unattended" && !config.teams.has(team)) return service.holder?.active === true;
1937
+ return service.holder?.rosterKnown(team) ?? false;
1938
+ },
1926
1939
  standing(device, team) {
1927
1940
  const service = link();
1928
- return service?.holder?.isMember(team, device) ? { level: service.levelOf(device) } : void 0;
1941
+ if (!service) return void 0;
1942
+ const { mode, config } = service.policyLook();
1943
+ if (mode === "unattended" && !config.teams.has(team)) return void 0;
1944
+ return service.holder?.isMember(team, device) ? { level: service.levelOf(device) } : void 0;
1929
1945
  },
1930
1946
  project: (team) => link()?.config().teams.get(team)?.project,
1931
1947
  thread: (device) => link()?.store.readThread(device).slice(-THREAD_ROWS).map((r) => ({ dir: r.dir, at: r.at, kind: r.kind, text: r.text })) ?? []
@@ -8017,7 +8033,12 @@ function createGuiHost(o) {
8017
8033
  let forwardNet;
8018
8034
  return {
8019
8035
  /** The hub and a net reading that binds its dialogs, for the forwarder an unattended device runs. */
8020
- dialogs: () => ({ hub: theHub(), read: async () => void await (forwardNet ??= netSource({ ...o.tower ? { services: (workspace) => o.tower.view(workspace) } : {}, run: cfg.run, world: cfg.world, sessions, hub: theHub() })).read() }),
8036
+ dialogs: () => ({
8037
+ hub: theHub(),
8038
+ read: async () => void await (forwardNet ??= netSource({ ...o.tower ? { services: (workspace) => o.tower.view(workspace) } : {}, run: cfg.run, world: cfg.world, sessions, hub: theHub() })).read(),
8039
+ /** The pane of a unit by the key the hub gives its dialogs (known after a `read`). */
8040
+ paneOf: (key2) => forwardNet?.lookup(key2)?.pane
8041
+ }),
8021
8042
  /** At the server's start: the GUI on the recorded ports unless it was stopped on purpose (which only the boss's own command undoes). */
8022
8043
  async start() {
8023
8044
  theHub().track();
@@ -8411,6 +8432,14 @@ function decodePlaintext(kind, bytes) {
8411
8432
  }
8412
8433
  }
8413
8434
 
8435
+ // src/link/scope.ts
8436
+ var EVERYONE = "all";
8437
+ function reaches(scope, c, admin) {
8438
+ if (scope === "all") return true;
8439
+ if (admin(c.team, c.device) && (scope.team === void 0 || scope.team === c.team)) return true;
8440
+ return scope.device !== void 0 && scope.device === c.device && (scope.team === void 0 || scope.team === c.team);
8441
+ }
8442
+
8414
8443
  // src/link/dialogs-out.ts
8415
8444
  var MAX_OPEN2 = 32;
8416
8445
  var MAX_SEEN = 1024;
@@ -8454,6 +8483,15 @@ var DialogForwarder = class {
8454
8483
  })().finally(() => this.polling = void 0);
8455
8484
  return this.polling = run3;
8456
8485
  }
8486
+ /** Whose unit asked: looked up until it is known, then kept. A lookup that fails leaves it unknown, which is the admins' alone. */
8487
+ async whose(d) {
8488
+ if (!this.d.dispatcher || d.who) return;
8489
+ d.who = await this.d.dispatcher(d.key).catch(() => void 0);
8490
+ }
8491
+ /** What the question's controllers are: every controller when the box cannot tell whose units are whose, else the dispatcher and the admins. */
8492
+ scopeOf(d) {
8493
+ return this.d.dispatcher ? d.who ? { device: d.who.device, ...d.who.team !== void 0 ? { team: d.who.team } : {} } : {} : EVERYONE;
8494
+ }
8457
8495
  async pass() {
8458
8496
  const to = this.d.controllers();
8459
8497
  if (!to.length && !this.open.size) return;
@@ -8465,13 +8503,16 @@ var DialogForwarder = class {
8465
8503
  if (Buffer.byteLength(text5) > LIMITS2.dialog) {
8466
8504
  this.skipped.add(id);
8467
8505
  this.d.log({ event: "dialog-oversize", dialog: id, bytes: Buffer.byteLength(text5) });
8468
- await this.d.notice?.(`oversize/${id}`, { kind: "dialog-oversize", unit: o.key, text: `A question of ${o.owner} was not forwarded: it is ${Buffer.byteLength(text5)} bytes, over the ${LIMITS2.dialog} the link carries. It waits at the device.` });
8506
+ const unit = { key: o.key };
8507
+ await this.whose(unit);
8508
+ await this.d.notice?.(`oversize/${id}`, { kind: "dialog-oversize", unit: o.key, text: `A question of ${o.owner} was not forwarded: it is ${Buffer.byteLength(text5)} bytes, over the ${LIMITS2.dialog} the link carries. It waits at the device.` }, this.scopeOf(unit));
8469
8509
  continue;
8470
8510
  }
8471
8511
  if (this.open.size >= MAX_OPEN2) continue;
8472
8512
  this.open.set(id, { key: o.key, target: o.target, owner: o.owner, text: text5, sent: /* @__PURE__ */ new Map(), away: /* @__PURE__ */ new Set(), claimed: false, since: this.now(), told: false });
8473
8513
  }
8474
8514
  for (const [id, d] of [...this.open]) {
8515
+ await this.whose(d);
8475
8516
  const state = this.d.hub.outcome(id);
8476
8517
  if (!state.open) {
8477
8518
  this.open.delete(id);
@@ -8483,17 +8524,22 @@ var DialogForwarder = class {
8483
8524
  d.unknownAt = void 0;
8484
8525
  d.sent.clear();
8485
8526
  }
8527
+ const mine = to.filter((c) => reaches(this.scopeOf(d), c, this.d.admin ?? (() => false)));
8486
8528
  for (const k of this.d.controllersKnown?.() === false ? [] : [...d.sent.keys()]) {
8487
- if (to.some((c) => `${c.team}
8529
+ if (mine.some((c) => `${c.team}
8488
8530
  ${c.device}` === k)) continue;
8489
8531
  d.sent.delete(k);
8490
8532
  await this.send(k, "dialog-close", { dialogId: id, by: "withdrawn" });
8491
8533
  }
8492
8534
  if (!d.told && !d.claimed && to.length && this.now() - d.since >= UNANSWERED_MS) {
8493
- d.told = true;
8494
- await this.d.notice?.(`unanswered/${id}`, { kind: "dialog-unanswered", unit: d.key, text: `A question of ${d.owner} has waited ${Math.round((this.now() - d.since) / 6e4)} minutes for an answer.` });
8535
+ const sent = await this.d.notice?.(`unanswered/${id}`, { kind: "dialog-unanswered", unit: d.key, text: `A question of ${d.owner} has waited ${Math.round((this.now() - d.since) / 6e4)} minutes for an answer.` }, this.scopeOf(d));
8536
+ d.told = sent !== false;
8495
8537
  }
8496
- for (const c of to) {
8538
+ if (!mine.length && !d.unrouted) {
8539
+ d.unrouted = true;
8540
+ this.d.log({ event: "dialog-unrouted", dialog: id, unit: d.key, owner: d.owner });
8541
+ }
8542
+ for (const c of mine) {
8497
8543
  const k = `${c.team}
8498
8544
  ${c.device}`;
8499
8545
  const at = d.sent.get(k), up = this.d.online?.(c.team, c.device) ?? true;
@@ -8532,6 +8578,8 @@ ${device}`);
8532
8578
  const drop = (reason) => this.d.log({ event: "drop", reason, kind: "dialog-answer", from: a.from, dialog: a.dialogId });
8533
8579
  const d = this.open.get(a.dialogId);
8534
8580
  if (!d) return drop("dialog-answer-unknown");
8581
+ await this.whose(d);
8582
+ if (!reaches(this.scopeOf(d), { team: a.team, device: a.from }, this.d.admin ?? (() => false))) return drop("dialog-answer-not-recipient");
8535
8583
  if (d.target.answer.digest !== a.digest) return drop("dialog-answer-digest");
8536
8584
  if (!ANSWER_ID2.test(a.answerId) || this.seen.has(a.answerId)) return drop("dialog-answer-id");
8537
8585
  this.seen.add(a.answerId);
@@ -8597,6 +8645,26 @@ ${dialogId}`;
8597
8645
  };
8598
8646
  var newAnswerId = () => randomBytes12(16).toString("hex");
8599
8647
 
8648
+ // src/server/dispatcher.ts
8649
+ function dispatcherOf(pane, listed2, allyPanes) {
8650
+ const mine = listed2.find((p) => p.pane_id === pane);
8651
+ if (!mine) return void 0;
8652
+ const ally = allyPanes.find((a) => a.pane === pane);
8653
+ if (ally) return { device: ally.device, ...ally.team ? { team: ally.team } : {} };
8654
+ const device = listed2.find((p) => p.tab_id === mine.tab_id && p.tokens?.sb_opened_by?.startsWith("link:"))?.tokens?.sb_opened_by?.replace(/^(link:)+/, "");
8655
+ return device && /^[a-z2-7]{26}$/.test(device) ? { device } : void 0;
8656
+ }
8657
+ function dispatcherLookup(d) {
8658
+ let listed2;
8659
+ return async (key2) => {
8660
+ const pane = d.paneOf(key2);
8661
+ if (!pane) return void 0;
8662
+ if (!listed2 || d.now() - listed2.at > 2e3) listed2 = { at: d.now(), panes: d.list().catch(() => void 0) };
8663
+ const panes3 = await listed2.panes;
8664
+ return panes3 ? dispatcherOf(pane, panes3, d.allyPanes()) : void 0;
8665
+ };
8666
+ }
8667
+
8600
8668
  // src/server/opentower.ts
8601
8669
  import { realpathSync as realpathSync7 } from "node:fs";
8602
8670
  import { basename as basename8 } from "node:path";
@@ -10708,8 +10776,14 @@ var LinkHolder = class {
10708
10776
  ...message.awaitsReply !== void 0 ? { awaitsReply: message.awaitsReply } : {}
10709
10777
  };
10710
10778
  this.o.store.putInbox(item);
10779
+ this.o.store.log({ event: "tower-in", kind: "tower", team: run3.team, from: header2.from, id: item.id, ...message.awaitsReply !== void 0 ? { ask: message.awaitsReply } : {} });
10711
10780
  this.thread(header2.from, { dir: "in", at: item.received, id: item.id, kind: "tower", ...message.awaitsReply !== void 0 ? { awaitsReply: message.awaitsReply } : {}, text: message.text });
10712
10781
  }
10782
+ /** An ally run took the inbox message `id` into its session: one `link.log` line names the run, the sender, the team and the ask, never the text (docs/specs/2026-11-05-link-shared-box.md, Audit). */
10783
+ delivered(id, run3) {
10784
+ const item = this.o.store.readInbox(id);
10785
+ if (item) this.o.store.log({ event: "tower-run", run: run3, team: item.team, from: item.from, id, ...item.awaitsReply !== void 0 ? { ask: item.awaitsReply } : {} });
10786
+ }
10713
10787
  /** A row of a device's thread, under the lock; a store that cannot write it does not lose the message. */
10714
10788
  thread(device, row2) {
10715
10789
  try {
@@ -10742,6 +10816,7 @@ var LinkHolder = class {
10742
10816
  ...taken ? { reply: { ask: message.inReplyTo, main: taken.main, mainName: taken.mainName, tower: taken.tower } } : {}
10743
10817
  };
10744
10818
  this.o.store.putInbox(item);
10819
+ this.o.store.log({ event: "tower-in", kind: "tower-reply", team: run3.team, from: header2.from, id: item.id, ask: message.inReplyTo });
10745
10820
  this.thread(header2.from, { dir: "in", at: item.received, id: item.id, kind: "tower-reply", inReplyTo: message.inReplyTo, text: message.text });
10746
10821
  }
10747
10822
  /** The election's record no longer names this session: nothing is acted on from here, and every stream ends. */
@@ -10895,7 +10970,7 @@ var LinkHolder = class {
10895
10970
  store.log({ event: "tower-open", project, from });
10896
10971
  const attempt = (async () => {
10897
10972
  try {
10898
- await openTower2(project, toWorkspace);
10973
+ await openTower2(project, toWorkspace, from);
10899
10974
  store.log({ event: "tower-opened", project });
10900
10975
  } catch (error) {
10901
10976
  store.log({ event: "tower-open-failed", project, error: error.message });
@@ -11078,6 +11153,17 @@ ${target.id}`, { text: req.text, at: this.now() });
11078
11153
  }
11079
11154
  return items.sort((a, b) => a.received - b.received || (a.id < b.id ? -1 : 1));
11080
11155
  }
11156
+ /** Whether this holder can tell who is in `team` right now: it runs, the team runs and has not died, and its roster reads. A device is taken as gone only while this is true (the revocation of a controller's mains). */
11157
+ rosterKnown(team) {
11158
+ const run3 = this.runs.get(team);
11159
+ if (!this.running || !run3 || run3.dead) return false;
11160
+ try {
11161
+ this.loadTeam(team);
11162
+ return true;
11163
+ } catch {
11164
+ return false;
11165
+ }
11166
+ }
11081
11167
  /** Whether `device` is a member of `team` on this computer's roster (a run whose sender left is ended). */
11082
11168
  isMember(team, device) {
11083
11169
  try {
@@ -11187,6 +11273,14 @@ ${target.id}`, { text: req.text, at: this.now() });
11187
11273
  dialogControllers() {
11188
11274
  return this.controllerRead().list;
11189
11275
  }
11276
+ /** Whether `device` is an admin of `team`'s roster now (an admin is told every question and notice of the box). False for a team that is not running or a roster that cannot be read. */
11277
+ isAdmin(team, device) {
11278
+ try {
11279
+ return this.loadTeam(team).state.members.get(device)?.role === "admin";
11280
+ } catch {
11281
+ return false;
11282
+ }
11283
+ }
11190
11284
  /** False while the controller list cannot be trusted to be complete (the holder is not running, or a team's roster could not be read): a caller does not withdraw anything then. */
11191
11285
  controllersKnown() {
11192
11286
  return this.controllerRead().known;
@@ -12288,6 +12382,13 @@ function createLinkService({ kit, stateDir, version, started, pid = process.pid,
12288
12382
  return store;
12289
12383
  },
12290
12384
  attended,
12385
+ /** How the policy file reads now: a refused one means the box cannot tell who may do what (g346-F003). */
12386
+ policyMode: () => policy().mode,
12387
+ /** The policy mode and the effective config from one read of the policy file, so the two cannot disagree. */
12388
+ policyLook() {
12389
+ const p = policy();
12390
+ return { mode: p.mode, config: readEffectiveConfig(p, stateDir, { home, agentDir }) };
12391
+ },
12291
12392
  /** The teams and projects of config.json, read fresh. */
12292
12393
  config,
12293
12394
  /** The level this computer gives a device (the policy file's, else `link.trust`, else `reply`): an ally run is claimed at it. */
@@ -13595,11 +13696,11 @@ ${c.device}`, units = mine.map((u) => ({ ...u, report: cutBytes(this.d.report(u.
13595
13696
  }
13596
13697
  }
13597
13698
  }
13598
- /** Tells every controller, once per `key`; nothing is remembered when there was nobody to tell, so the next trigger with the same key still goes out. */
13599
- async notify(key2, notice) {
13600
- if (this.told.has(key2)) return;
13601
- const to = this.d.controllers();
13602
- if (!to.length) return;
13699
+ /** Tells the controllers `scope` covers (the dispatcher and the admins; `EVERYONE` for all), once per `key`; nothing is remembered when there was nobody to tell, so the next trigger with the same key still goes out. Resolves whether anybody was addressed. */
13700
+ async notify(key2, notice, scope) {
13701
+ if (this.told.has(key2)) return false;
13702
+ const to = this.d.controllers().filter((c) => reaches(scope, c, this.d.admin ?? (() => false)));
13703
+ if (!to.length) return false;
13603
13704
  this.told.add(key2);
13604
13705
  while (this.told.size > TOLD_MAX) this.told.delete(this.told.values().next().value);
13605
13706
  this.d.log({ event: "notice", key: key2, kind: notice.kind, unit: notice.unit });
@@ -13610,14 +13711,17 @@ ${c.device}`, units = mine.map((u) => ({ ...u, report: cutBytes(this.d.report(u.
13610
13711
  this.d.log({ event: "notice-post-failed", device: c.device, kind: notice.kind, error: String(error.message).slice(0, 200) });
13611
13712
  }
13612
13713
  }
13714
+ return true;
13613
13715
  }
13614
13716
  };
13615
13717
  function runNotice(s2) {
13718
+ const scope = s2.device !== void 0 ? { device: s2.device, ...s2.team !== void 0 ? { team: s2.team } : {} } : {};
13616
13719
  const who = s2.ally ? "An ally run" : "A run";
13617
- if (s2.kind === "refused") return { key: `refused/${s2.workspace}/${createHash12("sha256").update(s2.text).digest("hex").slice(0, 16)}`, notice: { kind: "run-refused", unit: allyLabel(s2.label), text: s2.text } };
13720
+ if (s2.kind === "revoked") return { key: `revoked/${s2.main}/${s2.ended ? "ended" : s2.manual ? "manual" : "pending"}`, notice: { kind: s2.ended ? "main-revoked" : "main-revoke-pending", unit: allyLabel(s2.label), text: s2.text }, scope };
13721
+ if (s2.kind === "refused") return { key: `refused/${s2.workspace}/${createHash12("sha256").update(s2.text).digest("hex").slice(0, 16)}`, notice: { kind: "run-refused", unit: allyLabel(s2.label), text: s2.text }, scope };
13618
13722
  const trust = s2.detail?.startsWith("No trust decision");
13619
13723
  const kind = trust ? "trust-unanswered" : s2.outcome === "quit" ? "run-quit" : s2.outcome === "timeout" ? "run-timeout" : "run-crashed";
13620
- return { key: `run/${s2.run}`, notice: { kind, unit: allyLabel(s2.label), text: `${who} ${s2.outcome === "quit" ? "was quit" : s2.outcome === "timeout" ? "timed out" : "crashed"}${s2.detail ? `: ${s2.detail}` : ""}` } };
13724
+ return { key: `run/${s2.run}`, notice: { kind, unit: allyLabel(s2.label), text: `${who} ${s2.outcome === "quit" ? "was quit" : s2.outcome === "timeout" ? "timed out" : "crashed"}${s2.detail ? `: ${s2.detail}` : ""}` }, scope };
13621
13725
  }
13622
13726
 
13623
13727
  // src/server/towerrunstore.ts
@@ -15461,6 +15565,62 @@ function removeAnswer(slot, workspace, id, now) {
15461
15565
  if (was.some((a) => a.id === id)) write(slot, workspace, ANSWERS, was.filter((a) => a.id !== id));
15462
15566
  }
15463
15567
 
15568
+ // src/server/revoke.ts
15569
+ var GONE = /No live session is named|is not in this workspace|is not a main agent|was not opened by a tower/;
15570
+ function createRevoker(d) {
15571
+ const told = /* @__PURE__ */ new Set();
15572
+ let low = /* @__PURE__ */ new Set();
15573
+ let goneSeen = /* @__PURE__ */ new Map();
15574
+ let running;
15575
+ const pass = async () => {
15576
+ const now = /* @__PURE__ */ new Set(), gone = /* @__PURE__ */ new Map();
15577
+ for (const row2 of d.opened()) {
15578
+ if (row2.level !== void 0 && row2.level !== "dispatch") continue;
15579
+ if (!d.known(row2.team)) continue;
15580
+ const standing = d.standing(row2.device, row2.team);
15581
+ if (standing?.level === "dispatch") continue;
15582
+ now.add(row2.main);
15583
+ if (!low.has(row2.main)) continue;
15584
+ const why = standing ? "the device lost dispatch" : "the device left the team", who = `The main "${row2.name}" opened for ${row2.deviceLabel}`;
15585
+ try {
15586
+ await d.close(row2.workspace, row2.main);
15587
+ } catch (cause) {
15588
+ const reason = cause.message;
15589
+ if (GONE.test(reason)) {
15590
+ const seen = (goneSeen.get(row2.main) ?? 0) + 1;
15591
+ gone.set(row2.main, seen);
15592
+ if (seen < 2) continue;
15593
+ if (await d.paneGone(row2.workspace, row2.pane)) {
15594
+ d.log({ event: "revoke-gone", workspace: row2.workspace, main: row2.main });
15595
+ d.forget(row2.workspace, row2.main);
15596
+ gone.delete(row2.main);
15597
+ continue;
15598
+ }
15599
+ }
15600
+ const manual = /was not opened by a tower/.test(reason), key2 = `${row2.main}/${manual ? "manual" : "pending"}`;
15601
+ d.log({ event: "revoke-waiting", workspace: row2.workspace, main: row2.main, why });
15602
+ if (!told.has(key2)) {
15603
+ const sent = await d.signal({ kind: "revoked", workspace: row2.workspace, label: row2.label, ally: true, device: row2.device, ...row2.team ? { team: row2.team } : {}, main: row2.main, ended: false, ...manual ? { manual: true } : {}, text: manual ? `${who} still runs and cannot be ended by the box (${reason}). Close it by hand.` : `${who} could not be ended yet (${reason}). The box asks again until it quits.` });
15604
+ if (sent !== false) {
15605
+ told.add(key2);
15606
+ while (told.size > 256) told.delete(told.values().next().value);
15607
+ }
15608
+ }
15609
+ continue;
15610
+ }
15611
+ d.log({ event: "revoke-ended", workspace: row2.workspace, main: row2.main, why });
15612
+ d.forget(row2.workspace, row2.main);
15613
+ d.signal({ kind: "revoked", workspace: row2.workspace, label: row2.label, ally: true, device: row2.device, ...row2.team ? { team: row2.team } : {}, main: row2.main, ended: true, text: `${who} was ended: ${why}.` });
15614
+ }
15615
+ low = now;
15616
+ goneSeen = gone;
15617
+ };
15618
+ return {
15619
+ /** One look at every opened main; looks that overlap share the one in flight. A failure is logged and the next sweep tries again. */
15620
+ sweep: () => running ??= pass().catch((cause) => d.log({ event: "revoke-failed", error: cause.message.slice(0, 200) })).finally(() => running = void 0)
15621
+ };
15622
+ }
15623
+
15464
15624
  // src/server/runs.ts
15465
15625
  import { randomBytes as randomBytes17 } from "node:crypto";
15466
15626
  import { lstatSync as lstatSync7, realpathSync as realpathSync10 } from "node:fs";
@@ -15991,7 +16151,7 @@ function createRuns2(o) {
15991
16151
  release();
15992
16152
  svc.refusedAt = now();
15993
16153
  noticeOf(svc, { id: "settings", level: "error", kind: "settings", text: `A run could not start: ${cause.message}` });
15994
- o.signal?.({ kind: "refused", workspace: svc.ref.workspace, label: svc.ref.record().label, ally: true, text: `A run could not start: ${cause.message}` });
16154
+ o.signal?.({ kind: "refused", workspace: svc.ref.workspace, label: svc.ref.record().label, ally: true, text: `A run could not start: ${cause.message}`, device, team: first2.team });
15995
16155
  log.line("run-refused", { workspace: svc.ref.workspace, why: "settings", kind: "ally" });
15996
16156
  return;
15997
16157
  }
@@ -16202,7 +16362,7 @@ function createRuns2(o) {
16202
16362
  a.rec.outcome = outcome;
16203
16363
  a.rec.endedAt = iso2(now());
16204
16364
  if (detail) a.rec.detail = phrase4(detail, 200);
16205
- if (outcome === "crashed" || outcome === "timeout" || outcome === "quit") o.signal?.({ kind: "ended", run: a.rec.id, workspace: ws, label: svc.ref.record().label, ally: a.rec.kind === "ally", outcome, ...detail ? { detail: phrase4(detail, 200) } : {} });
16365
+ if (outcome === "crashed" || outcome === "timeout" || outcome === "quit") o.signal?.({ kind: "ended", run: a.rec.id, workspace: ws, label: svc.ref.record().label, ally: a.rec.kind === "ally", outcome, ...detail ? { detail: phrase4(detail, 200) } : {}, ...a.rec.kind === "ally" && a.rec.device ? { device: a.rec.device, ...a.rec.team ? { team: a.rec.team } : {} } : {} });
16206
16366
  try {
16207
16367
  writeRun(slot, a.rec);
16208
16368
  pruneRuns(slot, ws);
@@ -16324,7 +16484,7 @@ function createRuns2(o) {
16324
16484
  const orphan = a.rec.hand === true && !a.polled && a.pending === void 0 && a.closing === void 0;
16325
16485
  const outcome = a.pending ?? (a.closing !== void 0 ? "done" : orphan ? "quit" : "crashed");
16326
16486
  if (outcome === "crashed" && diedWithHerdr(a)) continue;
16327
- await finish(a, outcome, outcome === "crashed" ? "pi exited without tower_done" : orphan ? "the pi left before its first poll" : void 0, a.left === true);
16487
+ await finish(a, outcome, outcome === "crashed" ? "pi exited without tower_done" : orphan ? "the pi left before its first poll" : a.why, a.left === true);
16328
16488
  continue;
16329
16489
  }
16330
16490
  if (a.recovering !== void 0) {
@@ -16353,9 +16513,18 @@ function createRuns2(o) {
16353
16513
  if (!a.token) continue;
16354
16514
  if (a.rec.kind === "ally" && !standingOf(a)) {
16355
16515
  log.line("ally-ended", { workspace: a.rec.workspace, run: a.rec.id, why: "the sender left the roster" });
16516
+ a.why = "the sender left the team";
16356
16517
  terminate(a, "quit");
16357
16518
  continue;
16358
16519
  }
16520
+ const low = a.rec.kind === "ally" && a.rec.level === "dispatch" && o.ally?.known?.(a.rec.team ?? "") !== false && standingOf(a)?.level !== "dispatch";
16521
+ if (low && a.lowSeen) {
16522
+ log.line("ally-ended", { workspace: a.rec.workspace, run: a.rec.id, why: "the sender lost dispatch" });
16523
+ a.why = "the sender lost dispatch";
16524
+ terminate(a, "quit");
16525
+ continue;
16526
+ }
16527
+ a.lowSeen = low;
16359
16528
  if (now() - a.lastPoll > t.lost) {
16360
16529
  log.line("run-lost", { run: a.rec.id });
16361
16530
  terminate(a, "crashed");
@@ -16526,6 +16695,7 @@ function createRuns2(o) {
16526
16695
  a.offered.delete(event.id);
16527
16696
  a.rec.events.push(event.id);
16528
16697
  writeRun(slot, a.rec);
16698
+ if (a.rec.kind === "ally") o.ally?.delivered?.(event.id, a.rec.id);
16529
16699
  if (event.kind === "boss") bossTurn(a);
16530
16700
  return { ok: true };
16531
16701
  }
@@ -16790,7 +16960,7 @@ function createRuns2(o) {
16790
16960
  const a = of(token);
16791
16961
  if (a && !a.ended && a.rec.kind === "ally") {
16792
16962
  const standing = standingOf(a);
16793
- if (!standing) terminate(a, "quit");
16963
+ if (!standing) a.why ??= "the sender left the team", terminate(a, "quit");
16794
16964
  return { id: a.rec.id, workspace: a.rec.workspace, kind: "ally", ...standing ? { level: standing.level } : { gone: true }, ...a.rec.device ? { device: a.rec.device } : {}, ...a.rec.deviceLabel ? { deviceLabel: a.rec.deviceLabel } : {}, ...a.rec.deviceShort ? { deviceShort: a.rec.deviceShort } : {}, ...a.rec.team ? { team: a.rec.team } : {}, ...a.rec.teamName ? { teamName: a.rec.teamName } : {}, pane: a.rec.pane, asks: itemsOf(a, true).flatMap((i) => i.awaitsReply ? [i.awaitsReply] : []) };
16795
16965
  }
16796
16966
  return a && !a.ended ? { id: a.rec.id, workspace: a.rec.workspace, kind: headRun(a) ? "head" : a.rec.kind, ...a.rec.level ? { level: a.rec.level } : {}, ...a.rec.device ? { device: a.rec.device } : {}, ...a.rec.deviceLabel ? { deviceLabel: a.rec.deviceLabel } : {}, ...a.rec.team ? { team: a.rec.team } : {}, pane: a.rec.pane, asks: itemsOf(a, true).flatMap((i) => i.awaitsReply ? [i.awaitsReply] : []) } : void 0;
@@ -16911,6 +17081,8 @@ function createRuns2(o) {
16911
17081
  parked: readQueue(slot, workspace, true).map((e) => ({ id: e.id, kind: e.kind, ...e.from?.name ?? e.from?.id ? { from: e.from?.name ?? e.from?.id } : {}, first: phrase4(e.text.split("\n").find((l) => l.trim()) ?? "", 120) }))
16912
17082
  };
16913
17083
  },
17084
+ /** The panes of the live ally runs, with the device each serves and its team: the units a controller dispatched (a question asked in one is that controller's). */
17085
+ allyPanes: () => [...services.values()].flatMap((svc) => [...svc.allies.values()].filter((a) => !a.ended && a.rec.pane && a.rec.device).map((a) => ({ pane: a.rec.pane, device: a.rec.device, ...a.rec.team ? { team: a.rec.team } : {} }))),
16914
17086
  /** The waiting events of a workspace for the GUI's queue rows. */
16915
17087
  queue: (workspace) => {
16916
17088
  const s2 = find2(workspace);
@@ -17087,6 +17259,7 @@ var UNKNOWN = "(unknown)";
17087
17259
  var RUN_SEND_CAP_MS = 2e4;
17088
17260
  var RETRY_MS3 = [1e3, 2e3, 5e3, 1e4, 3e4];
17089
17261
  var REPUBLISH_MS = 300;
17262
+ var REVOKE_MS = 15e3;
17090
17263
  var REFRESH_MS2 = 4 * 6e4;
17091
17264
  var RECOVER_MS = 6e4;
17092
17265
  var RECOVER_POLL_MS = 2e3;
@@ -17216,10 +17389,14 @@ function createTowers(o) {
17216
17389
  standing: (device, team) => o.ally.standing(device, team),
17217
17390
  project: (team) => o.ally.project(team),
17218
17391
  thread: (device) => o.ally.thread(device),
17392
+ known: (team) => o.ally.known?.(team) ?? true,
17219
17393
  waiting: (identity2) => {
17220
17394
  const ws = workspaceOf(identity2), answers2 = ws ? readAnswers(slot, ws, now()).map((a) => ({ id: `${LOCAL}${a.id}`, device: a.device, label: a.label, short: a.short, team: a.team, teamName: a.teamName, fromWorkspace: "", replyExpected: false, text: a.text, at: a.at, local: a.from, ...a.ask ? { awaitsReply: a.ask } : {} })) : [];
17221
17395
  return [...o.ally.waiting(identity2), ...answers2].sort((a, b) => a.at - b.at);
17222
17396
  },
17397
+ delivered: (id, run4) => {
17398
+ if (!id.startsWith(LOCAL)) o.ally.delivered?.(id, run4);
17399
+ },
17223
17400
  taken: async (id, identity2) => {
17224
17401
  const ws = workspaceOf(identity2);
17225
17402
  if (!id.startsWith(LOCAL)) return o.ally.taken(id, identity2);
@@ -17691,6 +17868,7 @@ function createTowers(o) {
17691
17868
  error("purge", cause.message);
17692
17869
  }
17693
17870
  }, o.refreshMs ?? REFRESH_MS2));
17871
+ timers2.push(setInterval(() => void revoker.sweep(), o.revokeMs ?? REVOKE_MS));
17694
17872
  const poll = o.recoverPollMs ?? RECOVER_POLL_MS;
17695
17873
  timers2.push(setInterval(() => {
17696
17874
  if (now() < recoverUntil + poll) look2();
@@ -17856,6 +18034,25 @@ function createTowers(o) {
17856
18034
  }
17857
18035
  return closed;
17858
18036
  };
18037
+ const revoker = createRevoker({
18038
+ opened: () => o.ally ? [...services.values()].filter((l) => !l.stopped && l.state === "live" && l.control && !isHeadL(l)).flatMap((l) => readRoutes(slot, l.record.workspace, now()).filter((r) => r.kind === "opened").map((r) => ({ workspace: l.record.workspace, label: l.record.label, main: r.to, name: r.name, device: r.device, deviceLabel: r.label, team: r.team, ...r.level ? { level: r.level } : {}, ...r.pane ? { pane: r.pane } : {} }))) : [],
18039
+ known: (team) => o.ally?.known?.(team) ?? true,
18040
+ standing: (device, team) => o.ally?.standing(device, team),
18041
+ close: async (workspace, main) => void await need(workspace).control.closeMain(main),
18042
+ paneGone: async (workspace, pane) => {
18043
+ if (!pane) return false;
18044
+ try {
18045
+ const net = await need(workspace).reach.read();
18046
+ return !net.ps.some((p) => p.pane_id === pane);
18047
+ } catch {
18048
+ return false;
18049
+ }
18050
+ },
18051
+ // Herdr's listing is the second witness that a main is gone; no pane or a failed read confirms nothing
18052
+ forget: (workspace, main) => void forgetClosed(workspace, { main }),
18053
+ signal: (s2) => o.runs?.signal?.(s2),
18054
+ log: ({ event, ...rest }) => log.line(String(event), rest)
18055
+ });
17859
18056
  const answered = answeredAsks(now);
17860
18057
  const runToolFor = async (request) => {
17861
18058
  const who = runs2.runOf(request.token);
@@ -17865,7 +18062,7 @@ function createTowers(o) {
17865
18062
  const peer = who.kind === "ally" && who.device && who.team ? { device: who.device, label: who.deviceLabel ?? who.device.slice(0, 6), short: who.deviceShort ?? who.device.slice(0, 6), team: who.team, teamName: who.teamName ?? who.team.slice(0, 6) } : void 0;
17866
18063
  const route = (to, name3, kind, ask, live, panes3, pane) => {
17867
18064
  try {
17868
- addRoute(slot, ws, { ...peer, to, name: name3, kind, at: now(), ...ask ? { ask } : {}, ...pane ? { pane } : {} }, now(), live, panes3);
18065
+ addRoute(slot, ws, { ...peer, to, name: name3, kind, at: now(), ...ask ? { ask } : {}, ...pane ? { pane } : {}, ...kind === "opened" && who.level ? { level: who.level } : {} }, now(), live, panes3);
17869
18066
  } catch (cause) {
17870
18067
  error("ally-route", cause.message);
17871
18068
  }
@@ -18627,9 +18824,9 @@ async function runServer(o) {
18627
18824
  ...o.towers,
18628
18825
  // A seam that watches the runs (tests) sees every signal too, and does not take the place of the notice to the controllers.
18629
18826
  runs: { ...o.towers?.runs, signal: (sig) => {
18630
- const r = runNotice(sig);
18631
- void reporter?.notify(r.key, r.notice);
18827
+ const r = runNotice(sig), sent = reporter?.notify(r.key, r.notice, r.scope);
18632
18828
  o.towers?.runs?.signal?.(sig);
18829
+ return sent;
18633
18830
  } }
18634
18831
  });
18635
18832
  stops.push(cfg.watch(() => {
@@ -18648,7 +18845,10 @@ async function runServer(o) {
18648
18845
  }
18649
18846
  }), o.herdrPollMs ?? HERDR_POLL_MS));
18650
18847
  const dialogsOut = () => forwarder ??= new DialogForwarder({
18651
- ...guiHost.dialogs(),
18848
+ hub: guiHost.dialogs().hub,
18849
+ read: guiHost.dialogs().read,
18850
+ dispatcher: dispatcherLookup({ paneOf: (key2) => guiHost.dialogs().paneOf(key2), list: () => panes(cfg.run), allyPanes: () => towers?.runs.allyPanes() ?? [], now }),
18851
+ admin: (team, device) => link?.holder?.isAdmin(team, device) ?? false,
18652
18852
  controllers: () => link?.holder?.dialogControllers() ?? [],
18653
18853
  online: (team, device) => link?.holder?.deviceOnline(team, device) ?? false,
18654
18854
  controllersKnown: () => link?.holder?.controllersKnown() ?? false,
@@ -18658,11 +18858,12 @@ async function runServer(o) {
18658
18858
  },
18659
18859
  log: ({ event, ...rest }) => log.line(String(event), Object.fromEntries(Object.entries(rest).map(([k, v]) => [k, typeof v === "number" || typeof v === "boolean" ? v : phrase6(v, 200)]))),
18660
18860
  now,
18661
- notice: (key2, notice) => unitsOut().notify(key2, notice)
18861
+ notice: (key2, notice, scope) => unitsOut().notify(key2, notice, scope)
18662
18862
  });
18663
18863
  const unitWorkspaces = /* @__PURE__ */ new Map();
18664
18864
  const unitsOut = () => reporter ??= new UnitReporter({
18665
18865
  controllers: () => link?.holder?.dialogControllers() ?? [],
18866
+ admin: (team, device) => link?.holder?.isAdmin(team, device) ?? false,
18666
18867
  // Per team: the units whose workspace has a pane inside that team's project, when its policy shares status. Labels and monitor commands leave this computer, so they pass the same scrub as a board's.
18667
18868
  units: async (team) => {
18668
18869
  const policy = link?.holder?.policyOf(team), rows = towers?.status() ?? [];
@@ -18714,11 +18915,11 @@ ${unit}`), note = ws ? readNotes(slot, ws).at(-1) : void 0, sent = ws ? link?.ho
18714
18915
  asks: { take: (q) => asks.take(q) },
18715
18916
  served: (identity2) => towers?.allyWaiting(identity2),
18716
18917
  confirmed: (to, dialogId, allow) => towers?.runs.confirmed(dialogId, allow) ?? false,
18717
- openTower: async (project, toWorkspace) => {
18918
+ openTower: async (project, toWorkspace, from) => {
18718
18919
  try {
18719
18920
  await openTower({ run: cfg.run, towers: () => towers, now, home: o.link?.home ?? homedir12(), agentDir: env.PI_CODING_AGENT_DIR ?? join44(o.link?.home ?? homedir12(), ".pi", "agent") }, project, toWorkspace);
18720
18921
  } catch (error) {
18721
- void unitsOut().notify(`tower-open/${project}/${Math.floor(now() / 6e5)}`, { kind: "tower-open-failed", unit: allyLabel(project), text: `Could not open a tower for ${project}: ${error.message}` });
18922
+ void unitsOut().notify(`tower-open/${from}/${project}/${Math.floor(now() / 6e5)}`, { kind: "tower-open-failed", unit: allyLabel(project), text: `Could not open a tower for ${project}: ${error.message}` }, { device: from });
18722
18923
  throw error;
18723
18924
  }
18724
18925
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-straw-boss",
3
- "version": "0.58.3",
3
+ "version": "0.59.0",
4
4
  "description": "Straw Boss coordination for pi in Herdr: sessions join a tab's net by choice, the first joiner is its main agent, a workspace tower opens on demand with a live monitoring dashboard, and boss_broadcast reaches the rest",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -4,7 +4,7 @@ The project's managed apps live in `.straw-boss/apps.json` at the git root, or i
4
4
 
5
5
  ```json
6
6
  {"integration": {"skill": "integrate", "maxCars": 4, "maxWaitMin": 60},
7
- "apps": [{"name": "api", "dir": "apps/api", "match": ["backend", "rest api"], "integration": {"base": "develop"}}]}
7
+ "apps": [{"name": "api", "dir": "apps/api", "match": ["backend", "rest api"], "integration": {"base": "develop", "stages": ["merge", "deploy", "uat"], "targets": {"test-env": "auto"}}}]}
8
8
  ```
9
9
 
10
10
  | Field | Meaning |
@@ -16,7 +16,7 @@ The project's managed apps live in `.straw-boss/apps.json` at the git root, or i
16
16
  | `localFiles` | Gitignored files a new worktree needs, each with `path` relative to `dir`; copy them before launch. A `sensitive: true` file is copied only under the user's authorization, and an entry without `optional: true` that is missing blocks the launch. |
17
17
  | `crossAppSkills` | `withApp` and `skill`: an existing skill that handles this app depending on `withApp`; name it in the brief. |
18
18
  | `integration` (top level) | The project's merge train defaults: `skill` is the name of the project skill that holds the concrete checks (a name, as `gitWorkflowSkill` is), `maxCars` (default 4) and `maxWaitMin` (default 60) say when a train departs; the skill may override them. |
19
- | `integration` (per app) | `base` is the integration branch; `skill` overrides the project's. Only an app with this field takes the merge train; an app without it keeps today's flow, and the main merges after its own checks. |
19
+ | `integration` (per app) | `base` is the integration branch; `skill` overrides the project's; `stages` lists, in order, what the integrator carries each train through: `merge` (the default, always first), then optionally `deploy`, then `uat` (only after `deploy`); `targets` maps a deploy target to `auto` (every train deploys there with no per-car authorization: the boss's standing word, so only the boss sets it: a main adds or widens an `auto` entry only with the boss's words for it quoted in the commit message) or `strict` (only cars whose slip `auth` names it; the default for an unlisted target). Only an app with this field takes the merge train; an app without it keeps today's flow, and the main merges after its own checks. |
20
20
 
21
21
  ## Merge train
22
22
 
@@ -31,7 +31,10 @@ The skill is found by the headings below, exactly as written. Straw-boss reads n
31
31
  | `## Full gates` | integrator | The heavy checks, which depend on the clock (run at low load), and which failures may be rerun as flakes. |
32
32
  | `## Merge` | integrator | How the train enters the base branch. |
33
33
  | `## Post-merge` | integrator | What to watch after the merge, how to confirm the release, who closes tickets. |
34
+ | `## Deploy` | integrator | Stage `deploy`: how the train reaches each target, in what order, what a target needs beyond the slip's auth, and how a failed one is rolled back. |
35
+ | `## Deploy verify` | integrator | Stage `deploy`: what shows a target runs the train's own version. |
36
+ | `## UAT` | coordinator, integrator | Stage `uat`: which cars carry checkpoints, where and how the deployed result is captured and by whom, and whether a car the boss comments on is reverted before its fix. |
34
37
  | `## Urgent lane` | integrator | Optional: when a car may jump the queue, and whether it may interrupt a running gate. |
35
38
  | `## Load` | integrator | Optional: the project's lock name and load thresholds. |
36
39
 
37
- `Light checks`, `Full gates`, and `Merge` are required. If one is missing, the integrator does not depart and calls the tower with `why=blocked`; it never guesses a section.
40
+ `Light checks`, `Full gates`, and `Merge` are required, and so is each heading of a stage an app declares. If one is missing, the integrator does not depart and calls the tower with `why=blocked`; it never guesses a section.
@@ -1,12 +1,12 @@
1
1
  # Integrator
2
2
 
3
- You are the integrator of one project: the single main that merges its coordinators' cars into the integration branch of each app with an `integration` field (apps-config.md beside this file). You are a main, not the tower. The project's skill (apps-config `integration.skill`) holds every concrete check: read its headings `Light checks`, `Train assembly`, `Full gates`, `Merge`, `Post-merge`, `Urgent lane`, `Load`; add nothing it does not say. If `Light checks`, `Full gates`, or `Merge` is missing, do not depart: `call_tower` with `why=blocked` and name the config error.
3
+ You are the integrator of one project: the single main that takes its coordinators' cars, for each app with an `integration` field (apps-config.md beside this file), through the stages the app's `stages` list in order: `merge` into its integration branch, then `deploy`, then `uat`. You are a main, not the tower. The project's skill (apps-config `integration.skill`) holds every concrete check: read its headings `Light checks`, `Train assembly`, `Full gates`, `Merge`, `Post-merge`, `Deploy`, `Deploy verify`, `UAT`, `Urgent lane`, `Load`; add nothing it does not say. If `Light checks`, `Full gates`, or `Merge` is missing, or a declared stage lacks its own (`deploy`: `Deploy` and `Deploy verify`; `uat`: `UAT`), do not depart: `call_tower` with `why=blocked` and name the config error.
4
4
 
5
5
  ## Rules
6
6
 
7
- - You cannot create the boss's authorization: merge nothing whose car lacks a quote of one that covers the integration branch.
8
- - Only you merge into the integration branch and claim `pre-dev/<code>`. Your own merges and pushes need no `shared` call: the slips' auth covers them.
9
- - Name: `boss_rename` yourself `integ <project>` (16 characters at most: shorten the project) and never rename again. Keep a ledger at `~/.pi/agent/straw-boss/trains/<project>/<app>.json` (car, tickets, SHAs, stage) and one todo per train.
7
+ - You cannot create the boss's authorization: merge nothing whose car lacks a quote of one that covers the integration branch. A deploy target is `auto` or `strict` by the app's `integration.targets` (unlisted: `strict`): an `auto` target takes every train, the config being the boss's standing word; a `strict` one only cars whose `auth` names it.
8
+ - Only you merge into the integration branch and claim `pre-dev/<code>`. Your own merges, pushes, and merge-triggered deploys need no `shared` call: the slips' auth covers them. A deploy you start yourself still calls `shared` naming the target, so the tower can see a clash.
9
+ - Name: `boss_rename` yourself `integ <project>` (16 characters at most: shorten the project) and never rename again. Keep a ledger at `~/.pi/agent/straw-boss/trains/<project>/<app>.json` (car, tickets, SHAs, stage, deployed versions per target, UAT verdicts) and one todo per train.
10
10
 
11
11
  ## Receiving a car
12
12
 
@@ -16,19 +16,28 @@ A car slip is one block, and one missing field returns it:
16
16
  car: <app> | tickets: <ids> | branch: <name> | head: <sha40> | tested-on: <base sha>
17
17
  review: P0=0 P1=0 (<report path>) | light: pass (<commands and result per Light checks>)
18
18
  migrations: <names or none> (applied to a DB: no|<which>) | lane: normal|urgent (<ground for urgent>)
19
+ deploy: <targets or none> | uat: <checkpoints path or none>
19
20
  auth: "<the boss's words>" | coordinator: <session id>
20
21
  ```
21
22
 
22
- Check: `head` is still the branch tip, `auth` covers the integration branch, `review` has a report path, and no migration is applied to a DB (the rebase tool cannot regenerate it): else return the car to its coordinator. Answer the coordinator with the train number and departure conditions.
23
+ Check: `head` is still the branch tip, `auth` covers the integration branch and every `strict` target in `deploy`, `review` has a report path, a `uat` file lists per checkpoint its flow, state, steps, and expected result and comes with a target (`deploy` or an `auto` one), and no migration is applied to a DB (the rebase tool cannot regenerate it): else return the car to its coordinator. Answer the coordinator with the train number and departure conditions.
23
24
 
24
25
  ## Departure and assembly
25
26
 
26
27
  - Depart when the train reaches `maxCars` (default 4), or its oldest car has waited `maxWaitMin` (default 60), or every coordinator awaited has arrived; the skill's `Load` allows it (if omitted: wait while the 1-minute load exceeds the core count); and the integration branch's pipeline is not red.
27
28
  - Assemble on a local `train/<code>` from the latest base, in a worktree at `~/.herdr/worktrees/<repo>/train-<code>`: `merge --no-ff` each car in arrival order. Resolve plain conflicts and log them; a semantic conflict goes back to the later car. Run `Train assembly`.
28
- - A fresh-context `reviewer` reviews `train/<code>` against the base for interactions between cars, conflict resolutions, and migration order. P0 or P1 of one car goes back to it; of assembly, you fix it. Then run `Full gates` once per train. Green: `Merge`. Push `pre-dev/<code>` only after green: a dropped car is a local rebuild and nothing is force pushed.
29
+ - A fresh-context `reviewer` reviews `train/<code>` against the base for interactions between cars, conflict resolutions, and migration order. P0 or P1 of one car goes back to it; of assembly, you fix it. Then run `Full gates` once per train. Green: `Merge`, then `Post-merge`. Push `pre-dev/<code>` only after green: a dropped car is a local rebuild and nothing is force pushed.
29
30
  - Red: the skill's `Full gates` says how to find the guilty car. Drop it, rebuild from the rest. Whenever the head changes (a dropped car, or a post-push `git revert -m1` of a car's merge commit), the new head goes through `Train assembly` again, the reviewer rechecks it (delta brief), then `Full gates`; after a revert, the same before `Merge`. Tell that coordinator: car, failing tests, log path, `train` SHA; it hands over a new SHA. If its session is gone, `call_tower` with `why=blocked`.
30
- - `urgent` is only the boss's explicit "first" or a P0 production fix; it still gets review and gates.
31
+ - `urgent` is only the boss's explicit "first" or a fix of a P0 defect in a live deploy target; it still gets review and gates.
31
32
 
32
- ## After the merge
33
+ ## Deploy
33
34
 
34
- Run `Post-merge`; tell each coordinator by intercom "landed in <version>, your commit is <sha>"; it closes its ticket (you, only if its session is gone). When the queue and your post-merge work are empty, `call_tower` with `why=milestone`, reply false, saying idle; the tower closes you.
35
+ Stage `deploy`, after `Post-merge`: run `Deploy` for each `auto` target and each target the slips name, in the order the skill gives. A `strict` target takes the train only when every car in it has `auth` naming it (a deploy ships every car); else skip it and ask the boss with your question tool whether to deploy it or hold those cars; a held car is not landed; held before `Merge`, it leaves the train as a dropped car does. Where merging deploys a `strict` target, ask before `Merge`. One train at a time per target: a later train waits for the earlier one's `Deploy verify`, and where merging deploys a target (the skill says), the next train's `Merge` waits for it too. First do what the skill adds per target, then run `Deploy verify` per target: done is the target running the train's own version, as the skill shows it; a green job, an accepted request, or a finished monitor is not done. A failed target follows the skill's `Deploy` (rollback, retry); its car goes back as a red one does.
36
+
37
+ ## UAT
38
+
39
+ Stage `uat`, after `Deploy verify`: run `UAT` for the cars with a `uat` file, on the deployed result. One page per train, one `review` block: an item per checkpoint, `label` `<ticket>: <flow>, <state>`, `body` its steps and expected result. Open it with `show_doc` (`view: "page"`) once ready; a blank box means accepted. A later train may deploy meanwhile; captures are the record. A comment returns its car: send its coordinator comments, captures, version; `UAT` says whether to revert first; a revert on the base goes as its own train through review, `Full gates`, `Merge`, and `Deploy`. The fix comes back as a new car whose commented checkpoints are captured again, or all of its checkpoints after a revert.
40
+
41
+ ## After the train
42
+
43
+ When a car has no stage left, tell its coordinator by intercom "landed in <version>, your commit is <sha>", adding "accepted in UAT" for a `uat` car; it closes its ticket (you, only if its session is gone). When the queue is empty, no stage runs, and no UAT page waits, `call_tower` with `why=milestone`, reply false, saying idle; the tower closes you.