pi-roundtable-coding 0.27.1 → 0.29.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.
@@ -22,9 +22,11 @@ import {
22
22
  } from "pi-roundtable/kit";
23
23
  import { Type } from "typebox";
24
24
  import {
25
+ type CodingAutonomy,
25
26
  CodingDesk,
26
27
  type CodingJob,
27
28
  type CodingLimits,
29
+ type CodingModes,
28
30
  type CodingResult,
29
31
  type CodingThreadText,
30
32
  type CodingWorker,
@@ -76,6 +78,12 @@ export interface CodingPresentation {
76
78
  direct: boolean,
77
79
  ): { post: string; result: string };
78
80
  taskStarted?(job: CodingJob, skippedSkills: string[]): string;
81
+ /** The repo_task result for a task waiting for a worker slot, at `position` (from 1) in the queue. */
82
+ taskQueued?(
83
+ job: CodingJob,
84
+ position: number,
85
+ skippedSkills: string[],
86
+ ): string;
79
87
  }
80
88
  export interface CodingOptions {
81
89
  shelfDir: string;
@@ -122,6 +130,18 @@ export interface CodingOptions {
122
130
  workerPackages?: string[];
123
131
  agentDir?: string;
124
132
  timeoutMs?: number;
133
+ /** Workers running at once on the host; default 4. More tasks wait in a queue. */
134
+ maxWorkers?: number;
135
+ /** Tasks waiting for a worker slot, first in first out; one more is refused. Default 16. */
136
+ maxQueued?: number;
137
+ /**
138
+ * The conversations' modes, such as `context.services.find(MODES)`: a worker then follows the
139
+ * yolo and long-running modes of the conversation whose owner turn started it, read live.
140
+ * Without them a worker asks a card for every held call and stops at `timeoutMs`.
141
+ */
142
+ modes?: CodingModes;
143
+ /** The host's resolved `autonomy` limits, which the worker's yolo and long-running modes follow. */
144
+ autonomy?: CodingAutonomy;
125
145
  /** Optional additional worker rules; linked host rules also apply. */
126
146
  holds?: HoldCheck;
127
147
  /** A different Git host or an offline clone implementation. */
@@ -131,6 +151,35 @@ export interface CodingOptions {
131
151
  /** Override report delivery, for example to schedule a background conversation turn. */
132
152
  onResult?: (result: CodingResult) => Promise<void>;
133
153
  }
154
+ /**
155
+ * The worker's host hold check: the plugin's extra rules, else the linked host rules, with the
156
+ * classes of every check that holds a call so yolo mode can judge it; a check that holds a call
157
+ * without telling its classes leaves the call unclassed.
158
+ */
159
+ function workerHolds(
160
+ extra: HoldCheck | undefined,
161
+ linked: () => HoldCheck,
162
+ ): HoldCheck {
163
+ const check: HoldCheck = (tool, input, scope) =>
164
+ extra?.(tool, input, scope) ?? linked()(tool, input, scope);
165
+ check.classesOf = (tool, input, scope) => {
166
+ const found: string[] = [];
167
+ for (const holds of extra ? [extra, linked()] : [linked()]) {
168
+ if (holds(tool, input, scope) === undefined) continue;
169
+ const classes = holds.classesOf?.(tool, input, scope);
170
+ if (!classes?.length) return undefined;
171
+ found.push(...classes);
172
+ }
173
+ return found.length ? [...new Set(found)] : undefined;
174
+ };
175
+ return check;
176
+ }
177
+ function checkCount(name: string, value: number | undefined, least: number) {
178
+ if (value !== undefined && (!Number.isSafeInteger(value) || value < least))
179
+ throw new Error(
180
+ `${name} must be a whole number of at least ${least}; got ${value}.`,
181
+ );
182
+ }
134
183
  async function refusal<T>(operation: () => Promise<T>): Promise<T> {
135
184
  try {
136
185
  return await operation();
@@ -150,6 +199,18 @@ export function coding(options: CodingOptions) {
150
199
  checkLimit("limits.heldEntries", options.limits?.heldEntries);
151
200
  checkLimit("limits.heldChars", options.limits?.heldChars);
152
201
  checkLimit("diagnosticChars", options.diagnosticChars);
202
+ checkCount("maxWorkers", options.maxWorkers, 1);
203
+ checkCount("maxQueued", options.maxQueued, 0);
204
+ const longRunning = options.autonomy?.longRunningMaxMs;
205
+ if (
206
+ longRunning !== undefined &&
207
+ (!Number.isFinite(longRunning) ||
208
+ longRunning <= 0 ||
209
+ longRunning > 2_147_483_647)
210
+ )
211
+ throw new Error(
212
+ "autonomy.longRunningMaxMs must be positive, finite and at most 2147483647.",
213
+ );
153
214
  } catch (error) {
154
215
  throw new PluginError(
155
216
  error instanceof Error ? error.message : String(error),
@@ -185,14 +246,20 @@ export function coding(options: CodingOptions) {
185
246
  prompt: options.workerPrompt,
186
247
  blockText: options.workerBlockText,
187
248
  diagnosticChars: options.diagnosticChars,
188
- holds: (tool, input, scope) =>
189
- options.holds?.(tool, input, scope) ??
190
- context.sessions().holds(tool, input, scope),
249
+ holds: workerHolds(options.holds, () => context.sessions().holds),
191
250
  });
192
251
  const desk = new CodingDesk({
193
252
  shelf,
194
253
  worker,
195
254
  timeoutMs: options.timeoutMs,
255
+ ...(options.maxWorkers !== undefined
256
+ ? { maxWorkers: options.maxWorkers }
257
+ : {}),
258
+ ...(options.maxQueued !== undefined
259
+ ? { maxQueued: options.maxQueued }
260
+ : {}),
261
+ ...(options.modes ? { modes: options.modes } : {}),
262
+ ...(options.autonomy ? { autonomy: options.autonomy } : {}),
196
263
  limits: options.limits,
197
264
  logger: context.logger,
198
265
  threads: options.threads,
@@ -215,23 +282,8 @@ export function coding(options: CodingOptions) {
215
282
  });
216
283
  }),
217
284
  });
218
- const shipping = new Set<string>();
219
- async function ship<T>(
220
- repo: string,
221
- operation: () => Promise<T>,
222
- ): Promise<T> {
223
- desk.checkIdle(repo);
224
- if (shipping.has(repo))
225
- throw new AgentError(
226
- "A report or push is already in progress for this repository.",
227
- );
228
- shipping.add(repo);
229
- try {
230
- return await operation();
231
- } finally {
232
- shipping.delete(repo);
233
- }
234
- }
285
+ const ship = <T>(repo: string, operation: () => Promise<T>) =>
286
+ desk.ship(repo, operation);
235
287
  context.services.provide(CODING, { shelf, desk });
236
288
  // Whether the principal holds the owner role; no identity service, or a failed lookup,
237
289
  // tells of no one holding it.
@@ -427,7 +479,7 @@ export function coding(options: CodingOptions) {
427
479
  minTier: "owner",
428
480
  description: describe(
429
481
  "repo_task",
430
- "Start one background Pi coding worker in a clone. The worker reads repository instructions, edits, checks and commits; shipping uses a separate report and approval. Report returns to this channel.",
482
+ "Start a background Pi coding worker on a clone. Each worker has its own worktree and branch, so several may work on one repository; when it finishes, its commits are rebased onto the clone and fast-forwarded, and a conflict keeps the branch and is reported. When every worker slot is taken the task waits in a queue. The worker reads repository instructions, edits, checks and commits; shipping uses a separate report and approval. Report returns to this channel.",
431
483
  ),
432
484
  parameters: Type.Object({
433
485
  repo: repoSchema("repo_task"),
@@ -447,7 +499,7 @@ export function coding(options: CodingOptions) {
447
499
  throw new AgentError(
448
500
  "the host's own turns, such as a report's, start no coding tasks; ask the owner to start it",
449
501
  );
450
- if (shipping.has(repo))
502
+ if (desk.shipping(repo))
451
503
  throw new AgentError(
452
504
  "A report or push is in progress for this repository.",
453
505
  );
@@ -472,23 +524,48 @@ export function coding(options: CodingOptions) {
472
524
  model: options.model,
473
525
  thinking: options.thinking ?? "medium",
474
526
  };
475
- if (shipping.has(repo))
527
+ if (desk.shipping(repo))
476
528
  throw new AgentError(
477
529
  "A report or push is in progress for this repository.",
478
530
  );
531
+ const { speaker } = turn;
479
532
  const job = await desk.start({
480
533
  ...run,
534
+ // Its modes follow the conversation this turn runs in, as the turn's own would.
535
+ ...(speaker
536
+ ? {
537
+ starter: {
538
+ channel: turn.channel,
539
+ speaker: {
540
+ id: speaker.id,
541
+ principalId: speaker.principalId,
542
+ ...(speaker.name ? { name: speaker.name } : {}),
543
+ tier: speaker.tier,
544
+ },
545
+ },
546
+ }
547
+ : {}),
481
548
  repo,
482
549
  task,
483
550
  skillFiles: set?.skills.map((skill) => skill.file) ?? [],
484
551
  skillNames: set?.skills.map((skill) => skill.name) ?? [],
485
552
  });
553
+ const skipped = set?.missing.map((item) => item.name) ?? [];
554
+ const skippedText = skipped.length
555
+ ? ` Unavailable implicit skills skipped: ${skipped.join(", ")}.`
556
+ : "";
557
+ if (job.queuePosition !== undefined)
558
+ return (
559
+ options.presentation?.taskQueued?.(
560
+ job,
561
+ job.queuePosition,
562
+ skipped,
563
+ ) ??
564
+ `Queued coding task #${job.id} in ${repo} at position ${job.queuePosition}: every worker slot is taken, and it starts on ${job.model} when one frees. Its report returns to ${job.channel}.${skippedText}`
565
+ );
486
566
  return (
487
- options.presentation?.taskStarted?.(
488
- job,
489
- set?.missing.map((item) => item.name) ?? [],
490
- ) ??
491
- `Started coding task #${job.id} in ${repo} on ${job.model}; its report returns to ${job.channel}.${set?.missing.length ? ` Unavailable implicit skills skipped: ${set.missing.map((item) => item.name).join(", ")}.` : ""}`
567
+ options.presentation?.taskStarted?.(job, skipped) ??
568
+ `Started coding task #${job.id} in ${repo} on ${job.model}; its report returns to ${job.channel}.${skippedText}`
492
569
  );
493
570
  }),
494
571
  }),
package/src/index.ts CHANGED
@@ -1,14 +1,19 @@
1
1
  export type {
2
+ CodingAutonomy,
2
3
  CodingDeskOptions,
3
4
  CodingJob,
4
5
  CodingLimits,
6
+ CodingModes,
5
7
  CodingProgress,
6
8
  CodingResult,
9
+ CodingSpeaker,
10
+ CodingStart,
11
+ CodingStarter,
7
12
  CodingThreadText,
8
13
  CodingWorker,
9
14
  HeldCallAnswer,
10
15
  } from "./coding-desk.ts";
11
- export { CodingDesk, codingReport } from "./coding-desk.ts";
16
+ export { CodingDesk, codingReport, integrationText } from "./coding-desk.ts";
12
17
  export type {
13
18
  CodingOptions,
14
19
  CodingPresentation,
@@ -23,9 +28,16 @@ export { PiCodingWorker } from "./pi-coding-worker.ts";
23
28
  export type {
24
29
  ChangeReport,
25
30
  CloneCommand,
31
+ Integration,
26
32
  RepoState,
27
33
  RepoSummary,
34
+ WorkerTree,
35
+ } from "./repo-shelf.ts";
36
+ export {
37
+ ghClone,
38
+ RepoShelf,
39
+ reportPost,
40
+ WORKTREES_DIR,
28
41
  } from "./repo-shelf.ts";
29
- export { ghClone, RepoShelf, reportPost } from "./repo-shelf.ts";
30
42
  export { REPO_TOOLS } from "./repo-tools.ts";
31
43
  export { CodingWorkerFailure } from "./worker-failure.ts";
@@ -1,7 +1,12 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
3
3
  import type { HeldCall, HoldCheck } from "pi-roundtable";
4
- import { AgentError, canonicalJson, shellHoldRule } from "pi-roundtable/kit";
4
+ import {
5
+ AgentError,
6
+ canonicalJson,
7
+ holdChain,
8
+ shellHoldRule,
9
+ } from "pi-roundtable/kit";
5
10
  import type {
6
11
  CodingJob,
7
12
  CodingProgress,
@@ -55,6 +60,28 @@ function isMessage(value: unknown): value is WorkerMessage {
55
60
  );
56
61
  }
57
62
 
63
+ /** The worker's own shell rule as a check, for the classes of the calls it holds. */
64
+ const SHELL_CHECK: HoldCheck = holdChain([shellHoldRule]);
65
+
66
+ /**
67
+ * The hold classes of a held call, from every check that held it, as the host's gate records them
68
+ * for yolo mode; undefined when a holding check tells none, which leaves the call unclassed.
69
+ */
70
+ function classesOf(
71
+ tool: string,
72
+ input: Record<string, unknown>,
73
+ context: { workspace: string; scratchDir?: string },
74
+ holding: readonly HoldCheck[],
75
+ ): string[] | undefined {
76
+ const found: string[] = [];
77
+ for (const check of holding) {
78
+ const classes = check.classesOf?.(tool, input, context);
79
+ if (!classes?.length) return undefined;
80
+ found.push(...classes);
81
+ }
82
+ return found.length ? [...new Set(found)] : undefined;
83
+ }
84
+
58
85
  /** A fresh Bun process per task; only the host decides whether a held call may run. */
59
86
  export class PiCodingWorker implements CodingWorker {
60
87
  readonly #options: PiCodingWorkerOptions;
@@ -149,15 +176,24 @@ export class PiCodingWorker implements CodingWorker {
149
176
  ? { scratchDir: this.#options.scratchDir }
150
177
  : {}),
151
178
  };
152
- const action =
153
- shellHoldRule.describe(tool, input, context) ??
154
- this.#options.holds?.(tool, input, context);
179
+ // Every check is asked once, so the classes cover each reason the call is held.
180
+ const shell = shellHoldRule.describe(tool, input, context);
181
+ const { holds } = this.#options;
182
+ const host = holds?.(tool, input, context);
183
+ const action = shell ?? host;
155
184
  if (!signal.aborted) {
185
+ const holdClasses = action
186
+ ? classesOf(tool, input, context, [
187
+ ...(shell === undefined ? [] : [SHELL_CHECK]),
188
+ ...(host === undefined || !holds ? [] : [holds]),
189
+ ])
190
+ : undefined;
156
191
  answer = action
157
192
  ? await review({
158
193
  tool,
159
194
  input: canonicalJson(input),
160
195
  action,
196
+ ...(holdClasses ? { holdClasses } : {}),
161
197
  })
162
198
  : "approved";
163
199
  if (action && answer !== "approved")
package/src/repo-shelf.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  readSync,
11
11
  realpathSync,
12
12
  renameSync,
13
+ rmSync,
13
14
  } from "node:fs";
14
15
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
15
16
  import { AgentError, checkRepoName, scrubDiagnostic } from "pi-roundtable/kit";
@@ -54,6 +55,36 @@ export interface RepoState {
54
55
 
55
56
  export type CloneCommand = (repo: string, dir: string) => Promise<void>;
56
57
 
58
+ /** A coding worker's own checkout: a linked worktree on its own branch, cut from the clone's HEAD. */
59
+ export interface WorkerTree {
60
+ repo: string;
61
+ /** The worktree's real path, outside the shelf's list of managed repositories. */
62
+ dir: string;
63
+ /** The worker's branch, such as `worker/3`. */
64
+ branch: string;
65
+ /** The clone's full HEAD the branch was cut from. */
66
+ base: string;
67
+ }
68
+
69
+ /** What bringing a worker's branch back into its clone did. */
70
+ export type Integration =
71
+ /** The branch had no commits beyond the clone; the worktree and branch are gone. */
72
+ | { status: "nothing" }
73
+ /** Rebased onto the clone's branch and fast-forwarded it; the worktree and branch are gone. */
74
+ | { status: "merged"; branch: string; commits: string[] }
75
+ /** The rebase stopped on these files; it was aborted, and the branch and worktree are kept. */
76
+ | { status: "conflict"; branch: string; dir: string; files: string[] }
77
+ /** The clone or the worktree had uncommitted changes, or the clone moved; nothing was touched. */
78
+ | {
79
+ status: "refused";
80
+ branch: string;
81
+ dir: string;
82
+ reason: string;
83
+ };
84
+
85
+ /** The folder under the shelf that holds worker worktrees; the shelf lists no repository there. */
86
+ export const WORKTREES_DIR = ".worktrees";
87
+
57
88
  const GIT_TIMEOUT_MS = 60_000;
58
89
  const MAX_STAT_FILES = 40;
59
90
  const MAX_SUMMARY_CHARS = 120;
@@ -218,7 +249,7 @@ export class RepoShelf {
218
249
  if (!existsSync(this.dir)) return [];
219
250
  // pi-lens-ignore: no-sort-without-comparator — the names are strings, and code-unit order gives the same list on every machine
220
251
  return readdirSync(this.dir, { withFileTypes: true })
221
- .filter((owner) => owner.isDirectory())
252
+ .filter((owner) => owner.isDirectory() && !owner.name.startsWith("."))
222
253
  .flatMap((owner) =>
223
254
  readdirSync(join(this.dir, owner.name), {
224
255
  withFileTypes: true,
@@ -235,6 +266,7 @@ export class RepoShelf {
235
266
  /** A managed repository's folder, or an error naming the managed ones. */
236
267
  dirOf(repo: string): string {
237
268
  checkRepoName(repo);
269
+ refuseHidden(repo);
238
270
  const dir = join(this.dir, repo);
239
271
  if (!existsSync(join(dir, ".git")))
240
272
  throw new AgentError(
@@ -262,6 +294,7 @@ export class RepoShelf {
262
294
  /** Clones a repository into the shelf (behavior 2). */
263
295
  async add(repo: string): Promise<string> {
264
296
  checkRepoName(repo);
297
+ refuseHidden(repo);
265
298
  const dir = join(this.dir, repo);
266
299
  if (existsSync(dir) || this.#adding.has(repo))
267
300
  throw new AgentError(`${repo} is already present or being cloned.`);
@@ -398,6 +431,176 @@ export class RepoShelf {
398
431
  return log ? log.split("\n") : [];
399
432
  }
400
433
 
434
+ /**
435
+ * Cuts a worktree for coding job `id` from the clone's HEAD, on a fresh branch `worker/<id>`
436
+ * (with a suffix when an earlier run kept one of that name), under `<shelf>/.worktrees/`.
437
+ */
438
+ async addWorktree(repo: string, id: number): Promise<WorkerTree> {
439
+ const clone = this.dirOf(repo);
440
+ const base = await this.#git(clone, "rev-parse", "HEAD");
441
+ const parent = join(this.dir, WORKTREES_DIR, repo);
442
+ mkdirSync(parent, { recursive: true });
443
+ const rel = relative(realpathSync(this.dir), realpathSync(parent));
444
+ if (rel.startsWith("..") || isAbsolute(rel))
445
+ throw new AgentError("The worktree folder escapes the shelf.");
446
+ for (let attempt = 1; ; attempt++) {
447
+ const name = attempt === 1 ? `${id}` : `${id}-${attempt}`;
448
+ const branch = `worker/${name}`;
449
+ const dir = join(parent, name);
450
+ const taken =
451
+ existsSync(dir) ||
452
+ (
453
+ await run([
454
+ "git",
455
+ "-C",
456
+ clone,
457
+ "rev-parse",
458
+ "--verify",
459
+ "--quiet",
460
+ `refs/heads/${branch}`,
461
+ ])
462
+ ).code === 0;
463
+ if (taken) continue;
464
+ await this.#git(
465
+ clone,
466
+ "worktree",
467
+ "add",
468
+ "--quiet",
469
+ "-b",
470
+ branch,
471
+ dir,
472
+ base,
473
+ );
474
+ return { repo, dir: realpathSync(dir), branch, base };
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Rebases the worker's branch onto the clone's current HEAD inside its worktree and
480
+ * fast-forwards the clone, then removes the worktree and branch. The clone is never left
481
+ * mid-rebase: a conflict aborts the rebase in the worktree and keeps both. A clone or worktree
482
+ * with uncommitted changes is not touched. The caller serializes integrations of one clone.
483
+ */
484
+ async integrate(tree: WorkerTree): Promise<Integration> {
485
+ const clone = this.dirOf(tree.repo);
486
+ const kept = { branch: tree.branch, dir: tree.dir };
487
+ const dirty = await this.#git(clone, "status", "--porcelain");
488
+ if (dirty)
489
+ return {
490
+ status: "refused",
491
+ ...kept,
492
+ reason: `the clone has uncommitted changes, so it was left as it is:\n${dirty}`,
493
+ };
494
+ const head = await this.#git(clone, "rev-parse", "HEAD");
495
+ const ahead = Number(
496
+ await this.#git(clone, "rev-list", "--count", `${head}..${tree.branch}`),
497
+ );
498
+ if (ahead === 0) {
499
+ await this.removeWorktree(tree);
500
+ return { status: "nothing" };
501
+ }
502
+ const treeDir = this.#treeDir(tree);
503
+ const treeDirty = await this.#git(treeDir, "status", "--porcelain");
504
+ if (treeDirty)
505
+ return {
506
+ status: "refused",
507
+ ...kept,
508
+ reason: `the worker left uncommitted changes in its worktree:\n${treeDirty}`,
509
+ };
510
+ const rebase = await run(["git", "-C", treeDir, "rebase", "--quiet", head]);
511
+ if (rebase.code !== 0) {
512
+ const conflicted = await run([
513
+ "git",
514
+ "-C",
515
+ treeDir,
516
+ "diff",
517
+ "--name-only",
518
+ "--diff-filter=U",
519
+ ]);
520
+ await run(["git", "-C", treeDir, "rebase", "--abort"]);
521
+ return {
522
+ status: "conflict",
523
+ ...kept,
524
+ files: conflicted.out ? conflicted.out.split("\n") : [],
525
+ };
526
+ }
527
+ const merge = await run([
528
+ "git",
529
+ "-C",
530
+ clone,
531
+ "merge",
532
+ "--ff-only",
533
+ "--quiet",
534
+ tree.branch,
535
+ ]);
536
+ if (merge.code !== 0)
537
+ return {
538
+ status: "refused",
539
+ ...kept,
540
+ reason: `the clone could not fast-forward to the branch: ${scrubDiagnostic(merge.err, this.#diagnosticChars) || `exit ${merge.code}`}`,
541
+ };
542
+ const log = await this.#git(
543
+ clone,
544
+ "log",
545
+ "--format=%h %s",
546
+ "--abbrev=12",
547
+ `${head}..HEAD`,
548
+ );
549
+ await this.removeWorktree(tree);
550
+ return {
551
+ status: "merged",
552
+ branch: tree.branch,
553
+ commits: log ? log.split("\n") : [],
554
+ };
555
+ }
556
+
557
+ /** A worker worktree's state, as `state` reads a clone's. */
558
+ async treeState(tree: WorkerTree): Promise<RepoState> {
559
+ const dir = this.#treeDir(tree);
560
+ const status = await this.#git(dir, "status", "--porcelain");
561
+ return {
562
+ branch: await this.#git(dir, "rev-parse", "--abbrev-ref", "HEAD"),
563
+ head: await this.#git(dir, "rev-parse", "--short=12", "HEAD"),
564
+ uncommitted: status ? status.split("\n") : [],
565
+ };
566
+ }
567
+
568
+ /** `<short sha> <subject>` of each commit on the worker's branch after its base, newest first. */
569
+ async treeCommits(tree: WorkerTree): Promise<string[]> {
570
+ const log = await this.#git(
571
+ this.#treeDir(tree),
572
+ "log",
573
+ "--format=%h %s",
574
+ "--abbrev=12",
575
+ `${tree.base}..HEAD`,
576
+ );
577
+ return log ? log.split("\n") : [];
578
+ }
579
+
580
+ /** Removes a worker's worktree and its branch; what is already gone is skipped. */
581
+ async removeWorktree(tree: WorkerTree): Promise<void> {
582
+ const clone = this.dirOf(tree.repo);
583
+ this.#treeDir(tree, false);
584
+ await run(["git", "-C", clone, "worktree", "remove", "--force", tree.dir]);
585
+ if (existsSync(tree.dir))
586
+ rmSync(tree.dir, { recursive: true, force: true });
587
+ await run(["git", "-C", clone, "worktree", "prune"]);
588
+ await run(["git", "-C", clone, "branch", "-D", "--quiet", tree.branch]);
589
+ }
590
+
591
+ /** A worktree path, refused unless it lies in this repository's worktree folder. */
592
+ #treeDir(tree: WorkerTree, mustExist = true): string {
593
+ const parent = resolve(this.dir, WORKTREES_DIR, tree.repo);
594
+ const path = mustExist ? realpathSync(tree.dir) : resolve(tree.dir);
595
+ const root = existsSync(parent) ? realpathSync(parent) : parent;
596
+ const rel = relative(root, path);
597
+ if (!rel || rel.startsWith("..") || isAbsolute(rel) || rel.includes("/"))
598
+ throw new AgentError(
599
+ "The worktree lies outside the shelf's worktree folder.",
600
+ );
601
+ return path;
602
+ }
603
+
401
604
  async #summary(repo: string, fetch: boolean): Promise<RepoSummary> {
402
605
  const dir = this.dirOf(repo);
403
606
  const summary = summaryOf(dir);
@@ -499,6 +702,14 @@ export class RepoShelf {
499
702
  }
500
703
  }
501
704
 
705
+ /** Owners starting with a dot are the shelf's own folders, such as its worktrees. */
706
+ function refuseHidden(repo: string): void {
707
+ if (repo.startsWith("."))
708
+ throw new AgentError(
709
+ `"${repo}" is not a repository: owners starting with a dot are reserved for the shelf.`,
710
+ );
711
+ }
712
+
502
713
  /** The report as posted for the owner. */
503
714
  export function reportPost(report: ChangeReport, direct = false): string {
504
715
  return [
@@ -34,6 +34,7 @@ export function codingWorkerPrompt(dir: string): string {
34
34
  return [
35
35
  `You are a coding worker in ${dir}. The next message is your approved task. Read this repository's AGENTS.md.`,
36
36
  "Work with the shell and file tools available. Complete and verify the contract, stage files by name and commit using repository conventions, without tool attribution.",
37
+ "You work in your own worktree on its own branch: commit there and stay on it, without switching branches or rebasing; the host merges your branch back when you finish.",
37
38
  "Do not push or ship. The calling agent reviews your commits and requests the owner's approval through repo_change_report and repo_push.",
38
39
  "Risky calls wait for the owner's approval. An approved call runs; a declined or held call must not be retried or worked around.",
39
40
  "Resolve decisions settled by the task or repository. Put remaining owner decisions and held actions in your report instead of guessing.",