@mattstack/rt-client 0.16.0 → 0.19.1

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/src/commands.ts CHANGED
@@ -110,7 +110,7 @@ export function gateOptionValue(o: GateOption): string {
110
110
  export function gateOptionLabel(o: GateOption): string {
111
111
  return typeof o === "string" ? o : (o.label || o.value);
112
112
  }
113
- export interface GateAnswer { answers: Record<string, string | string[] | { value: string | string[]; note?: string }>; by: string; answeredAt: number }
113
+ export interface GateAnswer { answers: Record<string, string | string[] | { value: string | string[]; note?: string }>; by: string; answeredAt: number; overridden?: boolean }
114
114
  export interface GateRow {
115
115
  id: string; subject: string; kind: string;
116
116
  questions: GateQuestion[]; meta: Record<string, unknown> | null;
@@ -119,10 +119,14 @@ export interface GateRow {
119
119
  status: GateStatus; answer: GateAnswer | null;
120
120
  openedAt: number; parkedAt: number | null; closedAt: number | null;
121
121
  closedReason: "abandoned" | "superseded" | "pruned" | null;
122
+ /** Set only when `closedReason` is "superseded": the id of the gate that superseded this one. */
123
+ supersededBy: string | null;
122
124
  agent: string | null; pane: string | null;
123
125
  nudge: { session: string } | null;
124
126
  delivery: { outcome: "delivered" | "dead-pane"; at: number } | null;
125
127
  released: boolean;
128
+ owner: string | null;
129
+ escalatedAt: number | null;
126
130
  }
127
131
 
128
132
  export interface GateSubscription {
@@ -132,6 +136,25 @@ export interface GateSubscription {
132
136
  createdAt: number;
133
137
  lastDelivery: { outcome: "delivered" | "failed"; at: number } | null;
134
138
  dead: boolean;
139
+ scope: "prefix" | "owner";
140
+ ownerRef: string | null;
141
+ }
142
+
143
+ export interface HerdInfo { id: string; repo: string; room: string; workspace: string; shepherdSession: string; shepherdHandle: string; herdrSocket: string | null; hidden: boolean; status: "active" | "wrapped"; createdAt: number; wrappedAt: number | null }
144
+ /** A herd row as `herd:list` reports it: the registry row plus how many jobs hang off it. */
145
+ export interface HerdListRow extends HerdInfo { jobs: number }
146
+ export interface HerdJobInfo { herd: string; name: string; worktree: string; branch: string | null; tree: string | null; pane: string | null; agentSession: string | null; agentId: string | null; handle: string; status: "spawning" | "active" | "at-gate" | "at-milestone" | "done" | "closed" | "crashed"; disposable: boolean; lastGate: string | null; lastReport: number | null; createdAt: number; updatedAt: number }
147
+ /** `lastGateStatus`/`lastGateDelivery` come from the job's `lastGate` row: an `answered` gate whose delivery is `dead-pane` is the "answered, worker not woken" case the shepherd must act on. */
148
+ export interface HerdStatusData {
149
+ herd: HerdInfo;
150
+ jobs: Array<HerdJobInfo & { openGate: string | null; paneStatus: string | null; lastGateStatus: GateStatus | null; lastGateDelivery: "delivered" | "dead-pane" | null }>;
151
+ unread: number;
152
+ lifecycleConnected: boolean;
153
+ hiddenUp: boolean | null;
154
+ /** The shepherd session's own `herd:<id>/` subscription row, or null when none is live. */
155
+ subscription: { id: string; dead: boolean; lastDelivery: GateSubscription["lastDelivery"] } | null;
156
+ /** Whether the shepherd session's own inbox socket is reachable right now, probed fresh on every status call -- honest liveness, not the subscription row's bookkeeping. */
157
+ push: { state: "reachable" | "unreachable"; lastDelivery: GateSubscription["lastDelivery"] };
135
158
  }
136
159
 
137
160
  /**
@@ -229,7 +252,10 @@ export interface InviteResult { paneId: string; delivered: "accepted" | "queued"
229
252
  /** Duplicated shape on purpose: mirrors lib/daemon/inject.ts's InjectResult. */
230
253
  export type PaneDelivery = "accepted" | "queued" | "refused";
231
254
  export interface PaneSendResult { paneId: string; delivered: PaneDelivery; reason?: string }
232
- export interface PaneFocusResult { paneId: string; focused: boolean }
255
+ /** `attendTab` is set only for a `bg:` ref: focus for a background pane IS
256
+ the attend flow (a visible tab running a terminal attach), and this is
257
+ that tab's id. */
258
+ export interface PaneFocusResult { paneId: string; focused: boolean; attendTab?: string }
233
259
 
234
260
  // SKILLS-53: one judgment, computed once in rt, so the console and the tray
235
261
  // never derive two verdicts that can disagree.
@@ -387,7 +413,9 @@ export interface WorktreeProvisionData {
387
413
  export interface WorktreeCreateData { tree: string; path: string }
388
414
  export interface WorktreeDisposeData {
389
415
  disposed: string[];
390
- refused: Array<{ tree: string; reason: string }>;
416
+ /** `detail` is set only for a refusal whose bare `reason` code can't name
417
+ what a human needs to act on it (the run id and stage for `running-run`). */
418
+ refused: Array<{ tree: string; reason: string; detail?: string }>;
391
419
  recoverable: Array<{ tree: string; path: string; until: string }>;
392
420
  }
393
421
  export interface WorktreeRestoreData {
@@ -396,7 +424,7 @@ export interface WorktreeRestoreData {
396
424
  export interface WorktreeFreshenData { ran: string[] }
397
425
  export interface WorktreeAdoptData {
398
426
  main: string; claimed: string[]; unmanaged: string[]; disposed: string[];
399
- refused: Array<{ tree: string; reason: string }>;
427
+ refused: Array<{ tree: string; reason: string; detail?: string }>;
400
428
  }
401
429
 
402
430
  /** Duplicated shape on purpose: mirrors lib/endpoint/store.ts's EndpointClaim. */
@@ -540,7 +568,7 @@ export interface Commands {
540
568
  "chat:dm-open": { payload: { from: string; to: string; sessionId?: string }; data: { room: string; created: boolean } };
541
569
 
542
570
  // ─── Agent handoff (rt agent) ────────────────────────────────────────────
543
- "agent:start": { payload: { repo: string; cwd: string; prompt?: string; surface?: AgentSurface; model?: string; effort?: string; account?: string; label?: string; caller?: string; workspace?: string; tab?: string; extraArgs?: string }; data: AgentRecord };
571
+ "agent:start": { payload: { repo: string; cwd: string; prompt?: string; surface?: AgentSurface; model?: string; effort?: string; account?: string; label?: string; caller?: string; workspace?: string; tab?: string; extraArgs?: string; env?: Record<string, string>; herdrSocket?: string; handle?: string; bg?: boolean }; data: AgentRecord };
544
572
  "agent:resume": { payload: { id: string; prompt?: string; surface?: AgentSurface; workspace?: string; tab?: string }; data: AgentRecord };
545
573
  "agent:get": { payload: { id: string }; data: AgentRecord };
546
574
  "agent:list": { payload: { repo?: string }; data: { agents: AgentRecord[] } };
@@ -554,7 +582,9 @@ export interface Commands {
554
582
  data: { pane: ChatPane; ready: boolean };
555
583
  };
556
584
  "pane:send": { payload: { paneId: string; text: string; callerPane?: string }; data: PaneSendResult };
557
- "pane:focus": { payload: { paneId: string }; data: PaneFocusResult };
585
+ /** `callerWorkspace` (HERDR_WORKSPACE_ID) is required only for a `bg:`
586
+ ref, whose focus opens an attend tab in the caller's own workspace. */
587
+ "pane:focus": { payload: { paneId: string; callerWorkspace?: string }; data: PaneFocusResult };
558
588
 
559
589
  // ─── R013/R016 ────────────────────────────────────────────────
560
590
  "cache:read": { payload: { branches?: string[]; maxAgeMs?: number; repoIdentity?: string }; data: Record<string, BranchEnrichment> };
@@ -596,8 +626,15 @@ export interface Commands {
596
626
  * `conflict:true` and the WINNING row, so every consumer gets the winner
597
627
  * typed with no envelope hacks. `ok:false` is reserved for
598
628
  * not-found/closed/validation failures.
629
+ *
630
+ * Owner enforcement adds two structured rejections beyond the plain
631
+ * `{ok:false, error:string}` shape (see `GateAnswerResult` in
632
+ * `lib/daemon/handlers/gate.ts`): a herd-owned gate answered by anyone but
633
+ * the owning shepherd's session, the answering pane, or an explicit human
634
+ * `override` returns `{ok:false, error:"owned-by", owner}`; a closed gate
635
+ * returns `{ok:false, error:"gate-closed", reason, supersededBy?}`.
599
636
  */
600
- "gate:answer": { payload: { id: string; answers: GateAnswer["answers"]; by: string }; data: { row: GateRow; conflict?: true } };
637
+ "gate:answer": { payload: { id: string; answers: GateAnswer["answers"]; by: string; session?: string; override?: boolean }; data: { row: GateRow; conflict?: true } };
601
638
  /** `ok:false "not-found"` on an unknown id is terminal; the CLI loop must not re-enter on it.
602
639
  * `timeout` carries no row (nothing settled); `answered`/`closed` always carry the settled row. */
603
640
  "gate:wait": { payload: { id: string; waitMs?: number }; data: { status: "timeout" } | { status: "answered" | "closed"; row: GateRow } };
@@ -606,12 +643,31 @@ export interface Commands {
606
643
  "gate:list": { payload: { open?: boolean; subjectPrefix?: string; kind?: string; limit?: number; cursor?: number }; data: { gates: GateRow[]; cursor: number } };
607
644
  "gate:park": { payload: { id: string }; data: { ok: true } };
608
645
  "gate:close": { payload: { id: string; reason: "abandoned" | "superseded" | "pruned" }; data: { ok: true } };
609
- "gate:subscribe": { payload: { subjectPrefix: string; session: string }; data: { id: string } };
646
+ "gate:subscribe": { payload: { subjectPrefix: string; session: string; scope?: "owner"; ownerRef?: string }; data: { id: string } };
610
647
  "gate:unsubscribe": { payload: { id: string }; data: { removed: boolean } };
611
648
  /** The shepherd's gap-recovery liveness check and the observability window
612
649
  * onto delivery outcomes (dead marks included). */
613
650
  "gate:subscriptions": { payload: { session?: string; live?: boolean }; data: { subscriptions: GateSubscription[] } };
614
651
 
652
+ // ─── Herd (shepherd run registry) ────────────────────────────────────────
653
+ "herd:start": { payload: { name: string; repo: string; session: string; hidden?: boolean }; data: { herd: string; room: string; workspace: string; subscription: string; handle: string; hidden: boolean } };
654
+ "herd:resume": { payload: { herd: string; session: string }; data: { subscription: string; gates: GateRow[]; unread: number; status: HerdStatusData; handle: string } };
655
+ "herd:status": { payload: { herd: string }; data: HerdStatusData };
656
+ /** Active herds only unless `all`, so a shepherd's "which herd am I on" question has one answer. */
657
+ "herd:list": { payload: { all?: boolean }; data: { herds: HerdListRow[] } };
658
+ "herd:close": { payload: { herd: string; job: string }; data: { job: string; status: "closed"; /** Advisory: a resumable run can still write into this job's worktree after close, so this warns rather than blocking. */ warning?: string } };
659
+ /** `brief` is the brief TEXT, not a path: the CLI reads the file. It is stored at `<jobsRoot>/<herd>/<job>/job.md`, so a respawn with `dir` and no `brief` reads it back. */
660
+ "herd:spawn": { payload: { herd: string; job: string; brief?: string; dir?: string; model?: string; effort?: string; account?: string; disposable?: boolean }; data: { herd: string; job: string; pane: string; worktree: string; branch: string | null; tree: string | null; /** null = no provisioning ran (--dir); false = cold create, worth announcing. */ wasOnDeck: boolean | null; agentId: string; sessionId: string; handle: string } };
661
+ "herd:gates": { payload: { herd: string }; data: { gates: GateRow[] } };
662
+ "herd:ask": { payload: { herd: string; job: string; session: string; pane?: string; questions: GateQuestion[]; context?: string }; data: { gate: string } };
663
+ "herd:milestone": { payload: { herd: string; job: string; session: string; pane?: string; artifact: string; summary?: string }; data: { gate: string; message: number } };
664
+ "herd:answer": { payload: { gate: string }; data: { gate: string; status: GateStatus; answer: GateAnswer | null; closedReason: GateRow["closedReason"] } };
665
+ "herd:report": { payload: { herd: string; job: string; body: string }; data: { message: number } };
666
+ /** `callerWorkspace` is the attending session's own HERDR_WORKSPACE_ID: the attached tab opens there, not in the herd's workspace. */
667
+ "herd:attend": { payload: { herd: string; job: string; callerWorkspace: string }; data: { tab: string; pane: string } };
668
+ "herd:stop-hidden": { payload: Record<string, never>; data: { stopped: boolean } };
669
+ "herd:wrap-up": { payload: { herd: string; closePanes?: boolean; dispose?: string[]; deleteJobDirs?: boolean; archiveRoom?: boolean }; data: { closed: string[]; workspaceClosed: boolean; disposed: string[]; refused: Array<{ tree: string; reason: string }>; deletedJobDirs: boolean; archived: boolean } };
670
+
615
671
  /** Wire reply on success is always `{ok:true, repaired}` (no `data`
616
672
  * wrapper) — `data` here documents the extra field the same way PingData
617
673
  * does for `ping`, not the literal wire nesting (R3). */
@@ -632,6 +688,13 @@ export interface Commands {
632
688
  "worktree:restore": { payload: { repoName: string; tree: string }; data: WorktreeRestoreData };
633
689
  "worktree:freshen": { payload: { repoName?: string; tree?: string }; data: WorktreeFreshenData };
634
690
  "worktree:adopt": { payload: { repoName: string; claim?: boolean }; data: WorktreeAdoptData };
691
+
692
+ // ─── Background server (daemon-owned background herdr session) ──────────
693
+ "bg:ensure": { payload: { claim?: string }; data: { socket: string; started: boolean; parity: { ok: boolean; drift: string[] } | null } };
694
+ "bg:status": { payload: Record<string, never>; data: { up: boolean; socket: string; claims: Array<{ owner: string; pane: string | null; createdAt: number }> } };
695
+ /** Rejects (`ok:false`) naming every live claim owner while any claim is held. */
696
+ "bg:stop": { payload: Record<string, never>; data: { stopped: boolean } };
697
+ "bg:release": { payload: { claim: string }; data: { released: boolean } };
635
698
  }
636
699
 
637
700
  export type CommandName = keyof Commands;
@@ -715,6 +778,20 @@ export const COMMAND_NAMES: readonly CommandName[] = [
715
778
  "gate:subscribe",
716
779
  "gate:unsubscribe",
717
780
  "gate:subscriptions",
781
+ "herd:start",
782
+ "herd:resume",
783
+ "herd:status",
784
+ "herd:list",
785
+ "herd:close",
786
+ "herd:spawn",
787
+ "herd:gates",
788
+ "herd:ask",
789
+ "herd:milestone",
790
+ "herd:answer",
791
+ "herd:report",
792
+ "herd:attend",
793
+ "herd:stop-hidden",
794
+ "herd:wrap-up",
718
795
  "hooks:repair",
719
796
  "hooks:watch",
720
797
  "sdm:catalog",
@@ -729,4 +806,9 @@ export const COMMAND_NAMES: readonly CommandName[] = [
729
806
  "worktree:restore",
730
807
  "worktree:freshen",
731
808
  "worktree:adopt",
809
+
810
+ "bg:ensure",
811
+ "bg:status",
812
+ "bg:stop",
813
+ "bg:release",
732
814
  ];
package/src/index.ts CHANGED
@@ -54,6 +54,24 @@ export {
54
54
  gateSubscribe,
55
55
  gateUnsubscribe,
56
56
  gateSubscriptions,
57
+ herdStart,
58
+ herdSpawn,
59
+ herdAsk,
60
+ herdMilestone,
61
+ herdAnswer,
62
+ herdReport,
63
+ herdGates,
64
+ herdStatus,
65
+ herdList,
66
+ herdResume,
67
+ herdClose,
68
+ herdAttend,
69
+ herdWrapUp,
70
+ herdStopHidden,
71
+ bgEnsure,
72
+ bgStatus,
73
+ bgStop,
74
+ bgRelease,
57
75
  } from "./client.ts";
58
76
 
59
77
  export { COMMAND_NAMES, GATE_BY_PANE, gateOptionValue, gateOptionLabel } from "./commands.ts";
@@ -100,6 +118,10 @@ export type {
100
118
  GateAnswer,
101
119
  GateRow,
102
120
  GateSubscription,
121
+ HerdInfo,
122
+ HerdListRow,
123
+ HerdJobInfo,
124
+ HerdStatusData,
103
125
  } from "./commands.ts";
104
126
 
105
127
  export { subscribe, createRelay, DEFAULT_WS_URL } from "./relay.ts";
@@ -112,6 +134,9 @@ export { repoNameForPath } from "./repos.ts";
112
134
  export { decidePlacement, openSmartPane } from "./smart-pane.ts";
113
135
  export type { Placement, PlacementOpts, HerdrCall } from "./smart-pane.ts";
114
136
 
137
+ export { BG_PREFIX, parsePaneRef, formatPaneRef } from "./pane-ref.ts";
138
+ export type { PaneServer, PaneRef } from "./pane-ref.ts";
139
+
115
140
  // ─── Settings (RT-50) ────────────────────────────────────────────────────────
116
141
 
117
142
  export { getSetting, listSettings, explainSetting, expandVariables, SCOPE_ORDER, setSettingsWarnSink } from "./settings/resolve.ts";
@@ -0,0 +1,33 @@
1
+ export const BG_PREFIX = "bg:";
2
+
3
+ export type PaneServer = "visible" | "bg";
4
+
5
+ export interface PaneRef {
6
+ server: PaneServer;
7
+ paneId: string;
8
+ }
9
+
10
+ export function parsePaneRef(ref: string): PaneRef {
11
+ if (ref.startsWith(BG_PREFIX)) {
12
+ return {
13
+ server: "bg",
14
+ paneId: ref.slice(BG_PREFIX.length),
15
+ };
16
+ }
17
+ return {
18
+ server: "visible",
19
+ paneId: ref,
20
+ };
21
+ }
22
+
23
+ export function formatPaneRef(paneId: string, server: PaneServer): string {
24
+ if (server === "visible") {
25
+ return paneId;
26
+ }
27
+ // Idempotent: herdr pane ids (w<N>:p<N>) can never start with "bg:", so an
28
+ // already-formatted ref passes through unchanged instead of nesting.
29
+ if (paneId.startsWith(BG_PREFIX)) {
30
+ return paneId;
31
+ }
32
+ return BG_PREFIX + paneId;
33
+ }
@@ -27,6 +27,17 @@ export function teamSettingsPath(team: string): string {
27
27
  return join(teamsDir(), team, "mattstack", "settings.team.jsonc");
28
28
  }
29
29
 
30
+ /**
31
+ * ~/.mattstack/rt/teams/<team>.json: the machine-local team record. Mirrored
32
+ * from repo-tools/lib/team/team-local.ts's teamLocalPath, which is the
33
+ * authority: rt-client has no dependency on rt's lib/, so this literal is
34
+ * duplicated here rather than imported (same convention as `teamSettingsPath`
35
+ * and `userSettingsPath` above).
36
+ */
37
+ export function teamLocalPath(team: string): string {
38
+ return join(home(), ".mattstack", "rt", "teams", `${team}.json`);
39
+ }
40
+
30
41
  /**
31
42
  * ~/.mattstack/user/local/<machineKey()>/settings.local.jsonc — the machine
32
43
  * store, TRACKED and keyed per machine (path literals legal here only).
@@ -42,7 +42,7 @@ export const REGISTRY: readonly SettingDef[] = [
42
42
  merge: "deep",
43
43
  repoScoped: true,
44
44
  migrated: true,
45
- description: "Per-repo worktree pool config (onDeck size, ready steps, name pool); root/branchFormat/ready computed-or-empty in the reader.",
45
+ description: "Per-repo worktree pool config (onDeck size, ready steps, name pool, staleClaimDays); root/branchFormat/ready computed-or-empty in the reader.",
46
46
  },
47
47
  {
48
48
  key: "rt.worktreeReadyApproval",
@@ -85,7 +85,7 @@ export const REGISTRY: readonly SettingDef[] = [
85
85
  scopes: ["user"],
86
86
  default: [],
87
87
  merge: "replace",
88
- description: "Event-bus glob rules that raise a desktop notification: [{pattern, category, title, message}]. pattern is matched against the events-bus topic (Bun.Glob semantics); title/message may interpolate `{field}` from the event payload. A fresh key, not an ownership-latch port, so a default is fine here.",
88
+ description: "Event-bus glob rules that raise a desktop notification: [{pattern, category, title, message, subjectPrefix?, url?, owner?}]. pattern is matched against the events-bus topic (Bun.Glob semantics); title/message may interpolate `{field}` from the event payload, plus the computed `{question}` field (the event payload's first question label, `payload.questions[0].label`, empty string when absent); optional subjectPrefix matches the event payload's subject as a prefix. The optional url is interpolated the same way as title/message and becomes the notification's Open target; a gate rule should set it. The optional owner field (only literal \"human\" is valid) suppresses events whose payload.owner starts with \"herd:\", allowing gate rules to skip herd-owned events. A typical setup pairs a gate/opened rule with owner: \"human\" (human-owned gates notify) and a gate/escalated rule without owner (all escalations notify, whether human or herd). A fresh key, not an ownership-latch port, so a default is fine here.",
89
89
  },
90
90
  {
91
91
  key: "rt.cron",
@@ -427,7 +427,7 @@ export const REGISTRY: readonly SettingDef[] = [
427
427
  type: "array",
428
428
  scopes: ["team"],
429
429
  merge: "replace",
430
- description: "Board tabs ({id, label, source, slackChannel?, reviewSkill?}), editable from the board's settings modal. source.kind 'authors' is the classic roster board; 'codeowners' lists MRs from any author blocked on an unapproved CODEOWNERS section. Absent = one implicit authors tab (fallback lives in the board reader, never here).",
430
+ description: "Board tabs ({id, label, source, slackChannel?, reviewSkill?}), editable from the board's settings modal. source.kind 'authors' is the classic roster board; 'codeowners' lists any author's open MRs carrying a CODE_OWNER rule for the section, until merge/close. Absent = one implicit authors tab (fallback lives in the board reader, never here).",
431
431
  },
432
432
 
433
433
  // --- board (user) ----------------------------------------------------------
@@ -687,4 +687,14 @@ export const REGISTRY: readonly SettingDef[] = [
687
687
  merge: "replace",
688
688
  description: "Opaque extra claude arguments appended to every rt agent launch (escape hatch).",
689
689
  },
690
+
691
+ // --- gates (escalation) ----------------------------------------------------
692
+ {
693
+ key: "rt.gates.escalationTtlMinutes",
694
+ type: "number",
695
+ scopes: ["user"],
696
+ default: 10,
697
+ merge: "replace",
698
+ description: "Minutes an open herd-owned gate waits before the escalation sweep surfaces it to the human (topic gate/escalated/<id>). Fires on either trigger: the TTL elapses (reason \"ttl\"), or the owning herd's shepherd subscription is gone or dead before the TTL (reason \"owner-dead\"). 0 escalates any eligible gate on the first sweep after it opens. A fresh key, not an ownership-latch port, so a default is fine here.",
699
+ },
690
700
  ];
@@ -0,0 +1,18 @@
1
+ /**
2
+ * One field of the machine-local team record, for the write guard. The record
3
+ * itself is owned by repo-tools/lib/team/team-local.ts; this reads only what
4
+ * the guard needs and never writes.
5
+ */
6
+
7
+ import { readFileSync } from "fs";
8
+ import { teamLocalPath } from "./paths.ts";
9
+
10
+ /** Unreadable, absent or malformed all read as false, so nothing that predates the field is refused. */
11
+ export function isJoinedTeam(team: string): boolean {
12
+ try {
13
+ const parsed: unknown = JSON.parse(readFileSync(teamLocalPath(team), "utf8"));
14
+ return typeof parsed === "object" && parsed !== null && (parsed as { joinedByRt?: unknown }).joinedByRt === true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
@@ -100,6 +100,7 @@ import { dirname } from "path";
100
100
  import { machineSettingsPath, teamSettingsPath, userSettingsPath } from "./paths.ts";
101
101
  import { getDef, isMigrated, validateValue, type SettingDef, type SettingScope } from "./registry-machinery.ts";
102
102
  import { listTeams } from "./stores.ts";
103
+ import { isJoinedTeam } from "./team-local-read.ts";
103
104
 
104
105
  export interface SetSettingOpts {
105
106
  /** Normalized repo identity — required to target a repoScoped key's `repos.<identity>` section. */
@@ -208,6 +209,19 @@ function migratedFalseMessage(key: string, def: SettingDef): string {
208
209
  return `"${key}" is not writable through the settings resolver yet${legacyPart}`;
209
210
  }
210
211
 
212
+ /**
213
+ * A clone that arrived by redeeming an invite is pull-only, so a write here
214
+ * would never reach the team AND would leave a tracked file dirty, which is
215
+ * enough on its own to make the daemon's fast-forward pull fail.
216
+ */
217
+ function refuseIfJoined(team: string): void {
218
+ if (isJoinedTeam(team)) {
219
+ refuse(
220
+ `this machine joined "${team}" by invite, so its clone is pull-only and team settings cannot be written here. Ask the team's owner to make this change. Member-proposed changes are tracked in MAT-415.`,
221
+ );
222
+ }
223
+ }
224
+
211
225
  /** Resolves which store file a write targets, applying the team-selection rule for `scope: "team"`. */
212
226
  function resolveStorePath(scope: SettingScope, opts: SetSettingOpts): string {
213
227
  if (scope === "user") return userSettingsPath();
@@ -218,6 +232,7 @@ function resolveStorePath(scope: SettingScope, opts: SetSettingOpts): string {
218
232
  if (!existsSync(path)) {
219
233
  refuse(`team store for "${opts.team}" does not exist (${path}) — clone/seed it before writing to it`);
220
234
  }
235
+ refuseIfJoined(opts.team);
221
236
  return path;
222
237
  }
223
238
 
@@ -228,7 +243,9 @@ function resolveStorePath(scope: SettingScope, opts: SetSettingOpts): string {
228
243
  if (teams.length > 1) {
229
244
  refuse(`multiple local team stores found (${teams.join(", ")}) — pass opts.team to choose one`);
230
245
  }
231
- return teamSettingsPath(teams[0] as string);
246
+ const team = teams[0] as string;
247
+ refuseIfJoined(team);
248
+ return teamSettingsPath(team);
232
249
  }
233
250
 
234
251
  /**
@@ -243,7 +260,9 @@ function resolveStorePathForUnset(scope: SettingScope, opts: SetSettingOpts): st
243
260
 
244
261
  if (opts.team !== undefined) {
245
262
  const path = teamSettingsPath(opts.team);
246
- return existsSync(path) ? path : null;
263
+ if (!existsSync(path)) return null;
264
+ refuseIfJoined(opts.team);
265
+ return path;
247
266
  }
248
267
 
249
268
  const teams = listTeams();
@@ -251,7 +270,9 @@ function resolveStorePathForUnset(scope: SettingScope, opts: SetSettingOpts): st
251
270
  if (teams.length > 1) {
252
271
  refuse(`multiple local team stores found (${teams.join(", ")}) — pass opts.team to choose one`);
253
272
  }
254
- return teamSettingsPath(teams[0] as string);
273
+ const team = teams[0] as string;
274
+ refuseIfJoined(team);
275
+ return teamSettingsPath(team);
255
276
  }
256
277
 
257
278
  /** `// header comment\n{}\n` — see module doc for why the object must be seeded before the first `modify`. */