@coreplane/switchboard 1.233.0 → 1.234.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.
Files changed (32) hide show
  1. package/dist/assets/deploy/cloudflare-resident/worker.ts +200 -214
  2. package/dist/assets/package-lock.json +3 -3
  3. package/dist/assets/package.json +1 -1
  4. package/dist/assets/source.json +3 -3
  5. package/dist/assets/src/agents/registry.ts +13 -7
  6. package/dist/assets/src/core/provider.ts +9 -6
  7. package/dist/assets/src/core/runEvents.ts +18 -0
  8. package/dist/assets/src/execution/residentCleanliness.ts +45 -5
  9. package/dist/assets/src/execution/residentRebind.ts +92 -77
  10. package/dist/assets/src/execution/residentReuse.ts +7 -18
  11. package/dist/assets/src/execution/residentSteps.ts +0 -1
  12. package/dist/assets/web/dist/.vite/manifest.json +67 -28
  13. package/dist/assets/web/dist/assets/{ResidentDetailPage-V4K35SYX.js → ResidentDetailPage-C_WNBUao.js} +1 -1
  14. package/dist/assets/web/dist/assets/{ResidentsIndexPage-3HuYaDwD.js → ResidentsIndexPage-D1sTyaZ_.js} +1 -1
  15. package/dist/assets/web/dist/assets/RunFoldRow-C_bcTSjA.js +5 -0
  16. package/dist/assets/web/dist/assets/RunRoutePage-BWTi_6PG.js +10 -0
  17. package/dist/assets/web/dist/assets/RunsIndexPage-H6GkSv0a.js +1 -0
  18. package/dist/assets/web/dist/assets/{ScheduledPage-dfrjQ5E9.js → ScheduledPage-BLcPzm1S.js} +1 -1
  19. package/dist/assets/web/dist/assets/{StatusDot-DexKtvUC.js → StatusDot-Ct6AtHhS.js} +1 -1
  20. package/dist/assets/web/dist/assets/{Tooltip-DGQ2gu9M.js → Tooltip-qol5Boof.js} +1 -1
  21. package/dist/assets/web/dist/assets/UnitRoutePage-CIhTEwN7.js +1 -0
  22. package/dist/assets/web/dist/assets/{dist-boTdDLF4.js → dist-DwtJj-ou.js} +1 -1
  23. package/dist/assets/web/dist/assets/favicon-BQsePYv5.js +1 -0
  24. package/dist/assets/web/dist/assets/indexRow-Bnj883ii.js +1 -0
  25. package/dist/assets/web/dist/assets/{main-BHhLVMQh.js → main-CRtlGxRm.js} +2 -2
  26. package/dist/assets/web/dist/assets/main-Dm11o0hc.css +1 -0
  27. package/dist/cli.js +1156 -570
  28. package/package.json +1 -1
  29. package/dist/assets/web/dist/assets/RunRoutePage-Dr867-Ha.js +0 -13
  30. package/dist/assets/web/dist/assets/RunsIndexPage-D_lpXsr8.js +0 -1
  31. package/dist/assets/web/dist/assets/favicon-CWPcvWvp.js +0 -1
  32. package/dist/assets/web/dist/assets/main-CAVqMbiX.css +0 -1
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.233.0",
3
+ "version": "1.234.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.233.0",
9
+ "version": "1.234.0",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -20032,7 +20032,7 @@
20032
20032
  },
20033
20033
  "packages/switchboard": {
20034
20034
  "name": "@coreplane/switchboard",
20035
- "version": "1.233.0",
20035
+ "version": "1.234.0",
20036
20036
  "license": "Apache-2.0",
20037
20037
  "dependencies": {
20038
20038
  "@earendil-works/pi-ai": "0.85.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.233.0",
3
+ "version": "1.234.0",
4
4
  "private": true,
5
5
  "description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
6
6
  "license": "Apache-2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.233.0",
3
- "commit": "4c96f69d476e76e2bc768a9eb6d505b683fddd36",
4
- "builtAt": "2026-09-16T15:18:25.322Z"
2
+ "version": "1.234.0",
3
+ "commit": "0c5f8c5e4657cd12a9bbf85177bee69b61505ac3",
4
+ "builtAt": "2026-09-16T16:44:55.183Z"
5
5
  }
@@ -96,9 +96,10 @@ export interface AgentDef {
96
96
  * router's table is rendered from this registry. `false` keeps a preset
97
97
  * out of the table — structurally: it is absent from the table the model
98
98
  * is shown and refused as a single route even if the model names it.
99
- * `ship` (it holds the merge grant) opts out for good; `conductor` (it
100
- * starts other runs) opts out of the table and is reached through the
101
- * router's compound form alone, with its parts named. */
99
+ * `coding` (a bare write ask deserves the review loop, so `ship` holds its
100
+ * seat) opts out; `conductor` (it starts other runs) opts out of the table
101
+ * and is reached through the router's compound form alone, with its parts
102
+ * named. */
102
103
  routable?: false;
103
104
  /** System prompt variant for resident-repo runs (docs/reference/specs/resident-repos.md):
104
105
  * the workspace is a ready worktree — no cloning, no installs, no repo
@@ -535,6 +536,11 @@ const WORK_PRESETS = {
535
536
  // `config set channel efforts.coding=…`, or `effort:` per request).
536
537
  machine: "repo-resident",
537
538
  identity: "write", // pushes branches and opens pull requests
539
+ // Never routed: a plain write ask deserves the coding → review loop, so
540
+ // the router's table offers `ship` in coding's seat — a routed ship runs
541
+ // a generated one-unit plan whose merge is a person's, never the runner's.
542
+ // A request that wants a bare coding run names it — `agent:coding`.
543
+ routable: false,
538
544
  },
539
545
  review: {
540
546
  name: "review",
@@ -568,10 +574,10 @@ const WORK_PRESETS = {
568
574
  toolset: "full",
569
575
  machine: "repo-resident",
570
576
  identity: "write",
571
- // Never routed: ship is the plan runner and holds the merge grant, so a
572
- // wrong route into it is code landing on main, not a stray pull request.
573
- // A request that wants it names it — `agent:ship`.
574
- routable: false,
577
+ // Routable: a routed ship runs a generated one-unit plan whose merge is a
578
+ // person's (`merge: person`) and never a seeded plan (the hand-off refuses
579
+ // a routed `plan <path>.md` naming `agent:ship`), so a wrong route costs a
580
+ // reviewed pull request, never code landing on main.
575
581
  maxTurns: 1,
576
582
  maxTokens: 16000,
577
583
  maxMinutes: 120,
@@ -38,12 +38,15 @@ export interface CompletionRequest {
38
38
  system?: string;
39
39
  messages: ChatMessage[];
40
40
  tools?: ToolDef[];
41
- /** Force one of `tools`: the model must answer by calling the named tool,
42
- * so the call's input IS the answer and prose cannot occur (the request
43
- * router's shape, routing-and-config item 21). Anthropic: `tool_choice:
44
- * {type: "tool", name}`; Chat Completions: `tool_choice: {type: "function",
45
- * function: {name}}`. Absent → the model chooses. */
46
- toolChoice?: { type: "tool"; name: string };
41
+ /** Force tool calling (the request router's shape, routing-and-config item
42
+ * 21). `{type: "tool", name}` forces the named tool — Anthropic:
43
+ * `tool_choice: {type: "tool", name}`; Chat Completions: `tool_choice:
44
+ * {type: "function", function: {name}}` — so the call's input IS the answer
45
+ * and prose cannot occur. `{type: "any"}` forces one call to some tool of
46
+ * `tools` — spelled `"any"` on Anthropic's Messages API and `"required"` on
47
+ * Chat Completions, with parallel tool calls switched off on the wire so
48
+ * the answer is exactly one call. Absent → the model chooses. */
49
+ toolChoice?: { type: "tool"; name: string } | { type: "any" };
47
50
  maxTokens: number;
48
51
  /** model effort hint; providers apply it only where the model supports it */
49
52
  effort?: Effort;
@@ -137,6 +137,14 @@ export type RunNoteKind =
137
137
  * attach, before the first turn, so the run page explains a follow-up that
138
138
  * runs on the default instead of on its thread's PR. */
139
139
  | "rebind_refused"
140
+ /** The run ended with uncommitted changes or unpushed commits in its
141
+ * workspace, and they do not outlive it: a run starts from a clean tree
142
+ * (docs/reference/specs/resident-repos.md item 17), so the release that
143
+ * follows the reply discards them. The summary names both counts and what
144
+ * to do instead (commit and push). Read off the workspace by the run loop
145
+ * after the model's last turn — the release itself runs after the record
146
+ * is sealed — and set on the card's label too, so the loss is never silent. */
147
+ | "work_left_behind"
140
148
  /** A coding run submitted a PR description but the post-step opened no
141
149
  * pull request because the branch it observed IS the base the pull
142
150
  * request would target (docs/reference/specs/pr-description.md item 5) —
@@ -201,6 +209,7 @@ export const RUN_NOTE_KINDS = [
201
209
  "verdict_turn",
202
210
  "cold_sandbox",
203
211
  "rebind_refused",
212
+ "work_left_behind",
204
213
  "pr_not_opened",
205
214
  "review_not_posted",
206
215
  "compacted",
@@ -483,11 +492,20 @@ export type RunEvent =
483
492
  model?: string;
484
493
  /** The request's trace id (docs/reference/specs/tracing.md), once the root exists. */
485
494
  traceId?: string;
495
+ /** The harness the run is driven by (`Harness.name`; docs/reference/specs/harness.md
496
+ * item 8): `pi` today. Absent on a command run, which starts no process,
497
+ * and on a record written before the seam existed. Additive. */
498
+ harness?: string;
486
499
  effort?: string;
487
500
  repo?: string;
488
501
  ref?: string;
489
502
  pr?: number;
490
503
  headSha?: string;
504
+ /** The plan runner instance whose story this record is (agent-ship item
505
+ * 17): written on the pipeline's own record alone, so its page can list
506
+ * the instance's units. A child's instance rides the record's
507
+ * `parentInstanceId`, never here. */
508
+ instanceId?: string;
491
509
  seq?: number;
492
510
  at?: number;
493
511
  }
@@ -5,7 +5,7 @@
5
5
  * shipped code.
6
6
  *
7
7
  * Background: the check used to be three sequential container spawns per
8
- * binding (`test -d`, `su … git status --porcelain`, `su … git rev-list
8
+ * binding (`test -d`, `su … git status --porcelain -uno`, `su … git rev-list
9
9
  * --count HEAD --not --remotes`), and `isIdle()` ran it serially per live
10
10
  * binding — every refresh cycle's idle gate paid 3×N process round-trips.
11
11
  * The three probes fold into one `sh -c` script; the DECISION semantics are
@@ -42,7 +42,7 @@ export function worktreeCleanlinessScript(worktreePath: string, user: string): s
42
42
  const inner = [
43
43
  `t=$(mktemp) || { echo gitrc=1; echo 'giterr=mktemp failed'; exit 0; }`,
44
44
  `cd ${wt} 2>"$t" || { echo gitrc=1; printf 'giterr=%s\\n' "$(grep -m 1 . "$t" || echo 'cd failed')"; rm -f "$t"; exit 0; }`,
45
- `s_out=$(git status --porcelain 2>"$t"); s_rc=$?; s_err=$(grep -m 1 . "$t" || true)`,
45
+ `s_out=$(git status --porcelain -uno 2>"$t"); s_rc=$?; s_err=$(grep -m 1 . "$t" || true)`,
46
46
  `a_out=$(git rev-list --count HEAD --not --remotes 2>"$t"); a_rc=$?; a_err=$(grep -m 1 . "$t" || true)`,
47
47
  `rm -f "$t"`,
48
48
  `if [ "$s_rc" -ne 0 ] || [ "$a_rc" -ne 0 ]; then`,
@@ -66,6 +66,40 @@ export function worktreeCleanlinessScript(worktreePath: string, user: string): s
66
66
  export interface WorktreeCleanliness {
67
67
  clean: boolean;
68
68
  reason?: string;
69
+ /** What the probes counted, when they ran: the tracked files `git status
70
+ * --porcelain -uno` listed as changed — item 17's definition of dirt, the
71
+ * same the run loop counts for its note, so the card and the release log
72
+ * name one number; untracked scratch is the thread's own and not work —
73
+ * and the commits on no remote branch. Absent when the tree is missing or
74
+ * a probe failed. */
75
+ changes?: number;
76
+ unpushed?: number;
77
+ }
78
+
79
+ /** What a release discards (docs/reference/specs/resident-repos.md item 16a):
80
+ * the counts a run left in its tree, named in the detach answer and the
81
+ * bot's log so the loss is never silent. */
82
+ export interface LeftBehind {
83
+ uncommittedChanges: number;
84
+ unpushedCommits: number;
85
+ }
86
+
87
+ /** The counts the probes measured, as what a release leaves behind: nothing
88
+ * when the probes could not run (a tree that is gone or unreadable) and
89
+ * nothing when both are zero — the answer names only a loss. */
90
+ export function leftBehindOf(measured: WorktreeCleanliness): LeftBehind | undefined {
91
+ if (measured.changes === undefined || measured.unpushed === undefined) return undefined;
92
+ if (measured.changes === 0 && measured.unpushed === 0) return undefined;
93
+ return { uncommittedChanges: measured.changes, unpushedCommits: measured.unpushed };
94
+ }
95
+
96
+ /** The bot's word for a discarded tree, in the release log and the run's
97
+ * record: what was left, why it is gone, what to do instead. */
98
+ export function leftBehindSentence(left: LeftBehind): string {
99
+ return (
100
+ `${left.uncommittedChanges} uncommitted change(s) and ${left.unpushedCommits} unpushed commit(s) were left in the worktree; ` +
101
+ "a run starts from a clean tree, so they were discarded — commit and push what must be kept"
102
+ );
69
103
  }
70
104
 
71
105
  /** Decide from the script's tagged output. Unknown (missing/failed tags,
@@ -89,7 +123,13 @@ export function parseWorktreeCleanliness(r: {
89
123
  }
90
124
  const changes = Number(tags.get("changes")) || 0;
91
125
  const unpushed = Number(tags.get("unpushed")) || 0;
92
- if (changes > 0 || unpushed > 0)
93
- return { clean: false, reason: `dirty: ${changes} uncommitted change(s), ${unpushed} unpushed commit(s)` };
94
- return { clean: true };
126
+ if (changes > 0 || unpushed > 0) {
127
+ return {
128
+ clean: false,
129
+ reason: `dirty: ${changes} uncommitted change(s), ${unpushed} unpushed commit(s)`,
130
+ changes,
131
+ unpushed,
132
+ };
133
+ }
134
+ return { clean: true, changes, unpushed };
95
135
  }
@@ -16,21 +16,34 @@
16
16
  *
17
17
  * Shape: the caller names the reason for its hint (`ownPr`: the pull request
18
18
  * the thread's own run opened and its head branch — never a PR a person
19
- * named). The resident moves the binding only when all of these hold:
19
+ * named). The move is a decision about the BINDING alone; the tree is the
20
+ * attach's business afterwards (item 17: a run starts from a clean tree at
21
+ * the bound ref, so the attach provisions the tree at the moved ref as it
22
+ * provisions any other). The resident moves the binding only when all of
23
+ * these hold:
20
24
  * - the binding was made by default (the first message named no branch), read
21
25
  * off `boundBy`, or, for a binding made before that field, off whether the
22
26
  * ref is the default branch — a ref a person named is never moved;
23
- * - the thread was not rebound before — a thread moves once;
24
- * - the branch is a local branch of the thread's OWN worktree — the physical
25
- * fact that this thread's run created it; a branch the tree never made is
26
- * refused whatever the caller says;
27
- * - the tree has no uncommitted tracked changes — never at the cost of work —
28
- * unless its HEAD is already the branch: the run made the branch in this
29
- * tree and left an edit after pushing, so the record alone moves and no
30
- * git command touches the tree.
31
- * The move is a `git checkout` inside the existing tree: same path, same pool
32
- * user, deps and snapshot lineage untouched. Every refusal is named in the
33
- * attach answer so the bot can say why the follow-up runs where it does. */
27
+ * - the thread was not rebound before, or its earlier move was returned (the
28
+ * second movement below) — a thread moves once per pull request;
29
+ * - the branch is the thread's own: remembered from a release (`ownBranches`,
30
+ * the branches its runs pushed), or, when nothing was remembered, a local
31
+ * branch of the thread's surviving tree — the physical fact that this
32
+ * thread's run created it. A branch neither remembered nor local is
33
+ * refused whatever the caller says; so is one the mirror does not hold
34
+ * even after a fetch (the Worker's check: the tree is cloned from it).
35
+ * Every refusal is named in the attach answer so the bot can say why the
36
+ * follow-up runs where it does.
37
+ *
38
+ * The second movement: a rebound binding names a branch that can die — the
39
+ * pull request merges and the branch is deleted. A binding left on it would
40
+ * fail every later attach (`unknown-ref`) for the thread's whole life. So a
41
+ * binding a rebind moved, whose branch the mirror no longer holds after a
42
+ * fetch, goes back to the default it was bound to (`canReturnToDefault`,
43
+ * `returnToDefault`); the attach provisions the tree there, clean, and the
44
+ * thread is default-bound again, so a later own pull request may move it once
45
+ * more. A ref a person named that vanished keeps the `unknown-ref` refusal:
46
+ * that branch is the person's to sort out. */
34
47
 
35
48
  /** The pull request the thread's own run opened, and its head branch — the
36
49
  * reason a caller's refHint is that branch. */
@@ -145,15 +158,29 @@ export function boundByOf(binding: { ref: string; boundBy?: BoundBy }, defaultRe
145
158
  return binding.boundBy ?? (binding.ref === defaultRef ? "default" : "name");
146
159
  }
147
160
 
148
- /** The record a rebind leaves on the binding and in the attach answer. */
161
+ /** The record a rebind leaves on the binding and in the attach answer: from
162
+ * which ref, onto which branch, for which pull request, when. `returnedAt`:
163
+ * that branch was gone from the mirror at a later attach and the binding
164
+ * went back to the default (the second movement) — a returned move no
165
+ * longer counts as the thread's one move. */
149
166
  export interface Rebound {
150
167
  from: string;
151
168
  to: string;
152
169
  pr: number;
153
170
  at: string;
171
+ returnedAt?: string;
172
+ }
173
+
174
+ /** The move back, in the attach answer: from the branch that is gone, to the
175
+ * default, for the pull request whose branch it was, when. */
176
+ export interface Returned {
177
+ from: string;
178
+ to: string;
179
+ pr: number;
180
+ at: string;
154
181
  }
155
182
 
156
- export type RebindRefusal = "named-ref" | "already-rebound" | "branch-absent" | "dirty" | "checkout-failed";
183
+ export type RebindRefusal = "named-ref" | "already-rebound" | "branch-absent";
157
184
 
158
185
  /** Why the binding stood, in the attach answer: the branch it was asked to
159
186
  * move to, the pull request, the reason and its sentence. */
@@ -182,9 +209,10 @@ export type RebindPlan =
182
209
  | { kind: "none" }
183
210
  /** The binding alone rules it out; nothing on disk is consulted. */
184
211
  | { kind: "refuse"; refused: RebindRefused }
185
- /** The binding allows it and has a live tree; the tree decides
186
- * (`rebindVerdict`). `own`: the thread's own runs pushed the branch, so a
187
- * tree that turns out missing may still be recreated at it. */
212
+ /** The binding allows it and has a live tree. `own`: the thread's own runs
213
+ * pushed the branch, as the binding remembers it — the fact that decides;
214
+ * when nothing was remembered, the tree's local branch is the fallback
215
+ * evidence (`rebindVerdict`). */
188
216
  | { kind: "measure"; from: string; to: string; pr: number; own: boolean }
189
217
  /** The binding allows it, its tree was evicted, and the thread's own runs
190
218
  * pushed the branch: the binding moves and the attach recreates the tree
@@ -217,7 +245,10 @@ export function rebindPlan(input: {
217
245
  ),
218
246
  };
219
247
  }
220
- if (binding.rebound) {
248
+ // A move that was returned (its branch gone, the binding back on the
249
+ // default) no longer stands in the way: the thread may follow its next
250
+ // pull request as it followed the first.
251
+ if (binding.rebound && binding.rebound.returnedAt === undefined) {
221
252
  const r = binding.rebound;
222
253
  return {
223
254
  kind: "refuse",
@@ -243,42 +274,30 @@ export function rebindPlan(input: {
243
274
  return { kind: "measure", from: binding.ref, ...plan, own };
244
275
  }
245
276
 
246
- /** What the attach measured about the thread's tree, as the thread user. Each
247
- * probe past `exists` is measured only when the tree is there. */
277
+ /** What the attach measured about the thread's tree, as the thread user —
278
+ * only when the binding remembers no push of the branch: the tree is then
279
+ * the only place the branch's origin can be read. */
248
280
  export interface RebindTreeFacts {
249
281
  /** `<worktree>/.git` is a directory. */
250
282
  exists: boolean;
251
- /** `git rev-parse --verify --quiet refs/heads/<to>` succeeded in the tree. */
283
+ /** `git rev-parse --verify --quiet refs/heads/<to>` succeeded in the tree;
284
+ * false for a branch the tree never made and for a tree git cannot read
285
+ * (neither verifies anything). */
252
286
  branchExists?: boolean;
253
- /** `git status --porcelain -uno` ran: false is a tree git cannot read
254
- * (corrupt, or owned by an earlier pool user), where nothing is verifiable. */
255
- readable?: boolean;
256
- /** `git status --porcelain -uno` listed a tracked change (untracked scratch
257
- * files are the thread's own state and survive a checkout). */
258
- dirty?: boolean;
259
- /** `git rev-parse --abbrev-ref HEAD` in the tree — the branch checked out
260
- * (`HEAD` when detached) — measured once the tree is dirty: the one fact
261
- * that tells a dirty tree already on the branch from one elsewhere. */
262
- head?: string;
263
287
  }
264
288
 
265
289
  export type RebindVerdict =
266
- /** Move the binding. `checkout: true`: check the branch out in the existing
267
- * tree. `checkout: false`: the tree is dirty but its HEAD is already the
268
- * branch — the run made it here and left an edit after pushing — so only
269
- * the record moves; no checkout, no fetch, no reset, the tree not touched
270
- * by the rebind (`note` says so for the log). */
271
- | { kind: "rebind"; checkout: true }
272
- | { kind: "rebind"; checkout: false; note: string }
273
- /** The tree is gone (a slept container) and the thread's own runs pushed the
274
- * branch: move the binding and let the attach recreate the tree at it. */
275
- | { kind: "recreate" }
276
- | { kind: "refuse"; refused: RebindRefused };
290
+ /** The branch is the thread's own: move the binding. The tree is not this
291
+ * verdict's concern — the attach provisions it at the moved ref (item 17),
292
+ * once the Worker has seen the mirror hold the branch. */
293
+ { kind: "rebind" } | { kind: "refuse"; refused: RebindRefused };
277
294
 
278
- /** The tree's verdict on a measured plan. */
295
+ /** Whether the branch is the thread's own, for a measured plan: the memory of
296
+ * a release decides by itself; without it, the surviving tree must hold the
297
+ * branch as a local branch. */
279
298
  export function rebindVerdict(plan: { to: string; pr: number; own?: boolean }, tree: RebindTreeFacts): RebindVerdict {
299
+ if (plan.own === true) return { kind: "rebind" };
280
300
  if (!tree.exists) {
281
- if (plan.own === true) return { kind: "recreate" };
282
301
  return {
283
302
  kind: "refuse",
284
303
  refused: rebindRefused(
@@ -288,46 +307,42 @@ export function rebindVerdict(plan: { to: string; pr: number; own?: boolean }, t
288
307
  ),
289
308
  };
290
309
  }
291
- if (tree.readable === false) {
292
- return {
293
- kind: "refuse",
294
- refused: rebindRefused(
295
- plan,
296
- "branch-absent",
297
- "the thread's worktree cannot be read; the branch cannot be verified there",
298
- ),
299
- };
300
- }
301
310
  if (tree.branchExists !== true) {
302
311
  return {
303
312
  kind: "refuse",
304
313
  refused: rebindRefused(
305
314
  plan,
306
315
  "branch-absent",
307
- `${JSON.stringify(plan.to)} is not a local branch of the thread's worktree; only a branch this thread's own run made moves it`,
316
+ `${JSON.stringify(plan.to)} is not a local branch of the thread's worktree and none of its runs pushed it; only a branch this thread's own run made moves it`,
308
317
  ),
309
318
  };
310
319
  }
311
- if (tree.dirty === true) {
312
- // A dirty tree whose HEAD is the branch has nothing a checkout could
313
- // cost: the run created the branch in this very tree and left the edit
314
- // after pushing, and only the record still names the old ref. Moving
315
- // the record is the whole move. Any other HEAD keeps the guard.
316
- if (tree.head === plan.to) {
317
- return {
318
- kind: "rebind",
319
- checkout: false,
320
- note: `the worktree is dirty but its HEAD is already ${JSON.stringify(plan.to)} (the run made the branch here); the binding moves, the tree is not touched`,
321
- };
322
- }
323
- return {
324
- kind: "refuse",
325
- refused: rebindRefused(
326
- plan,
327
- "dirty",
328
- "the worktree has uncommitted changes on the bound branch; the binding stands until they are committed or discarded",
329
- ),
330
- };
331
- }
332
- return { kind: "rebind", checkout: true };
320
+ return { kind: "rebind" };
321
+ }
322
+
323
+ /** Whether a binding whose ref the mirror no longer holds goes back to the
324
+ * default branch (the second movement): only a binding a rebind moved onto
325
+ * its own pull request's branch — bound by default in the first place, still
326
+ * on that branch, the move not yet returned. Anything else keeps the
327
+ * attach's `unknown-ref` refusal: a ref a person named is that person's to
328
+ * sort out, and a binding on the default cannot lose its ref. */
329
+ export function canReturnToDefault(
330
+ binding: { ref: string; boundBy?: BoundBy; rebound?: Rebound },
331
+ defaultRef: string,
332
+ ): boolean {
333
+ const r = binding.rebound;
334
+ if (r === undefined || r.returnedAt !== undefined || binding.ref !== r.to) return false;
335
+ return binding.ref !== defaultRef && boundByOf(binding, defaultRef) === "default";
336
+ }
337
+
338
+ /** The move back: the binding's ref becomes the default and the move that
339
+ * brought it here is stamped returned, so the thread may move again. Only
340
+ * ever applied to a binding `canReturnToDefault` admitted. */
341
+ export function returnToDefault<B extends { ref: string; rebound?: Rebound }>(
342
+ binding: B & { rebound: Rebound },
343
+ defaultRef: string,
344
+ at: string,
345
+ ): { binding: B; returned: Returned } {
346
+ const returned: Returned = { from: binding.ref, to: defaultRef, pr: binding.rebound.pr, at };
347
+ return { binding: { ...binding, ref: defaultRef, rebound: { ...binding.rebound, returnedAt: at } }, returned };
333
348
  }
@@ -16,11 +16,11 @@
16
16
  * PROVISIONS (a fresh one). A reusing attach keeps a readable tree exactly
17
17
  * as it stands, dirt and stale HEAD included, and refuses by name a tree it
18
18
  * cannot keep (gone, unreadable, built for the other mode) without touching
19
- * it. A provisioning attach keeps the dirty/stale discipline byte for byte —
20
- * with one exception it is told about (`keepTree`): when its own rebind onto
21
- * the thread's pull request branch found the tree dirty with that branch
22
- * already checked out and moved the record alone, the dirt is the thread's
23
- * own work and the tree is kept as it stands. */
19
+ * it. A provisioning attach keeps the dirty/stale discipline byte for byte:
20
+ * a run starts from a clean tree at the bound ref's tip, and what a run
21
+ * wants kept it commits and pushes (docs/reference/specs/resident-repos.md
22
+ * item 17) — so a tree left dirty, or on a branch the binding has since
23
+ * moved away from, is recreated, never repaired. */
24
24
 
25
25
  export type ParsedReuse = { reuse: boolean } | { error: string };
26
26
 
@@ -62,15 +62,6 @@ export type WorktreeDecision =
62
62
  export function decideWorktree(input: {
63
63
  /** True for a resumed run's attach: keep the tree, never wipe it. */
64
64
  reuse: boolean;
65
- /** True when this attach's rebind moved the binding onto the branch the
66
- * tree already had checked out, dirty, and promised not to touch it
67
- * (residentRebind.ts, the `checkout: false` verdict): the dirt is the
68
- * thread's own uncommitted work on its own pull request branch, so a
69
- * readable tree is kept as it stands — dirt and HEAD included, like a
70
- * resumed run's — while a tree that turns out missing, unreadable or built
71
- * for the other mode has nothing to keep and is provisioned like any fresh
72
- * attach's. Absent on every attach that did not make that move. */
73
- keepTree?: boolean;
74
65
  /** The tree was built for the other mode (read-only against writable). */
75
66
  modeSwitch: boolean;
76
67
  /** The commit a provisioning attach checks out: the ref's tip, or the expected head. */
@@ -78,7 +69,7 @@ export function decideWorktree(input: {
78
69
  worktreePath: string;
79
70
  facts: WorktreeFacts;
80
71
  }): WorktreeDecision {
81
- const { reuse, keepTree = false, modeSwitch, sha, worktreePath, facts } = input;
72
+ const { reuse, modeSwitch, sha, worktreePath, facts } = input;
82
73
  if (modeSwitch) {
83
74
  return reuse
84
75
  ? {
@@ -105,9 +96,7 @@ export function decideWorktree(input: {
105
96
  }
106
97
  // A reusing attach judges nothing past readability: the dirt and the HEAD are the run's own state.
107
98
  if (reuse) return { kind: "reuse" };
108
- // The rebind promised a dirty tree on its own branch would not be touched;
109
- // the dirt is the reason it made that promise, so it is not a reason to wipe.
110
- if (facts.dirty) return keepTree ? { kind: "reuse" } : { kind: "recreate", why: "dirty" };
99
+ if (facts.dirty) return { kind: "recreate", why: "dirty" };
111
100
  if (facts.head !== sha && facts.descendsFromTip !== true) return { kind: "recreate", why: "stale" };
112
101
  return { kind: "reuse" };
113
102
  }
@@ -18,7 +18,6 @@ export const RESIDENT_STEP_LABELS = {
18
18
  "for-each-ref": "listing the branches",
19
19
  checkout: "checking out the branch",
20
20
  "checkout-update": "updating the checkout",
21
- "rebind-checkout": "checking out the thread's own branch",
22
21
  "rev-parse": "reading the commit",
23
22
  "cat-file": "checking the mirror for the commit",
24
23
  "show-ref": "reading the branch tip",