dsh-report-ledger 0.1.0 → 0.2.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.
package/lib/index.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { randomBytes, randomUUID } from "node:crypto";
3
- import { appendFile, mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import { appendFile, mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises";
4
5
  import { homedir } from "node:os";
5
- import { join } from "node:path";
6
+ import { basename, dirname, join, normalize, resolve } from "node:path";
7
+ import { execFile } from "node:child_process";
6
8
  import { defineTool } from "@deepseek-ai/dsh-tools";
7
9
  //#region \0rolldown/runtime.js
8
10
  var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
@@ -149,10 +151,16 @@ const PROTOCOL = [
149
151
  "- report_list —— 只列缩略;用 session 过滤看某位同伴相关的全部汇报,用 task 把同一次协作跨会话拉到一起(部分匹配、不区分大小写)。",
150
152
  "- report_ack —— 回执:确认已收到并处理,让发送方看到闭环(事情仍在进行时用它)。",
151
153
  "- report_amend —— 修正:改错的主题/任务标签/产物,或更正正文。**只有发起者或共写者**能改;正文默认是**追加**一段更正,明确要求(replace_body)才会整段替换,两种都会在传递路径里留下记录。",
152
- "- report_close —— 结案:事情做完了才用它,且**只有发起者或共写者**能关;之后任何贡献/主送/抄送/转呈都会自动重开并记录。",
154
+ "- report_close —— 结案:事情做完了才用它,且**只有发起者或共写者**能关;之后任何贡献/主送/抄送/转呈都会自动重开并记录。结案同时**释放**它持有的工作区(见下)。",
155
+ "- report_release —— 交还工作区:工作做完了但对话没结束、想让出检出时用它。检出原样留在磁盘上,结束的只是\"占用\"。",
156
+ "- report_lease —— 认领一个**已存在**的检出(人手建的、上一份汇报留下的、或你要接手的):与 report_release 互为反向。分支从检出本身读出,所以只有想强制校验时才传 branch。",
153
157
  "- peer_list —— 列出你能对话的代理(上级、下属、兄弟、你开启的同伴、账本里有往来的联系人),并标注谁此刻在线。",
154
158
  "- peer_start —— 开启一个**独立同伴会话**(不是下属):它有自己的生命周期、比你活得更久;任务会作为一份汇报交给它。",
155
159
  "",
160
+ "工作区(若本部署启用):peer_start / report_author 加 worktree:true,会为该汇报分配一个**独立的 git 检出**,分支名形如 dsh/R-0007-<主题>。",
161
+ "独立的只有**工作目录与索引**;config、hooks、对象库、以及机器上一切检出之外的东西都是**共享**的——装一个 hook 或改一项配置,会影响所有检出里每一次提交。",
162
+ "一个分支同一时刻只能被一个检出占用,所以它同时也是一把锁:认领已有检出用 report_lease,交还用 report_release,结案会自动交还。被占用时会明确告诉你占用者是谁。",
163
+ "",
156
164
  "五条行为约定:",
157
165
  "1. 收件方不在线不是错误。投递由账本持有,等该会话重新驻留时自动送达;不要因为对方此刻不在就放弃交接、或改为自己扛下。",
158
166
  "2. 传递路径(含每一份抄送与副本)由账本自动记录,不需要你手工维护——但每一步都必须经工具完成,绕过工具的口信不会进入路径。",
@@ -6788,7 +6796,7 @@ var require_public_api = /* @__PURE__ */ __commonJSMin(((exports) => {
6788
6796
  exports.stringify = stringify;
6789
6797
  }));
6790
6798
  //#endregion
6791
- //#region src/report/frontmatter.ts
6799
+ //#region src/report/types.ts
6792
6800
  var import_dist = (/* @__PURE__ */ __commonJSMin(((exports) => {
6793
6801
  var composer = require_composer();
6794
6802
  var Document = require_Document();
@@ -6835,14 +6843,58 @@ var import_dist = (/* @__PURE__ */ __commonJSMin(((exports) => {
6835
6843
  exports.visit = visit.visit;
6836
6844
  exports.visitAsync = visit.visitAsync;
6837
6845
  })))();
6838
- /** The front-matter fence. */
6839
- const FENCE = "---";
6840
- const STATUSES = [
6846
+ /**
6847
+ * The report model.
6848
+ *
6849
+ * A report is the plugin's first-class interaction primitive between agents. It
6850
+ * is deliberately two-layered:
6851
+ *
6852
+ * - the FRONT MATTER is the digest — a bounded flat mapping that can enter a
6853
+ * model context, an index, or a timeline card without dragging the body in;
6854
+ * - the BODY is the detail, stored out of context and read on demand.
6855
+ *
6856
+ * The transfer path is a third, append-only stream (see {@link RouteHop}): every
6857
+ * hop — authoring, contribution, send, delivery, CC, forwarding, copying, read,
6858
+ * acknowledgement — is recorded with its actor and time, so the complete route
6859
+ * including every copy survives independently of the report text.
6860
+ *
6861
+ * @module dsh-report-ledger/report/types
6862
+ */
6863
+ /**
6864
+ * Every report status, as both the runtime list and the source of
6865
+ * {@link ReportStatus}.
6866
+ *
6867
+ * A union type and a validator array are two spellings of one fact, and two
6868
+ * spellings drift. The parser falls back to `open` for anything it does not
6869
+ * recognize, so a status that existed in the type but not in the array would
6870
+ * quietly rewrite itself on the next read.
6871
+ */
6872
+ const REPORT_STATUSES = [
6841
6873
  "open",
6842
6874
  "acked",
6843
6875
  "closed"
6844
6876
  ];
6845
- const ACTIONS = [
6877
+ /**
6878
+ * One recorded hop in a report's transfer path.
6879
+ *
6880
+ * `authored` and `contributed` establish co-authorship; `sent`/`delivered` are
6881
+ * the addressed hand-off and its arrival; `cc` is a quiet copy; `forwarded` and
6882
+ * `copied` are onward transfer and duplication; `read`/`acked` close the loop for
6883
+ * one reader; `leased`/`released` bracket a claim on a working tree, and
6884
+ * `reclaimed` records that the ledger cleared a checkout away — whether to stay
6885
+ * within its retained budget or because the owner asked for it at closing time;
6886
+ * `amended` records a correction to the digest or the body; `closed` concludes
6887
+ * the matter, and `reopened` records that fresh activity brought it back to life.
6888
+ *
6889
+ * Declared as a value and derived into a type on purpose. The parser validates
6890
+ * each record against this list and **drops** one it does not recognize, so a
6891
+ * union that named an action this array omitted would lose those hops from the
6892
+ * audit trail without a word — the single failure a ledger must not have.
6893
+ * Deriving the type from the value makes that drift impossible rather than
6894
+ * merely unlikely; it happened once already, when `leased` and `released`
6895
+ * reached the union before the array.
6896
+ */
6897
+ const HOP_ACTIONS = [
6846
6898
  "authored",
6847
6899
  "contributed",
6848
6900
  "sent",
@@ -6852,10 +6904,31 @@ const ACTIONS = [
6852
6904
  "copied",
6853
6905
  "read",
6854
6906
  "acked",
6907
+ "leased",
6908
+ "released",
6909
+ "reclaimed",
6855
6910
  "amended",
6856
6911
  "closed",
6857
6912
  "reopened"
6858
6913
  ];
6914
+ //#endregion
6915
+ //#region src/report/frontmatter.ts
6916
+ /**
6917
+ * The report file codec: YAML front matter plus a Markdown body.
6918
+ *
6919
+ * The format deliberately mirrors what this harness already uses for its own
6920
+ * documents (skills, package READMEs): an opening `---` line, a flat YAML
6921
+ * mapping, a closing `---` line, then free text. The parser below is a
6922
+ * reimplementation of the shipped skills parser
6923
+ * (`@deepseek-ai/dsh-skill-filesystem`, whose `parseFrontmatter` is module
6924
+ * private and not re-exported), tuned for the report schema: it tolerates a
6925
+ * hand-edited ledger by normalizing whatever it finds instead of throwing, so a
6926
+ * human can correct a report file without breaking the ledger.
6927
+ *
6928
+ * @module dsh-report-ledger/report/frontmatter
6929
+ */
6930
+ /** The front-matter fence. */
6931
+ const FENCE = "---";
6859
6932
  /**
6860
6933
  * Split a document into its YAML front matter and body.
6861
6934
  *
@@ -6921,6 +6994,33 @@ function time(value, fallback) {
6921
6994
  return parsed === void 0 || parsed < 0 ? fallback : parsed;
6922
6995
  }
6923
6996
  /**
6997
+ * Read a working-tree binding, tolerating the hand-edited short form.
6998
+ *
6999
+ * A human who wants to point a report at a checkout should be able to write
7000
+ * `worktree: /path/to/tree` and have it mean what it looks like, so a bare
7001
+ * scalar is normalized to a path-only binding. A mapping with no usable path is
7002
+ * dropped whole rather than half-read: a binding with a branch but no path would
7003
+ * claim a branch the ledger cannot locate, which is worse than no binding.
7004
+ * @param value - the raw front-matter value.
7005
+ * @returns the binding, or `undefined` when there is no usable path.
7006
+ */
7007
+ function toWorktree(value) {
7008
+ if (value === void 0 || value === null) return void 0;
7009
+ const record = typeof value === "object" && !Array.isArray(value) ? value : { path: value };
7010
+ const path = str$2(record.path);
7011
+ if (path === void 0) return void 0;
7012
+ const branch = str$2(record.branch);
7013
+ const head = str$2(record.head);
7014
+ const repo = str$2(record.repo);
7015
+ return {
7016
+ path,
7017
+ ...branch === void 0 ? {} : { branch },
7018
+ ...head === void 0 ? {} : { head },
7019
+ ...repo === void 0 ? {} : { repo },
7020
+ ...typeof record.allocated === "boolean" ? { allocated: record.allocated } : {}
7021
+ };
7022
+ }
7023
+ /**
6924
7024
  * Normalize a raw front-matter mapping into the report schema.
6925
7025
  *
6926
7026
  * Every field is optional on the way in: a missing recipient list becomes empty,
@@ -6933,15 +7033,16 @@ function toFrontMatter(data) {
6933
7033
  const report = str$2(data.report) ?? str$2(data.id);
6934
7034
  if (report === void 0) return void 0;
6935
7035
  const statusText = str$2(data.status);
6936
- const status = STATUSES.find((candidate) => candidate === statusText) ?? "open";
7036
+ const status = REPORT_STATUSES.find((candidate) => candidate === statusText) ?? "open";
6937
7037
  const created = time(data.created, 0);
6938
7038
  const updated = time(data.updated, created);
7039
+ const worktree = toWorktree(data.worktree);
6939
7040
  const lastRaw = data.last;
6940
7041
  let last;
6941
7042
  if (lastRaw !== null && typeof lastRaw === "object" && !Array.isArray(lastRaw)) {
6942
7043
  const record = lastRaw;
6943
7044
  const actionText = str$2(record.action);
6944
- const action = ACTIONS.find((candidate) => candidate === actionText);
7045
+ const action = HOP_ACTIONS.find((candidate) => candidate === actionText);
6945
7046
  const actor = str$2(record.actor);
6946
7047
  if (action !== void 0 && actor !== void 0) last = {
6947
7048
  at: time(record.at, updated),
@@ -6963,6 +7064,7 @@ function toFrontMatter(data) {
6963
7064
  ...str$2(data.parent) === void 0 ? {} : { parent: str$2(data.parent) },
6964
7065
  children: strList(data.children),
6965
7066
  ...str$2(data.task) === void 0 ? {} : { task: str$2(data.task) },
7067
+ ...worktree === void 0 ? {} : { worktree },
6966
7068
  artifacts: strList(data.artifacts),
6967
7069
  hops: num(data.hops) ?? 0,
6968
7070
  ...last === void 0 ? {} : { last },
@@ -7008,6 +7110,13 @@ function frontMatterYaml(front) {
7008
7110
  if (front.parent !== void 0) ordered.parent = front.parent;
7009
7111
  ordered.children = [...front.children];
7010
7112
  if (front.task !== void 0) ordered.task = front.task;
7113
+ if (front.worktree !== void 0) ordered.worktree = {
7114
+ path: front.worktree.path,
7115
+ ...front.worktree.branch === void 0 ? {} : { branch: front.worktree.branch },
7116
+ ...front.worktree.head === void 0 ? {} : { head: front.worktree.head },
7117
+ ...front.worktree.repo === void 0 ? {} : { repo: front.worktree.repo },
7118
+ ...front.worktree.allocated === void 0 ? {} : { allocated: front.worktree.allocated }
7119
+ };
7011
7120
  ordered.artifacts = [...front.artifacts];
7012
7121
  ordered.hops = front.hops;
7013
7122
  if (front.last !== void 0) ordered.last = {
@@ -7051,7 +7160,7 @@ function parseHop(raw) {
7051
7160
  if (value === null || typeof value !== "object" || Array.isArray(value)) return void 0;
7052
7161
  const record = value;
7053
7162
  const actionText = str$2(record.action);
7054
- const action = ACTIONS.find((candidate) => candidate === actionText);
7163
+ const action = HOP_ACTIONS.find((candidate) => candidate === actionText);
7055
7164
  const actor = str$2(record.actor);
7056
7165
  if (action === void 0 || actor === void 0) return void 0;
7057
7166
  const from = str$2(record.from);
@@ -7280,6 +7389,793 @@ async function listReports() {
7280
7389
  digests.sort((left, right) => right.updated - left.updated || right.report.localeCompare(left.report));
7281
7390
  return digests;
7282
7391
  }
7392
+ /**
7393
+ * Whether a report document exists on disk.
7394
+ * @param id - report identifier.
7395
+ * @returns true when the document exists.
7396
+ */
7397
+ function reportExists(id) {
7398
+ return isValidReportId(id) && existsSync(reportPath(id));
7399
+ }
7400
+ /**
7401
+ * Give back an id that was claimed but never became a report.
7402
+ *
7403
+ * {@link allocateId} claims a name by creating an empty document, which is what
7404
+ * makes a concurrent claim lose cleanly. When the work that needed the id then
7405
+ * fails — a working tree that could not be created, a peer that never started —
7406
+ * the claim would otherwise leave an empty file behind forever, burning the
7407
+ * number and showing up in no listing.
7408
+ *
7409
+ * Only an **empty** document is removed. A report with any content is kept, so a
7410
+ * misuse of this function can cost at most a placeholder, never a record.
7411
+ * @param id - the claimed identifier.
7412
+ * @returns true when an empty claim was removed.
7413
+ */
7414
+ async function discardUnwrittenId(id) {
7415
+ if (!isValidReportId(id)) return false;
7416
+ const path = reportPath(id);
7417
+ try {
7418
+ if (await readFile(path, "utf8") !== "") return false;
7419
+ } catch {
7420
+ return false;
7421
+ }
7422
+ try {
7423
+ await unlink(path);
7424
+ return true;
7425
+ } catch {
7426
+ return false;
7427
+ }
7428
+ }
7429
+ //#endregion
7430
+ //#region src/report/worktree.ts
7431
+ /**
7432
+ * Git worktree adapter.
7433
+ *
7434
+ * The ledger records *intent*; git records *reality*. This module is the whole
7435
+ * of the second half: it shells out to `git`, reads `git worktree list`, and
7436
+ * decides nothing. Every policy question — which report may hold a branch, when
7437
+ * a lease may be reclaimed, what to do when the two layers disagree — belongs to
7438
+ * the service layer, because answering it needs the ledger. This module must
7439
+ * stay usable and testable without one.
7440
+ *
7441
+ * Four deliberate choices:
7442
+ *
7443
+ * - **Shell out, never reimplement.** Git's occupancy check, its refusal to
7444
+ * remove an unclean tree, and its `repair` are the authority. A weaker
7445
+ * reimplementation in TypeScript would be a second source of truth about the
7446
+ * same bytes, which is the exact failure this plugin exists to avoid.
7447
+ * - **No `--force`, anywhere.** Neither {@link addWorktree} nor
7448
+ * {@link removeWorktree} exposes a force flag, so no caller can acquire the
7449
+ * ability to discard uncommitted work or to claim a branch git says is taken.
7450
+ * A safeguard can only be overridden by a human running git, deliberately.
7451
+ * - **`--porcelain -z` for parsing.** The non-`-z` form escapes unusual
7452
+ * characters and quotes paths containing them, so a parser for it must
7453
+ * reimplement git's C-style quoting. The `-z` form emits raw NUL-separated
7454
+ * fields, which makes parsing a split rather than an unescape.
7455
+ * - **Paths stay native, comparison normalizes.** Git prints forward slashes
7456
+ * even on Windows while the harness hands us backslashes. Rather than pick a
7457
+ * dialect everywhere, stored paths keep the form the OS uses and
7458
+ * {@link compareKey} is the single place that decides when two spellings name
7459
+ * one file.
7460
+ *
7461
+ * @module dsh-report-ledger/report/worktree
7462
+ */
7463
+ /** Directory name, under a repository's parent, that holds ledger-allocated worktrees. */
7464
+ const WORKTREE_DIR = ".dsh-worktrees";
7465
+ /** Default timeout for one git invocation. */
7466
+ const GIT_TIMEOUT_MS = 3e4;
7467
+ /** Exit status used for a failure that never reached git (a missing binary). */
7468
+ const NOT_EXECUTED = -1;
7469
+ /**
7470
+ * Recognized refusals, most specific first.
7471
+ *
7472
+ * Every pattern is copied from a message observed from git 2.49 on Windows.
7473
+ * Matching on prose is brittle by nature, so the classifier exists only to let
7474
+ * the caller choose a *remedy*; the raw stderr is always passed through
7475
+ * unchanged, and `unknown` is a correct answer rather than a failure.
7476
+ *
7477
+ * Order carries meaning. `a branch named 'x' already exists` and `'<path>'
7478
+ * already exists` are different problems with different remedies — reuse or
7479
+ * rename the branch, versus choose another directory — and git words both with
7480
+ * "already exists", so the branch form has to be recognized before the generic
7481
+ * one or every name collision looks like a full directory.
7482
+ */
7483
+ const REFUSALS = [
7484
+ ["branch-in-use", /is already used by worktree at/i],
7485
+ ["branch-exists", /a branch named .* already exists/i],
7486
+ ["missing-but-registered", /is a missing but already registered worktree/i],
7487
+ ["main-working-tree", /is a main working tree|cannot be locked or unlocked/i],
7488
+ ["locked", /cannot remove a locked working tree/i],
7489
+ ["unclean", /contains modified or untracked files/i],
7490
+ ["path-exists", /already exists/i]
7491
+ ];
7492
+ /**
7493
+ * Run one git command and capture its result without throwing.
7494
+ *
7495
+ * The ledger treats a git refusal as information, not as an exception: "this
7496
+ * branch is taken" is a normal answer that has to reach a model as a sentence.
7497
+ * So this returns the exit status instead of rejecting, and a binary that cannot
7498
+ * be run at all is reported the same way rather than as a crash.
7499
+ * @param cwd - directory to run in.
7500
+ * @param args - arguments after the program name.
7501
+ * @returns the exit status and both streams.
7502
+ */
7503
+ async function runGit(cwd, args) {
7504
+ return await new Promise((settle) => {
7505
+ execFile("git", [...args], {
7506
+ cwd,
7507
+ encoding: "utf8",
7508
+ timeout: GIT_TIMEOUT_MS,
7509
+ maxBuffer: 8 * 1024 * 1024,
7510
+ windowsHide: true
7511
+ }, (error, stdout, stderr) => {
7512
+ const raw = error?.code;
7513
+ const out = typeof stdout === "string" ? stdout : "";
7514
+ const err = typeof stderr === "string" ? stderr : "";
7515
+ settle({
7516
+ ok: error === null,
7517
+ code: error === null ? 0 : typeof raw === "number" ? raw : NOT_EXECUTED,
7518
+ stdout: out,
7519
+ stderr: err === "" ? error?.message ?? "" : err
7520
+ });
7521
+ });
7522
+ });
7523
+ }
7524
+ /**
7525
+ * Locate the repository a directory belongs to.
7526
+ *
7527
+ * Answers `undefined` rather than throwing when the directory is not in a
7528
+ * repository, because "not a repository" is the ordinary answer for the many
7529
+ * sessions that never touch one, and the caller must be able to ask cheaply.
7530
+ * @param cwd - a directory inside the repository (or the repository itself).
7531
+ * @returns the location, or `undefined` when this is not a git repository.
7532
+ */
7533
+ async function resolveRepo(cwd) {
7534
+ if (cwd.trim() === "") return void 0;
7535
+ const common = await runGit(cwd, ["rev-parse", "--git-common-dir"]);
7536
+ if (!common.ok) return void 0;
7537
+ const reported = common.stdout.trim();
7538
+ if (reported === "") return void 0;
7539
+ const commonDir = normalize(resolve(cwd, reported));
7540
+ const bare = (await runGit(cwd, ["rev-parse", "--is-bare-repository"])).stdout.trim() === "true";
7541
+ const repoRoot = bare ? commonDir : dirname(commonDir);
7542
+ const repoParent = dirname(repoRoot);
7543
+ const repoName = basename(repoRoot).replace(/\.git$/i, "") || "repo";
7544
+ return {
7545
+ commonDir,
7546
+ repoRoot,
7547
+ repoParent,
7548
+ repoName,
7549
+ bare,
7550
+ worktreeRoot: join(repoParent, WORKTREE_DIR, repoName)
7551
+ };
7552
+ }
7553
+ /** Strip `refs/heads/` so a branch is named the way every other layer names it. */
7554
+ function shortBranch(ref) {
7555
+ return ref.startsWith("refs/heads/") ? ref.slice(11) : ref;
7556
+ }
7557
+ /**
7558
+ * Parse `git worktree list --porcelain -z` output.
7559
+ *
7560
+ * In the `-z` form every field is NUL-terminated and a record ends at an empty
7561
+ * field, so a record boundary is a split on `\0` that yields `''`. Fields are
7562
+ * `key value`, or a bare `key` for a boolean one (`detached`, `bare`) and for a
7563
+ * flag whose reason is optional (`locked`, `prunable`).
7564
+ * @param raw - the command's standard output.
7565
+ * @returns one entry per record that carried a path.
7566
+ */
7567
+ function parseWorktreeList(raw) {
7568
+ const entries = [];
7569
+ let fields = /* @__PURE__ */ new Map();
7570
+ const flush = () => {
7571
+ const path = fields.get("worktree");
7572
+ if (path !== void 0 && path !== "") {
7573
+ const head = fields.get("HEAD");
7574
+ const branchRef = fields.get("branch");
7575
+ const lockReason = fields.get("locked");
7576
+ const prunableReason = fields.get("prunable");
7577
+ entries.push({
7578
+ path: normalize(path),
7579
+ detached: fields.has("detached"),
7580
+ bare: fields.has("bare"),
7581
+ locked: fields.has("locked"),
7582
+ prunable: fields.has("prunable"),
7583
+ ...head === void 0 || head === "" ? {} : { head },
7584
+ ...branchRef === void 0 || branchRef === "" ? {} : { branch: shortBranch(branchRef) },
7585
+ ...lockReason === void 0 || lockReason === "" ? {} : { lockReason },
7586
+ ...prunableReason === void 0 || prunableReason === "" ? {} : { prunableReason }
7587
+ });
7588
+ }
7589
+ fields = /* @__PURE__ */ new Map();
7590
+ };
7591
+ for (const field of raw.split("\0")) {
7592
+ if (field === "") {
7593
+ flush();
7594
+ continue;
7595
+ }
7596
+ const space = field.indexOf(" ");
7597
+ if (space < 0) fields.set(field, "");
7598
+ else fields.set(field.slice(0, space), field.slice(space + 1));
7599
+ }
7600
+ flush();
7601
+ return entries;
7602
+ }
7603
+ /**
7604
+ * List every working tree of a repository.
7605
+ * @param repo - the repository.
7606
+ * @returns one entry per working tree, main worktree first.
7607
+ * @throws when git itself cannot be asked.
7608
+ */
7609
+ async function listWorktrees(repo) {
7610
+ const result = await runGit(repo.commonDir, [
7611
+ "worktree",
7612
+ "list",
7613
+ "--porcelain",
7614
+ "-z"
7615
+ ]);
7616
+ if (!result.ok) throw new Error(`report-ledger: git worktree list failed: ${result.stderr.trim() || `exit ${result.code}`}`);
7617
+ return parseWorktreeList(result.stdout);
7618
+ }
7619
+ /**
7620
+ * Map each branch to the entry currently holding it.
7621
+ *
7622
+ * Prunable entries are skipped deliberately. Their working tree is gone, so they
7623
+ * do not actually hold anything — which is precisely why a stale entry must not
7624
+ * be allowed to block reuse forever, and why the same list yields two different
7625
+ * answers depending on the question being asked.
7626
+ * @param entries - entries from {@link listWorktrees}.
7627
+ * @returns branch name to holding entry.
7628
+ */
7629
+ function branchHolders(entries) {
7630
+ const holders = /* @__PURE__ */ new Map();
7631
+ for (const entry of entries) {
7632
+ if (entry.branch === void 0 || entry.prunable) continue;
7633
+ if (!holders.has(entry.branch)) holders.set(entry.branch, entry);
7634
+ }
7635
+ return holders;
7636
+ }
7637
+ /**
7638
+ * Whether a working tree has no modification, no staged change and no untracked
7639
+ * file.
7640
+ *
7641
+ * Untracked files count as dirty on purpose: they are exactly what `git worktree
7642
+ * remove` refuses to delete, so a check that ignored them would authorize a
7643
+ * removal git then rejects.
7644
+ * @param worktreePath - the working tree to inspect.
7645
+ * @returns true only when git confirms a clean tree; a failure to ask answers
7646
+ * false, because an unreadable tree must never be treated as safe to delete.
7647
+ */
7648
+ async function isWorktreeClean(worktreePath) {
7649
+ const result = await runGit(worktreePath, ["status", "--porcelain"]);
7650
+ if (!result.ok) return false;
7651
+ return result.stdout.trim() === "";
7652
+ }
7653
+ /**
7654
+ * Read the commit a working tree is on.
7655
+ * @param worktreePath - the working tree to inspect.
7656
+ * @returns the object id, or `undefined` for an unborn branch or a failure.
7657
+ */
7658
+ async function headOf(worktreePath) {
7659
+ const result = await runGit(worktreePath, ["rev-parse", "HEAD"]);
7660
+ if (!result.ok) return void 0;
7661
+ const sha = result.stdout.trim();
7662
+ return /^[0-9a-f]{40,64}$/.test(sha) ? sha : void 0;
7663
+ }
7664
+ /**
7665
+ * Whether a repository uses submodules.
7666
+ *
7667
+ * Git's own manual is explicit that multiple checkouts are experimental and that
7668
+ * support for submodules is incomplete — a linked worktree containing submodules
7669
+ * cannot even be moved. So this is a gate the caller is expected to consult
7670
+ * before allocating, not a warning to print afterwards.
7671
+ *
7672
+ * A bare repository answers false: the answer is derived from the working tree's
7673
+ * `.gitmodules`, which a bare repository does not have.
7674
+ * @param repo - the repository.
7675
+ * @returns true when `.gitmodules` exists in the main working tree.
7676
+ */
7677
+ function hasSubmodules(repo) {
7678
+ if (repo.bare) return false;
7679
+ return existsSync(join(repo.repoRoot, ".gitmodules"));
7680
+ }
7681
+ /**
7682
+ * Attach a linked working tree.
7683
+ *
7684
+ * There is no force flag: git's occupancy check and its refusal of an unclean
7685
+ * path are returned to the caller as results, never overridden. A refusal is
7686
+ * data the ledger turns into a sentence naming a remedy.
7687
+ * @param input - what to create or check out.
7688
+ * @returns the git result; the caller decides whether a refusal is fatal.
7689
+ */
7690
+ async function addWorktree(input) {
7691
+ const args = ["worktree", "add"];
7692
+ if (input.relativePaths === true) args.push("--relative-paths");
7693
+ if (input.checkoutExisting === true) args.push(input.path, input.branch);
7694
+ else args.push("-b", input.branch, input.path, input.base);
7695
+ return await runGit(input.repo.commonDir, args);
7696
+ }
7697
+ /**
7698
+ * Remove a linked working tree.
7699
+ *
7700
+ * Deliberately without a force option. Git refuses when the tree is unclean or
7701
+ * locked, and that refusal is the last line of defence for work no one has
7702
+ * committed; exposing `--force` here would turn a policy mistake in the ledger
7703
+ * into silent data loss. A human who truly wants the tree gone can run the
7704
+ * command themselves, which is the correct amount of friction.
7705
+ * @param repo - the repository the tree belongs to.
7706
+ * @param worktreePath - the working tree to remove.
7707
+ * @returns the git result; a refusal is data, not an exception.
7708
+ */
7709
+ async function removeWorktree(repo, worktreePath) {
7710
+ return await runGit(repo.commonDir, [
7711
+ "worktree",
7712
+ "remove",
7713
+ worktreePath
7714
+ ]);
7715
+ }
7716
+ /**
7717
+ * Classify a git refusal so the caller can name a remedy.
7718
+ * @param stderr - the command's standard error.
7719
+ * @returns the recognized kind, or `'unknown'`.
7720
+ */
7721
+ function classifyGitRefusal(stderr) {
7722
+ for (const [kind, pattern] of REFUSALS) if (pattern.test(stderr)) return kind;
7723
+ return "unknown";
7724
+ }
7725
+ /** Strip a trailing separator without ever eating a root. */
7726
+ function stripTrailingSeparators(path) {
7727
+ if (/^[a-zA-Z]:[\\/]$/.test(path)) return path;
7728
+ if (/^[\\/]{1,2}$/.test(path)) return path;
7729
+ return path.replace(/[\\/]+$/, "");
7730
+ }
7731
+ /**
7732
+ * Reduce a path to the string two spellings are compared by.
7733
+ *
7734
+ * The single place that decides when two paths name one file. Windows compares
7735
+ * case-insensitively and treats either separator as a separator; a POSIX host
7736
+ * does neither, so the rule is the host's rather than a lowest common
7737
+ * denominator — comparing case-insensitively on Linux would merge two directories
7738
+ * git considers distinct.
7739
+ * @param path - a path, absolute or relative to the current directory.
7740
+ * @returns the comparison key.
7741
+ */
7742
+ function compareKey(path) {
7743
+ const absolute = stripTrailingSeparators(normalize(resolve(path)));
7744
+ return process.platform === "win32" ? absolute.toLowerCase() : absolute;
7745
+ }
7746
+ /**
7747
+ * Whether two paths name the same file.
7748
+ * @param left - one path.
7749
+ * @param right - another path.
7750
+ * @returns true when they share a comparison key.
7751
+ */
7752
+ function samePath(left, right) {
7753
+ return compareKey(left) === compareKey(right);
7754
+ }
7755
+ //#endregion
7756
+ //#region src/report/lease.ts
7757
+ /**
7758
+ * The working-tree lease referee.
7759
+ *
7760
+ * A lease answers one question: may *this* report work in *that* checkout? The
7761
+ * answer needs two authorities, and this module consults both.
7762
+ *
7763
+ * - **The ledger knows intent.** It can say which report claims a branch, and
7764
+ * therefore name the agent to talk to. Git cannot: it reports a path, and a
7765
+ * path is not a colleague.
7766
+ * - **Git knows reality.** It can say which working tree actually has a branch
7767
+ * checked out. The ledger cannot: a checkout a human made by hand never
7768
+ * appears in it.
7769
+ *
7770
+ * Neither is sufficient, and when they disagree the disagreement is reported
7771
+ * rather than resolved — a ledger that silently overrode git would be lying
7772
+ * about the filesystem, and one that silently deferred to git would lose the
7773
+ * only party who can be asked to let go.
7774
+ *
7775
+ * **Lease liveness is derived from the hop stream, never stored as a flag.** The
7776
+ * digest is a cache of current state, and `leased`/`released` hops are the
7777
+ * authority for whether a claim is still held — the same shape as
7778
+ * `pendingTargets`, which derives the durable mailbox from hops rather than
7779
+ * recording it. The binding itself *does* persist in the digest after release,
7780
+ * because a working tree kept on disk must still be traceable back to the report
7781
+ * that made it.
7782
+ *
7783
+ * @module dsh-report-ledger/report/lease
7784
+ */
7785
+ /**
7786
+ * Derive lease liveness from the authoritative hop stream.
7787
+ * @param hops - a report's hops, oldest first.
7788
+ * @returns the current lease state.
7789
+ */
7790
+ function leaseState(hops) {
7791
+ let state = "none";
7792
+ for (const hop of hops) if (hop.action === "leased") state = "held";
7793
+ else if (hop.action === "released") state = "released";
7794
+ else if (hop.action === "reclaimed") state = "reclaimed";
7795
+ return state;
7796
+ }
7797
+ /**
7798
+ * Whether two bindings name the same claim.
7799
+ *
7800
+ * A branch, when both sides name one, is the claim's identity: two reports bound
7801
+ * to the same branch are claiming one thing however differently they spell the
7802
+ * path. With no branch on one side there is nothing but the path to compare,
7803
+ * which is what a detached tree has instead of a name.
7804
+ * @param left - one binding.
7805
+ * @param right - another binding.
7806
+ * @returns true when they are the same claim.
7807
+ */
7808
+ function sameClaim(left, right) {
7809
+ if (left.branch !== void 0 && right.branch !== void 0) return left.branch === right.branch;
7810
+ return samePath(left.path, right.path);
7811
+ }
7812
+ /** The claimed name in prose: the branch when there is one, else the path. */
7813
+ function claimName(binding) {
7814
+ return binding.branch ?? `${binding.path} (detached)`;
7815
+ }
7816
+ /**
7817
+ * Decide whether a claim may be taken.
7818
+ *
7819
+ * Git is consulted before the ledger on purpose. Accusing a colleague of holding
7820
+ * something that turns out not to exist is a worse answer than "there is no such
7821
+ * checkout", and the ledger layer is only meaningful once the thing being
7822
+ * claimed is real.
7823
+ * @param query - the claim and the authorities to check it against.
7824
+ * @returns the refusal, or `undefined` when the claim may be taken.
7825
+ * @throws when git cannot be asked at all.
7826
+ */
7827
+ async function evaluateLease(query) {
7828
+ const { binding, repo } = query;
7829
+ const refuse = (refusal) => ({
7830
+ kind: "refused",
7831
+ refusal
7832
+ });
7833
+ if (repo === void 0) return refuse({
7834
+ kind: "no-repository",
7835
+ message: `${binding.path} is not inside a git repository, so there is no working tree to claim. Bind a checkout that exists, or let the ledger allocate one.`
7836
+ });
7837
+ const entries = await listWorktrees(repo);
7838
+ const entry = entries.find((candidate) => samePath(candidate.path, binding.path));
7839
+ if (entry === void 0) return refuse({
7840
+ kind: "not-a-worktree",
7841
+ message: `${binding.path} is not a working tree of ${repo.commonDir}. An existing checkout can be listed with git worktree list.`
7842
+ });
7843
+ if (entry.prunable) return refuse({
7844
+ kind: "not-a-worktree",
7845
+ message: `${binding.path} is registered but its working tree is gone, so git reports it prunable${entry.prunableReason === void 0 ? "" : ` (${entry.prunableReason})`}. If it was moved by hand, run git worktree repair from the new location; otherwise remove the stale entry first.`
7846
+ });
7847
+ if (binding.branch !== void 0) {
7848
+ const actual = entry.branch;
7849
+ if (actual !== binding.branch) return refuse({
7850
+ kind: "branch-mismatch",
7851
+ message: `${binding.path} is on ${actual === void 0 ? "a detached HEAD" : `'${actual}'`}, not '${binding.branch}', so the binding no longer describes the checkout.`
7852
+ });
7853
+ const holder = branchHolders(entries).get(binding.branch);
7854
+ if (holder !== void 0 && !samePath(holder.path, binding.path)) return refuse({
7855
+ kind: "checked-out",
7856
+ heldAt: holder.path,
7857
+ message: `'${binding.branch}' is checked out at ${holder.path}, and a branch is held by exactly one working tree. Claim ${holder.path} instead, or use a different branch.`
7858
+ });
7859
+ }
7860
+ for (const front of query.digests) {
7861
+ if (front.report === query.report) continue;
7862
+ if (front.worktree === void 0) continue;
7863
+ if (!sameClaim(front.worktree, binding)) continue;
7864
+ if (front.status === "closed") continue;
7865
+ if (leaseState(await query.hopsOf(front.report)) !== "held") continue;
7866
+ const holders = [.../* @__PURE__ */ new Set([front.from, ...front.authors])];
7867
+ return refuse({
7868
+ kind: "held-by-report",
7869
+ heldBy: front.report,
7870
+ heldByActor: front.from,
7871
+ message: `${front.report} (opened by ${front.from}) already holds ${claimName(binding)}. A claim is held by one report at a time. Ask it to release with report_release, or to close — closing releases the claim. Its authors are ${holders.join(", ")}.`
7872
+ });
7873
+ }
7874
+ return {
7875
+ kind: "granted",
7876
+ binding: {
7877
+ path: binding.path,
7878
+ ...entry.branch === void 0 ? {} : { branch: entry.branch },
7879
+ ...entry.head === void 0 ? {} : { head: entry.head },
7880
+ repo: repo.commonDir,
7881
+ ...binding.allocated === void 0 ? {} : { allocated: binding.allocated }
7882
+ }
7883
+ };
7884
+ }
7885
+ /** Longest slug kept in a branch and directory name. */
7886
+ const SLUG_LIMIT = 40;
7887
+ /**
7888
+ * Reduce a subject to a ref-safe slug.
7889
+ *
7890
+ * The output alphabet is `[a-z0-9-]`, a subset of what git accepts in a ref
7891
+ * name, so nothing needs escaping afterwards. That narrowness is the point: git
7892
+ * forbids `~^:?*[`, `..`, `@{`, spaces, control characters, and a leading or
7893
+ * trailing `.`, and a slug that can only hold lower-case letters, digits and
7894
+ * interior hyphens cannot spell any of them. A subject in a non-Latin script
7895
+ * reduces to nothing, which is why a name without a slug exists too.
7896
+ * @param subject - the report subject.
7897
+ * @returns the slug, possibly empty.
7898
+ */
7899
+ function slugify(subject) {
7900
+ return subject.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, SLUG_LIMIT).replace(/-+$/g, "");
7901
+ }
7902
+ /**
7903
+ * The name a report's checkout goes by, in both its branch and its directory.
7904
+ *
7905
+ * A report id is always part of it, so a checkout traces back to its report from
7906
+ * `git branch` alone, and the report id is recoverable from a directory listing
7907
+ * without consulting the ledger.
7908
+ * @param report - report identifier.
7909
+ * @param subject - the report subject, for the readable suffix.
7910
+ * @returns the name.
7911
+ */
7912
+ function worktreeName(report, subject) {
7913
+ const slug = slugify(subject);
7914
+ return slug === "" ? report : `${report}-${slug}`;
7915
+ }
7916
+ /**
7917
+ * The branch a report's checkout is on.
7918
+ * @param report - report identifier.
7919
+ * @param subject - the report subject.
7920
+ * @returns the branch name.
7921
+ */
7922
+ function worktreeBranch(report, subject) {
7923
+ return `dsh/${worktreeName(report, subject)}`;
7924
+ }
7925
+ /**
7926
+ * Where a report's checkout is created.
7927
+ *
7928
+ * The repository name is always a path component, so two repositories sharing a
7929
+ * parent — the ordinary case for anyone with several projects — cannot allocate
7930
+ * into each other's directory.
7931
+ * @param repo - the repository.
7932
+ * @param report - report identifier.
7933
+ * @param subject - the report subject.
7934
+ * @param base - base directory override, when configured.
7935
+ * @returns the absolute path.
7936
+ */
7937
+ function worktreePath(repo, report, subject, base) {
7938
+ const name = worktreeName(report, subject);
7939
+ return base === void 0 ? join(repo.worktreeRoot, name) : join(base, repo.repoName, name);
7940
+ }
7941
+ /**
7942
+ * Fill in the defaults for working-tree support.
7943
+ * @param config - the plugin configuration, when one was supplied.
7944
+ * @returns the resolved runtime.
7945
+ */
7946
+ function resolveWorktree(config) {
7947
+ return {
7948
+ enabled: config?.enabled === true,
7949
+ settings: {
7950
+ maxActive: config?.maxActive ?? 4,
7951
+ maxRetained: config?.maxRetained ?? 4,
7952
+ reclaim: config?.reclaim ?? "never",
7953
+ relativePaths: config?.relativePaths === true,
7954
+ ...config?.root === void 0 ? {} : { base: config.root }
7955
+ },
7956
+ onClose: config?.onClose ?? "keep"
7957
+ };
7958
+ }
7959
+ /** When a claim ended, from the hop that ended it. */
7960
+ function lastReleaseAt(hops) {
7961
+ let at = 0;
7962
+ for (const hop of hops) if (hop.action === "released") at = hop.at;
7963
+ return at;
7964
+ }
7965
+ /**
7966
+ * The message shown when the active budget is full.
7967
+ *
7968
+ * "Wait" is useless advice unless it says for whom, and the ledger already knows
7969
+ * both the holders and whether each is around — so the refusal names them, and
7970
+ * names the two ways out that do not involve waiting.
7971
+ * @param held - the reports currently holding a checkout.
7972
+ * @param limit - the configured cap.
7973
+ * @returns the refusal message.
7974
+ */
7975
+ function budgetMessage(held, limit) {
7976
+ const holders = held.map((holder) => `${holder.report} -> ${holder.actor} (${holder.live ? "live" : "absent"})`).join(", ");
7977
+ return `the working-tree budget is full (${held.length} of ${limit} held). Held by: ${holders}. Wait for one of these to finish, hand one back with report_release, or close its report — closing releases the claim.`;
7978
+ }
7979
+ /**
7980
+ * Decide what to allocate, or refuse.
7981
+ *
7982
+ * One pass over the ledger yields both answers: which checkouts are held now,
7983
+ * and which ended claims left one behind. The budget is then checked against the
7984
+ * held set alone and refuses without an override; reclamation runs afterwards,
7985
+ * for the disk limit, and can only reach checkouts whose claim already ended.
7986
+ * @param query - the allocation and the ledger it must fit into.
7987
+ * @param settings - the resolved settings.
7988
+ * @returns the plan, or the refusal.
7989
+ */
7990
+ async function planAllocation(query, settings) {
7991
+ if (hasSubmodules(query.repo)) return {
7992
+ kind: "submodules",
7993
+ message: `${query.repo.repoRoot} uses submodules (it has a .gitmodules file). Git does not recommend multiple checkouts of a superproject, and a linked worktree containing submodules cannot be moved, so the ledger will not allocate one here.`
7994
+ };
7995
+ const held = [];
7996
+ const reclaimable = [];
7997
+ const stale = [];
7998
+ for (const front of query.digests) {
7999
+ if (front.report === query.report) continue;
8000
+ const binding = front.worktree;
8001
+ if (binding === void 0) continue;
8002
+ const hops = await query.hopsOf(front.report);
8003
+ const state = leaseState(hops);
8004
+ if (state === "held") {
8005
+ held.push({
8006
+ report: front.report,
8007
+ actor: front.from,
8008
+ live: query.isLive(front.from)
8009
+ });
8010
+ continue;
8011
+ }
8012
+ if (state !== "released" || front.status !== "closed" || binding.allocated !== true) continue;
8013
+ if (!existsSync(binding.path)) {
8014
+ stale.push({
8015
+ report: front.report,
8016
+ path: binding.path
8017
+ });
8018
+ continue;
8019
+ }
8020
+ reclaimable.push({
8021
+ report: front.report,
8022
+ path: binding.path,
8023
+ ...binding.branch === void 0 ? {} : { branch: binding.branch },
8024
+ releasedAt: lastReleaseAt(hops)
8025
+ });
8026
+ }
8027
+ if (held.length >= settings.maxActive) return {
8028
+ kind: "budget",
8029
+ message: budgetMessage(held, settings.maxActive)
8030
+ };
8031
+ reclaimable.sort((left, right) => left.releasedAt - right.releasedAt || left.report.localeCompare(right.report));
8032
+ let reclaimed;
8033
+ const reclaimSkipped = [];
8034
+ if (settings.reclaim === "oldest-closed" && reclaimable.length > settings.maxRetained) for (const candidate of reclaimable) {
8035
+ if (!await isWorktreeClean(candidate.path)) {
8036
+ reclaimSkipped.push(`${candidate.report} (uncommitted changes at ${candidate.path})`);
8037
+ continue;
8038
+ }
8039
+ reclaimed = {
8040
+ report: candidate.report,
8041
+ path: candidate.path,
8042
+ ...candidate.branch === void 0 ? {} : { branch: candidate.branch }
8043
+ };
8044
+ break;
8045
+ }
8046
+ return {
8047
+ binding: {
8048
+ path: worktreePath(query.repo, query.report, query.subject, settings.base),
8049
+ branch: worktreeBranch(query.report, query.subject),
8050
+ repo: query.repo.commonDir,
8051
+ allocated: true
8052
+ },
8053
+ retained: reclaimable.length - (reclaimed === void 0 ? 0 : 1),
8054
+ ...reclaimed === void 0 ? {} : { reclaimed },
8055
+ reclaimSkipped,
8056
+ stale
8057
+ };
8058
+ }
8059
+ //#endregion
8060
+ //#region src/report/maintenance.ts
8061
+ /**
8062
+ * The maintenance view: what the ledger thinks it owns, and what is true of it.
8063
+ *
8064
+ * This exists because of an asymmetry the design accepts deliberately. A working
8065
+ * tree is not a first-class object in the ledger — it is a field on a report plus
8066
+ * a directory on disk — so there is no index to consult when an operator asks
8067
+ * "what checkouts are out there, and is any of it broken?". The answer has to be
8068
+ * assembled from the reports and from git, every time, and that is what this
8069
+ * module does.
8070
+ *
8071
+ * It **reports and never repairs**. Nothing here removes, prunes or reclaims: a
8072
+ * checkout moved by hand still works from its own side while git calls it
8073
+ * dangling, so an automatic prune destroys live work, and the whole point of a
8074
+ * maintenance view is to let a human decide with the facts in front of them.
8075
+ *
8076
+ * @module dsh-report-ledger/report/maintenance
8077
+ */
8078
+ /**
8079
+ * Assemble the maintenance view.
8080
+ *
8081
+ * Two passes, because they cost different things: the first asks the ledger what
8082
+ * it believes (cheap), the second asks git what is true (one `worktree list` per
8083
+ * repository, plus one `status` per checkout that is actually on disk).
8084
+ * @param deps - the ledger readers.
8085
+ * @param settings - the configured limits.
8086
+ * @param enabled - whether allocation is on in this deployment.
8087
+ * @returns the view.
8088
+ */
8089
+ async function buildWorktreeReport(deps, settings, enabled) {
8090
+ const digests = await deps.listReports();
8091
+ const records = [];
8092
+ for (const front of digests) {
8093
+ const binding = front.worktree;
8094
+ if (binding === void 0) continue;
8095
+ const onDisk = existsSync(binding.path);
8096
+ records.push({
8097
+ report: front.report,
8098
+ subject: front.subject,
8099
+ status: front.status,
8100
+ path: binding.path,
8101
+ ...binding.branch === void 0 ? {} : { branch: binding.branch },
8102
+ ...binding.head === void 0 ? {} : { head: binding.head },
8103
+ ...binding.repo === void 0 ? {} : { repo: binding.repo },
8104
+ allocated: binding.allocated === true,
8105
+ state: leaseState(await deps.readHops(front.report)),
8106
+ onDisk,
8107
+ ...onDisk ? { clean: await isWorktreeClean(binding.path) } : {},
8108
+ prunable: false
8109
+ });
8110
+ }
8111
+ const byRepo = /* @__PURE__ */ new Map();
8112
+ for (const record of records) {
8113
+ const key = record.repo ?? record.path;
8114
+ const bucket = byRepo.get(key);
8115
+ if (bucket === void 0) byRepo.set(key, [record]);
8116
+ else bucket.push(record);
8117
+ }
8118
+ const warnings = [];
8119
+ const unmanaged = [];
8120
+ const dangling = [];
8121
+ const prunable = /* @__PURE__ */ new Set();
8122
+ for (const bucket of byRepo.values()) {
8123
+ const first = bucket[0];
8124
+ if (first === void 0) continue;
8125
+ const repo = await resolveRepo(first.path) ?? (first.repo === void 0 ? void 0 : await resolveRepo(first.repo));
8126
+ if (repo === void 0) {
8127
+ warnings.push(`the repository holding ${bucket.map((record) => record.report).join(", ")} could not be resolved, so those checkouts were not inspected`);
8128
+ continue;
8129
+ }
8130
+ let entries;
8131
+ try {
8132
+ entries = await listWorktrees(repo);
8133
+ } catch (error) {
8134
+ warnings.push(`git could not list the working trees of ${repo.commonDir}: ${error instanceof Error ? error.message : String(error)}`);
8135
+ continue;
8136
+ }
8137
+ for (const entry of entries) if (entry.prunable) {
8138
+ prunable.add(entry.path);
8139
+ dangling.push(entry.path);
8140
+ }
8141
+ for (const entry of entries) {
8142
+ if (entry.bare || entry.prunable || samePath(entry.path, repo.repoRoot)) continue;
8143
+ if (records.some((record) => samePath(record.path, entry.path))) continue;
8144
+ unmanaged.push(entry.path);
8145
+ }
8146
+ }
8147
+ const resolved = records.map((record) => prunable.has(record.path) ? {
8148
+ ...record,
8149
+ prunable: true
8150
+ } : record);
8151
+ const active = resolved.filter((record) => record.state === "held").length;
8152
+ const retained = resolved.filter((record) => record.state === "released" && record.onDisk).length;
8153
+ for (const record of resolved) {
8154
+ if (record.state === "held" && !record.onDisk) warnings.push(`${record.report} holds a claim on ${record.path}, and that checkout is no longer on disk. Release the claim, or close the report — closing releases it.`);
8155
+ if (record.state === "released" && !record.onDisk) warnings.push(`${record.report}'s bound checkout at ${record.path} is gone, and the ledger still records the claim as released rather than reclaimed. The next allocation will note that it is gone.`);
8156
+ if (record.clean === false && record.state !== "held") warnings.push(`the kept checkout at ${record.path} (${record.report}) has uncommitted work, so it will never be reclaimed automatically.`);
8157
+ }
8158
+ if (retained > settings.maxRetained && settings.reclaim === "never") warnings.push(`${retained} checkouts are kept on disk past the retained limit of ${settings.maxRetained}, and automatic reclamation is off, so they will accumulate. Close reports with worktree:"remove-if-clean", or set worktree.reclaim to "oldest-closed".`);
8159
+ const uniqueDangling = [...new Set(dangling)].sort();
8160
+ for (const path of uniqueDangling) warnings.push(`git reports the worktree entry at ${path} as dangling. If it was moved by hand, run git worktree repair from the new location; otherwise prune the stale entry.`);
8161
+ for (const path of unmanaged) warnings.push(`git has a working tree no report is bound to: ${path}. A report can adopt it with report_lease.`);
8162
+ resolved.sort((left, right) => left.report.localeCompare(right.report));
8163
+ return {
8164
+ generatedAt: Date.now(),
8165
+ enabled,
8166
+ limits: {
8167
+ maxActive: settings.maxActive,
8168
+ maxRetained: settings.maxRetained,
8169
+ reclaim: settings.reclaim
8170
+ },
8171
+ active,
8172
+ retained,
8173
+ records: resolved,
8174
+ unmanaged: [...new Set(unmanaged)].sort(),
8175
+ dangling: uniqueDangling,
8176
+ warnings
8177
+ };
8178
+ }
7283
8179
  //#endregion
7284
8180
  //#region src/report/roster.ts
7285
8181
  /**
@@ -7529,6 +8425,8 @@ function holdsChannelTo(input, target) {
7529
8425
  if (byId.get(target)?.header.parentSession === input.self) return true;
7530
8426
  return input.peers.some((peer) => peer.startedBy === input.self && peer.sessionId === target);
7531
8427
  }
8428
+ /** How long a caller waits for evidence about a peer's first turn, by default. */
8429
+ const DEFAULT_RECEIPT_MS = 1500;
7532
8430
  /** The peer service. */
7533
8431
  var PeerService = class {
7534
8432
  #host;
@@ -7602,6 +8500,19 @@ var PeerService = class {
7602
8500
  return holdsChannelTo(await this.#inputs(self), target);
7603
8501
  }
7604
8502
  /**
8503
+ * Observe what became of a peer's first turn.
8504
+ *
8505
+ * Not a status check: it is the honest answer to "did the thing I just handed
8506
+ * this peer actually start working?" — the question a bare `delivered` cannot
8507
+ * answer. Call it after the assignment report has been delivered.
8508
+ * @param sessionId - the peer session to observe.
8509
+ * @param timeoutMs - how long a still-running turn is awaited before being reported as running.
8510
+ * @returns the observation, or `undefined` when the peer is not resident.
8511
+ */
8512
+ async receipt(sessionId, timeoutMs = DEFAULT_RECEIPT_MS) {
8513
+ return await this.#host.confirmPeer(sessionId, timeoutMs);
8514
+ }
8515
+ /**
7605
8516
  * Open one independent peer session.
7606
8517
  *
7607
8518
  * The peer is recorded before it is returned, so the relationship survives even
@@ -7619,10 +8530,11 @@ var PeerService = class {
7619
8530
  if (name !== void 0 && name.length > 60) throw new Error("report-ledger: peer_start name must be 60 characters or fewer");
7620
8531
  const already = await this.startedCount(self);
7621
8532
  if (already >= this.#maxPeers) throw new Error(`report-ledger: this session already started ${already} peers (limit ${this.#maxPeers}); reuse an existing peer, or report to one you already hold a channel to instead of opening another`);
7622
- const cwd = this.#host.selfCwd(self);
8533
+ const cwd = input.cwd ?? this.#host.selfCwd(self);
7623
8534
  const sessionId = `session-${randomUUID()}`;
7624
8535
  const created = await this.#host.createPeer({
7625
8536
  sessionId,
8537
+ startedBy: self,
7626
8538
  ...cwd === void 0 ? {} : { cwd }
7627
8539
  });
7628
8540
  await appendPeer({
@@ -7646,6 +8558,14 @@ var PeerService = class {
7646
8558
  const TIMELINE_PATH = "/api/report-ledger/timeline";
7647
8559
  /** Path of one report's detail. */
7648
8560
  const REPORT_PATH = "/api/report-ledger/report";
8561
+ /**
8562
+ * Path of the working-tree maintenance view.
8563
+ *
8564
+ * Registered whatever the feature's state, so an operator asking "is anything
8565
+ * out there?" gets an answer even when the answer is "nothing, and allocation is
8566
+ * off". A 404 would be indistinguishable from a typo.
8567
+ */
8568
+ const WORKTREES_PATH = "/api/report-ledger/worktrees";
7649
8569
  /** Longest accepted root session id. */
7650
8570
  const MAX_ID_LENGTH = 200;
7651
8571
  /** Conservative session-id shape: no separators that could escape a query. */
@@ -7773,6 +8693,23 @@ function createTimelineHandler(deps) {
7773
8693
  });
7774
8694
  }
7775
8695
  /**
8696
+ * Build the working-tree maintenance handler.
8697
+ *
8698
+ * Read-only and unparameterised: it answers one question — what checkouts does
8699
+ * the ledger know about, and what is true of them — with no way to change any of
8700
+ * it. Repair stays a human decision made from these facts.
8701
+ * @param deps - data sources.
8702
+ * @returns the route handler.
8703
+ */
8704
+ function createWorktreesHandler(deps) {
8705
+ return guarded(async (_url, response) => {
8706
+ writeJson(response, 200, {
8707
+ ok: true,
8708
+ value: await deps.worktrees()
8709
+ });
8710
+ });
8711
+ }
8712
+ /**
7776
8713
  * Build the single-report handler.
7777
8714
  * @param deps - data sources.
7778
8715
  * @returns the route handler.
@@ -7820,8 +8757,21 @@ function createReportHandler(deps) {
7820
8757
  const BODY_INLINE_LIMIT = 8e3;
7821
8758
  /** How many characters of a body are shown when it exceeds the inline limit. */
7822
8759
  const BODY_HEAD_LIMIT = 1200;
7823
- /** Serialize one report for model-facing output. */
7824
- function renderFrontLine(front) {
8760
+ /**
8761
+ * One token naming a bound working tree.
8762
+ *
8763
+ * The branch is the lease's identity, so it is what a listing shows; a detached
8764
+ * tree has none, and there the directory name is the only stable handle. The
8765
+ * full path stays in the digest for anyone who needs to act on it.
8766
+ * @param worktree - the binding.
8767
+ * @returns the label.
8768
+ */
8769
+ function worktreeLabel(worktree) {
8770
+ if (worktree.branch !== void 0) return worktree.branch;
8771
+ return basename(worktree.path) || worktree.path;
8772
+ }
8773
+ /** Serialize one report for model-facing output. */
8774
+ function renderFrontLine(front) {
7825
8775
  const parts = [
7826
8776
  `${front.report} [${front.status}] ${front.subject}`,
7827
8777
  `from=${front.from}`,
@@ -7832,6 +8782,7 @@ function renderFrontLine(front) {
7832
8782
  `updated=${new Date(front.updated).toISOString()}`
7833
8783
  ];
7834
8784
  if (front.task !== void 0) parts.push(`task=${front.task}`);
8785
+ if (front.worktree !== void 0) parts.push(`worktree=${worktreeLabel(front.worktree)}@${front.worktree.path}`);
7835
8786
  if (front.parent !== void 0) parts.push(`parent=${front.parent}`);
7836
8787
  if (front.children.length > 0) parts.push(`children=${front.children.join(",")}`);
7837
8788
  if (front.last !== void 0) parts.push(`last=${front.last.action}@${front.last.actor}`);
@@ -7871,31 +8822,55 @@ var ReportService = class ReportService {
7871
8822
  #host;
7872
8823
  #locks = /* @__PURE__ */ new Map();
7873
8824
  /**
8825
+ * Mutexes keyed by shared git directory, for decisions that span reports.
8826
+ *
8827
+ * A per-report mutex cannot serialize two *different* reports racing for one
8828
+ * branch, and claiming a working tree is exactly that decision. The key is the
8829
+ * repository rather than a checkout, because a branch is held by a repository
8830
+ * — keying by path would leave two checkouts of one repository mutually
8831
+ * invisible, which is the race the lock exists to stop. Their order is fixed;
8832
+ * see {@link ReportService.lease}.
8833
+ */
8834
+ #repoLocks = /* @__PURE__ */ new Map();
8835
+ /**
7874
8836
  * @param host - host capabilities (agent lookup, display names).
7875
8837
  */
7876
8838
  constructor(host) {
7877
8839
  this.#host = host;
7878
8840
  }
8841
+ /** Run one task under a key's mutex. */
8842
+ async #serialize(map, key, work) {
8843
+ const next = (map.get(key) ?? Promise.resolve()).then(work, work);
8844
+ map.set(key, next.then(() => void 0, () => void 0));
8845
+ return next;
8846
+ }
7879
8847
  /** Run one mutation under the report's mutex. */
7880
8848
  async #withLock(report, work) {
7881
- const next = (this.#locks.get(report) ?? Promise.resolve()).then(work, work);
7882
- this.#locks.set(report, next.then(() => void 0, () => void 0));
7883
- return next;
8849
+ return await this.#serialize(this.#locks, report, work);
8850
+ }
8851
+ /** Run one decision under the repository's mutex. */
8852
+ async #withRepoLock(key, work) {
8853
+ return await this.#serialize(this.#repoLocks, key, work);
7884
8854
  }
7885
8855
  /** Append one hop, then refresh the derived digest fields. */
7886
- async #record(report, hop) {
8856
+ async #record(report, hop, options = {}) {
7887
8857
  await appendHop(report, hop);
7888
- return this.#sync(report);
8858
+ return await this.#sync(report, options);
7889
8859
  }
7890
- /** Recompute the derived front-matter fields from the hop stream. */
7891
- async #sync(report) {
8860
+ /**
8861
+ * Recompute the derived front-matter fields from the hop stream.
8862
+ * @param report - report identifier.
8863
+ * @param options - `touch: false` keeps the digest's `updated` stamp, for an
8864
+ * administrative hop that must not make a concluded report look active again.
8865
+ */
8866
+ async #sync(report, options = {}) {
7892
8867
  const current = await readReport(report);
7893
8868
  if (current === void 0) throw new Error(`report-ledger: report ${report} does not exist`);
7894
8869
  const projected = projectRoute(await readHops(report));
7895
8870
  const front = {
7896
8871
  ...current.front,
7897
8872
  ...projected,
7898
- updated: Date.now()
8873
+ updated: options.touch === false ? current.front.updated : Date.now()
7899
8874
  };
7900
8875
  await writeReport({
7901
8876
  front,
@@ -7933,7 +8908,8 @@ var ReportService = class ReportService {
7933
8908
  async author(input) {
7934
8909
  const subject = input.subject.trim();
7935
8910
  if (subject === "") throw new Error("report-ledger: report_author requires a non-empty subject");
7936
- const report = await allocateId();
8911
+ const report = input.reportId ?? await allocateId();
8912
+ if (!isValidReportId(report)) throw new Error(`report-ledger: ${JSON.stringify(report)} is not a usable report id (expected the form R-0001)`);
7937
8913
  const now = Date.now();
7938
8914
  const fromName = await this.#host.describe(input.actor);
7939
8915
  await writeReport({
@@ -7952,7 +8928,8 @@ var ReportService = class ReportService {
7952
8928
  artifacts: [...input.artifacts ?? []],
7953
8929
  hops: 0,
7954
8930
  ...input.parent === void 0 ? {} : { parent: input.parent },
7955
- ...input.task === void 0 ? {} : { task: input.task }
8931
+ ...input.task === void 0 ? {} : { task: input.task },
8932
+ ...input.worktree === void 0 ? {} : { worktree: input.worktree }
7956
8933
  },
7957
8934
  body: input.body
7958
8935
  });
@@ -7962,6 +8939,13 @@ var ReportService = class ReportService {
7962
8939
  action: "authored",
7963
8940
  to: []
7964
8941
  });
8942
+ if (input.worktree !== void 0) await this.#record(report, {
8943
+ at: Date.now(),
8944
+ actor: input.actor,
8945
+ action: "leased",
8946
+ to: [],
8947
+ note: `${input.worktree.branch ?? "(detached HEAD)"} @ ${input.worktree.path}`
8948
+ });
7965
8949
  if (input.parent !== void 0) {
7966
8950
  if (await readReport(input.parent) !== void 0) await this.#withLock(input.parent, async () => {
7967
8951
  const latest = await this.#require(input.parent);
@@ -8015,6 +8999,27 @@ var ReportService = class ReportService {
8015
8999
  return actor === front.from || front.authors.includes(actor);
8016
9000
  }
8017
9001
  /**
9002
+ * Throw unless the actor owns the report.
9003
+ *
9004
+ * Correcting, concluding and claiming a checkout are all the owner's acts, and
9005
+ * they are refused the same way — by naming who may do it instead. A refusal
9006
+ * that only says "no" makes the model guess; naming the authors turns it into
9007
+ * a hand-off.
9008
+ *
9009
+ * The gerund is passed rather than derived, because the derivation is wrong:
9010
+ * `close` + `ing` is not a word, and a refusal that reads like a bug invites a
9011
+ * model to route around it.
9012
+ * @param front - the report's digest.
9013
+ * @param actor - the session attempting the act.
9014
+ * @param verb - the act, in the imperative, for the sentence.
9015
+ * @param gerund - the same act as a noun phrase.
9016
+ */
9017
+ #assertOwner(front, actor, verb, gerund) {
9018
+ if (ReportService.owns(front, actor)) return;
9019
+ const owners = [.../* @__PURE__ */ new Set([front.from, ...front.authors])];
9020
+ throw new Error(`report-ledger: ${actor} may not ${verb} ${front.report} — ${gerund} is the owner's act, not a reader's. Ask one of its authors to do it: ${owners.join(", ")}`);
9021
+ }
9022
+ /**
8018
9023
  * Return a closed report to life because fresh activity arrived.
8019
9024
  *
8020
9025
  * `closed` is a state, not a lock. If someone contributes to, sends, copies, or
@@ -8181,8 +9186,11 @@ var ReportService = class ReportService {
8181
9186
  return ` ${new Date(hop.at).toISOString()} ${hop.action.padEnd(11)} by ${hop.actor}${targets}${note}`;
8182
9187
  }).join("\n");
8183
9188
  const pending = pendingTargets(hops);
9189
+ const worktree = found.front.worktree;
9190
+ const lease = worktree === void 0 ? [] : [`lease=${leaseState(hops)} ${worktree.branch ?? "(detached HEAD)"} @ ${worktree.path} (${worktree.allocated === true ? "allocated by the ledger" : "adopted"})`];
8184
9191
  return [
8185
9192
  renderFrontLine(found.front),
9193
+ ...lease,
8186
9194
  `body=${path}`,
8187
9195
  "",
8188
9196
  "## transfer path",
@@ -8202,9 +9210,14 @@ var ReportService = class ReportService {
8202
9210
  const all = await listReports();
8203
9211
  const session = filter.session;
8204
9212
  const task = filter.task?.trim().toLowerCase();
9213
+ const worktree = filter.worktree?.trim().toLowerCase();
8205
9214
  const matching = all.filter((front) => {
8206
9215
  if (filter.status !== void 0 && front.status !== filter.status) return false;
8207
9216
  if (task !== void 0 && task !== "" && !(front.task ?? "").toLowerCase().includes(task)) return false;
9217
+ if (worktree !== void 0 && worktree !== "") {
9218
+ if (front.worktree === void 0) return false;
9219
+ if (!`${front.worktree.branch ?? ""} ${front.worktree.path}`.toLowerCase().includes(worktree)) return false;
9220
+ }
8208
9221
  if (session === void 0) return true;
8209
9222
  return front.from === session || front.to.includes(session) || front.cc.includes(session) || front.authors.includes(session);
8210
9223
  });
@@ -8218,6 +9231,7 @@ var ReportService = class ReportService {
8218
9231
  cc: front.cc,
8219
9232
  hops: front.hops,
8220
9233
  ...front.task === void 0 ? {} : { task: front.task },
9234
+ ...front.worktree === void 0 ? {} : { worktree: worktreeLabel(front.worktree) },
8221
9235
  ...front.last === void 0 ? {} : { last: `${front.last.action}@${front.last.actor}` },
8222
9236
  updated: front.updated
8223
9237
  }));
@@ -8263,22 +9277,53 @@ var ReportService = class ReportService {
8263
9277
  * @param report - report identifier.
8264
9278
  * @param actor - the session concluding it.
8265
9279
  * @param note - optional note.
8266
- * @returns the refreshed digest.
9280
+ * @param options - what to do with a bound working tree on disk.
9281
+ * @returns the refreshed digest, and what happened to the working tree.
8267
9282
  * @throws when the actor is neither the originator nor a co-author.
8268
9283
  */
8269
- async close(report, actor, note) {
9284
+ async close(report, actor, note, options = {}) {
8270
9285
  return this.#withLock(report, async () => {
8271
9286
  const current = await this.#require(report);
8272
- if (!ReportService.owns(current.front, actor)) {
8273
- const owners = [.../* @__PURE__ */ new Set([current.front.from, ...current.front.authors])];
8274
- throw new Error(`report-ledger: ${actor} may not close ${report} — closing is the owner's act, not a reader's. Ask one of its authors to close it: ${owners.join(", ")}`);
9287
+ this.#assertOwner(current.front, actor, "close", "closing");
9288
+ const binding = current.front.worktree;
9289
+ if (binding !== void 0 && leaseState(await readHops(report)) === "held") await this.#record(report, {
9290
+ at: Date.now(),
9291
+ actor,
9292
+ action: "released",
9293
+ to: [],
9294
+ note: "released by closing the report"
9295
+ });
9296
+ let outcome;
9297
+ if (binding !== void 0) if (options.worktree !== "remove-if-clean") outcome = { action: "kept" };
9298
+ else {
9299
+ const repo = await resolveRepo(binding.path);
9300
+ if (repo === void 0) outcome = {
9301
+ action: "refused",
9302
+ detail: `${binding.path} is no longer inside a git repository`
9303
+ };
9304
+ else {
9305
+ const removed = await removeWorktree(repo, binding.path);
9306
+ outcome = removed.ok ? { action: "removed" } : {
9307
+ action: "refused",
9308
+ detail: removed.stderr.trim()
9309
+ };
9310
+ }
8275
9311
  }
9312
+ const disposition = outcome === void 0 ? void 0 : outcome.action === "removed" ? "working tree removed" : outcome.action === "kept" ? "working tree kept" : `working tree kept: ${outcome.detail ?? "removal refused"}`;
9313
+ if (outcome?.action === "removed") await this.#record(report, {
9314
+ at: Date.now(),
9315
+ actor,
9316
+ action: "reclaimed",
9317
+ to: [],
9318
+ note: `checkout ${binding?.path ?? ""} removed at the owner's request`
9319
+ });
9320
+ const closedNote = [note, disposition].filter((part) => part !== void 0 && part !== "").join("; ");
8276
9321
  await this.#record(report, {
8277
9322
  at: Date.now(),
8278
9323
  actor,
8279
9324
  action: "closed",
8280
9325
  to: [],
8281
- ...note === void 0 ? {} : { note }
9326
+ ...closedNote === "" ? {} : { note: closedNote }
8282
9327
  });
8283
9328
  const fresh = await this.#require(report);
8284
9329
  await writeReport({
@@ -8291,11 +9336,305 @@ var ReportService = class ReportService {
8291
9336
  });
8292
9337
  return {
8293
9338
  front: (await this.#require(report)).front,
9339
+ outcomes: [],
9340
+ ...outcome === void 0 ? {} : { worktree: outcome }
9341
+ };
9342
+ });
9343
+ }
9344
+ /**
9345
+ * Claim a working tree for a report.
9346
+ *
9347
+ * The decision runs under two mutexes, outer first: the working tree's,
9348
+ * because two reports racing for one branch is exactly what a per-report mutex
9349
+ * cannot see, and then the report's, because writing the digest still races
9350
+ * other mutations of the same report. Only this method nests them —
9351
+ * {@link ReportService.release} takes the report lock alone, since giving a
9352
+ * claim up needs no atomicity against the filesystem — so the order is fixed
9353
+ * and cannot form the cycle that would deadlock.
9354
+ *
9355
+ * Re-claiming is allowed and is how a binding is refreshed (a new head, a
9356
+ * corrected path): the report already holding it is skipped by the referee, so
9357
+ * refreshing needs no release first. It is still recorded, because the audit
9358
+ * is the product.
9359
+ * @param report - report identifier.
9360
+ * @param actor - the claiming session; must own the report.
9361
+ * @param binding - the working tree to claim.
9362
+ * @returns the refreshed digest.
9363
+ * @throws when git or the ledger refuses the claim.
9364
+ */
9365
+ async lease(report, actor, binding) {
9366
+ const repo = await resolveRepo(binding.path) ?? (binding.repo === void 0 ? void 0 : await resolveRepo(binding.repo));
9367
+ const key = compareKey(repo?.commonDir ?? binding.path);
9368
+ return await this.#withRepoLock(key, async () => await this.#withLock(report, async () => {
9369
+ const current = await this.#require(report);
9370
+ this.#assertOwner(current.front, actor, "lease", "leasing");
9371
+ const verdict = await evaluateLease({
9372
+ binding,
9373
+ repo,
9374
+ report,
9375
+ digests: await listReports(),
9376
+ hopsOf: async (id) => await readHops(id)
9377
+ });
9378
+ if (verdict.kind === "refused") throw new Error(`report-ledger: ${verdict.refusal.message}`);
9379
+ const claim = verdict.binding;
9380
+ await writeReport({
9381
+ front: {
9382
+ ...current.front,
9383
+ worktree: claim,
9384
+ updated: Date.now()
9385
+ },
9386
+ body: current.body
9387
+ });
9388
+ return {
9389
+ front: await this.#record(report, {
9390
+ at: Date.now(),
9391
+ actor,
9392
+ action: "leased",
9393
+ to: [],
9394
+ note: `${claim.branch ?? "(detached HEAD)"} @ ${claim.path}`
9395
+ }),
8294
9396
  outcomes: []
8295
9397
  };
9398
+ }));
9399
+ }
9400
+ /**
9401
+ * Give up a report's claim on its working tree, without touching the disk.
9402
+ *
9403
+ * The binding stays in the digest on purpose. A checkout kept on disk must
9404
+ * remain traceable to the report that made it — otherwise the ledger forgets
9405
+ * which working trees it is responsible for, and a later cleanup could not
9406
+ * tell its own from a stranger's. What ended is the claim, and the hop stream
9407
+ * is where that is recorded.
9408
+ * @param report - report identifier.
9409
+ * @param actor - the releasing session; must own the report.
9410
+ * @param note - optional note.
9411
+ * @returns the refreshed digest.
9412
+ * @throws when the report holds nothing, or the actor is not an owner.
9413
+ */
9414
+ async release(report, actor, note) {
9415
+ return await this.#withLock(report, async () => {
9416
+ const current = await this.#require(report);
9417
+ this.#assertOwner(current.front, actor, "release", "releasing");
9418
+ if (current.front.worktree === void 0) throw new Error(`report-ledger: ${report} is not bound to a working tree, so there is nothing to release`);
9419
+ if (leaseState(await readHops(report)) !== "held") throw new Error(`report-ledger: ${report} does not currently hold its working tree, so there is nothing to release (a claim is released once; closing a report releases it too)`);
9420
+ return {
9421
+ front: await this.#record(report, {
9422
+ at: Date.now(),
9423
+ actor,
9424
+ action: "released",
9425
+ to: [],
9426
+ ...note === void 0 ? {} : { note }
9427
+ }),
9428
+ outcomes: []
9429
+ };
9430
+ });
9431
+ }
9432
+ /**
9433
+ * Create the working tree a report is about to be given, and clear room for it.
9434
+ *
9435
+ * Separate from {@link ReportService.lease} because it runs *before* the
9436
+ * report exists: a working tree's name is derived from the report id, and the
9437
+ * caller needs the checkout's path to hand a peer its working directory. So
9438
+ * the order is claim the id, provision the checkout, then author — and this is
9439
+ * the middle step.
9440
+ *
9441
+ * Runs under the repository mutex, because the budget it consults and the
9442
+ * branch it takes are both repository-wide facts that two concurrent
9443
+ * allocations would otherwise read at the same moment.
9444
+ * @param input - what to provision, and for whom.
9445
+ * @returns the binding to record, plus what housekeeping happened.
9446
+ * @throws when a gate refuses the allocation or git cannot create the tree.
9447
+ */
9448
+ async prepareWorktree(input) {
9449
+ return await this.#withRepoLock(compareKey(input.repo.commonDir), async () => {
9450
+ const plan = await planAllocation({
9451
+ report: input.report,
9452
+ subject: input.subject,
9453
+ repo: input.repo,
9454
+ digests: await listReports(),
9455
+ hopsOf: async (id) => await readHops(id),
9456
+ isLive: (sessionId) => this.#host.getAgent(sessionId) !== void 0
9457
+ }, input.settings);
9458
+ if ("kind" in plan) throw new Error(`report-ledger: ${plan.message}`);
9459
+ for (const gone of plan.stale) await this.#recordVanished(input.actor, gone);
9460
+ if (plan.reclaimed !== void 0) await this.#reclaim(input.actor, plan.reclaimed);
9461
+ const created = await this.#createWorktree(input.repo, plan.binding, input.base, input.settings.relativePaths);
9462
+ if (!created.ok) throw new Error(`report-ledger: ${created.reason}`);
9463
+ const head = await headOf(plan.binding.path);
9464
+ return {
9465
+ binding: {
9466
+ ...plan.binding,
9467
+ ...head === void 0 ? {} : { head }
9468
+ },
9469
+ retained: plan.retained,
9470
+ reclaimSkipped: plan.reclaimSkipped,
9471
+ stale: plan.stale,
9472
+ ...plan.reclaimed === void 0 ? {} : { reclaimed: plan.reclaimed }
9473
+ };
9474
+ });
9475
+ }
9476
+ /**
9477
+ * Attach the linked working tree, recovering from a branch left by a crash.
9478
+ *
9479
+ * A previous attempt can have created the branch and then failed before the
9480
+ * checkout existed, which leaves `-b` permanently refused on that name. Falling
9481
+ * back to checking the existing branch out is what makes the retry work rather
9482
+ * than dead-end, and it cannot take a live claim: git still refuses a branch
9483
+ * another working tree holds.
9484
+ */
9485
+ async #createWorktree(repo, binding, base, relativePaths) {
9486
+ const first = await addWorktree({
9487
+ repo,
9488
+ path: binding.path,
9489
+ branch: binding.branch,
9490
+ base,
9491
+ relativePaths
9492
+ });
9493
+ if (first.ok) return { ok: true };
9494
+ if (classifyGitRefusal(first.stderr) !== "branch-exists") return {
9495
+ ok: false,
9496
+ reason: `could not create a working tree at ${binding.path}: ${first.stderr.trim()}`
9497
+ };
9498
+ const retry = await addWorktree({
9499
+ repo,
9500
+ path: binding.path,
9501
+ branch: binding.branch,
9502
+ checkoutExisting: true,
9503
+ relativePaths
9504
+ });
9505
+ if (retry.ok) return { ok: true };
9506
+ return {
9507
+ ok: false,
9508
+ reason: `the branch ${binding.branch} already existed and could not be checked out at ${binding.path}: ${retry.stderr.trim()}`
9509
+ };
9510
+ }
9511
+ /**
9512
+ * Record that a checkout the ledger believed it owned has already vanished.
9513
+ *
9514
+ * Nothing is removed — there is nothing left to remove — so this exists purely
9515
+ * because leaving the belief in place is not free: the binding would be
9516
+ * re-examined as a reclamation candidate on every later allocation, and a
9517
+ * candidate whose directory is missing fails the cleanliness check, so it
9518
+ * would be reported as having uncommitted changes. That sentence is wrong, and
9519
+ * it is the kind of wrong that sends a reader looking for work nobody left.
9520
+ *
9521
+ * The hop is administrative, recorded with `touch: false` for the same reason
9522
+ * a reclamation is: a vanished checkout is not a concluded report becoming
9523
+ * active again.
9524
+ */
9525
+ async #recordVanished(actor, gone) {
9526
+ await this.#withLock(gone.report, async () => {
9527
+ if (!reportExists(gone.report)) return;
9528
+ await this.#record(gone.report, {
9529
+ at: Date.now(),
9530
+ actor,
9531
+ action: "reclaimed",
9532
+ to: [],
9533
+ note: `checkout ${gone.path} was already gone; nothing to remove`
9534
+ }, { touch: false });
9535
+ });
9536
+ }
9537
+ /**
9538
+ * Clear a checkout whose claim already ended, and record that it happened.
9539
+ *
9540
+ * Removal goes through git without a force flag, so an unclean tree refuses
9541
+ * here exactly as it would for a human — and that refusal aborts the
9542
+ * allocation rather than being stepped over, because taking something away is
9543
+ * never the safe default.
9544
+ *
9545
+ * The hop is recorded with `touch: false`: clearing litter must not make a
9546
+ * concluded report look like it just became active and float it to the top of
9547
+ * every listing. It is still recorded, because taking something away from
9548
+ * another report is precisely the act an audit exists for.
9549
+ */
9550
+ async #reclaim(actor, target) {
9551
+ const repo = await resolveRepo(target.path);
9552
+ if (repo !== void 0) {
9553
+ const removed = await removeWorktree(repo, target.path);
9554
+ if (!removed.ok) throw new Error(`report-ledger: refusing to reclaim ${target.path} from ${target.report} — ${removed.stderr.trim()}`);
9555
+ }
9556
+ await this.#withLock(target.report, async () => {
9557
+ if (!reportExists(target.report)) return;
9558
+ await this.#record(target.report, {
9559
+ at: Date.now(),
9560
+ actor,
9561
+ action: "reclaimed",
9562
+ to: [],
9563
+ note: `checkout ${target.path} cleared to stay within the retained budget`
9564
+ }, { touch: false });
8296
9565
  });
8297
9566
  }
8298
9567
  /**
9568
+ * Undo a provisioning that a later step could not use.
9569
+ *
9570
+ * Deliberately without a force flag: a checkout that something has already
9571
+ * modified refuses to go, and it should — the failure that triggered the
9572
+ * rollback is not a licence to discard work. The caller is told whether the
9573
+ * cleanup happened rather than being left to assume it did.
9574
+ * @param binding - the binding whose checkout should go.
9575
+ * @returns true when the checkout was removed.
9576
+ */
9577
+ async discardWorktree(binding) {
9578
+ const repo = await resolveRepo(binding.path);
9579
+ if (repo === void 0) return false;
9580
+ return (await removeWorktree(repo, binding.path)).ok;
9581
+ }
9582
+ /**
9583
+ * Claim an id and provision a working tree for a report that does not exist yet.
9584
+ *
9585
+ * The two halves of a checkout's identity are circular: its branch and
9586
+ * directory are named after the report, and the caller needs its path *before*
9587
+ * the report is authored — a peer has to be created in it. This breaks the
9588
+ * circle by claiming the id first, and gives the claim back if provisioning
9589
+ * fails, so a failure does not burn a number.
9590
+ * @param input - what to provision, and for whom.
9591
+ * @returns the claimed id and the provisioned checkout.
9592
+ * @throws when the session has no directory, is not in a repository, is in one
9593
+ * with no commit, or a gate refuses the allocation.
9594
+ */
9595
+ async provision(input) {
9596
+ const cwd = this.#host.selfCwd?.(input.actor);
9597
+ if (cwd === void 0) throw new Error(`report-ledger: cannot allocate a working tree for ${input.actor} — the session has no working directory, so there is no repository to allocate one in`);
9598
+ const repo = await resolveRepo(cwd);
9599
+ if (repo === void 0) throw new Error(`report-ledger: ${cwd} is not inside a git repository, so there is no repository to allocate a working tree in`);
9600
+ const base = await headOf(cwd);
9601
+ if (base === void 0) throw new Error(`report-ledger: ${cwd} has no commit yet, so there is nothing to branch a working tree from`);
9602
+ const report = await allocateId();
9603
+ try {
9604
+ return {
9605
+ report,
9606
+ prepared: await this.prepareWorktree({
9607
+ report,
9608
+ subject: input.subject,
9609
+ actor: input.actor,
9610
+ repo,
9611
+ base,
9612
+ settings: input.settings
9613
+ })
9614
+ };
9615
+ } catch (error) {
9616
+ await discardUnwrittenId(report);
9617
+ throw error;
9618
+ }
9619
+ }
9620
+ /**
9621
+ * Undo a provisioning whose report never got authored.
9622
+ *
9623
+ * A step after provisioning can fail — a peer that cannot be created, an
9624
+ * authoring error — and without this the caller would leave a checkout and a
9625
+ * burned number behind. Both halves are best-effort and reported rather than
9626
+ * assumed: a checkout refuses to go once anything has touched it, and that
9627
+ * refusal is the correct outcome, not an error to swallow.
9628
+ * @param provisioned - what {@link ReportService.provision} returned.
9629
+ * @returns what was actually undone.
9630
+ */
9631
+ async rollbackProvision(provisioned) {
9632
+ return {
9633
+ worktree: await this.discardWorktree(provisioned.prepared.binding),
9634
+ id: await discardUnwrittenId(provisioned.report)
9635
+ };
9636
+ }
9637
+ /**
8299
9638
  * Correct a report: the digest fields and/or the body, as the owner's act.
8300
9639
  *
8301
9640
  * The tension this resolves is real. A ledger's value comes from history being
@@ -8419,11 +9758,49 @@ var ReportService = class ReportService {
8419
9758
  function renderRow(row) {
8420
9759
  const last = row.last === void 0 ? "-" : row.last;
8421
9760
  const task = row.task === void 0 ? "" : ` task=${row.task}`;
8422
- return `${row.report} [${row.status}] ${row.subject}${task} | from=${row.from} to=${row.to.length === 0 ? "-" : row.to.join(",")} cc=${row.cc.length === 0 ? "-" : row.cc.join(",")} hops=${row.hops} last=${last}`;
9761
+ const worktree = row.worktree === void 0 ? "" : ` wt=${row.worktree}`;
9762
+ return `${row.report} [${row.status}] ${row.subject}${task}${worktree} | from=${row.from} to=${row.to.length === 0 ? "-" : row.to.join(",")} cc=${row.cc.length === 0 ? "-" : row.cc.join(",")} hops=${row.hops} last=${last}`;
8423
9763
  }
8424
9764
  //#endregion
8425
9765
  //#region src/report/timeline.ts
8426
9766
  /**
9767
+ * Timeline assembly: the recursive session subtree plus the reports that touch it.
9768
+ *
9769
+ * Kept free of any harness object so the whole thing is unit-testable: the
9770
+ * caller adapts `ctx.sessionQuery` and the ledger into the small structural
9771
+ * shapes below, and this module turns them into the JSON the tab renders.
9772
+ *
9773
+ * Ordering is DFS pre-order with siblings sorted by creation time, which is both
9774
+ * hierarchical and chronological — a child is always created after its parent,
9775
+ * so "top to bottom" reads as the collaboration actually unfolded.
9776
+ *
9777
+ * @module dsh-report-ledger/report/timeline
9778
+ */
9779
+ /**
9780
+ * Collect the checkouts a set of reports is bound to.
9781
+ *
9782
+ * Only a report that carries a binding costs a hop read, so a ledger with no
9783
+ * working trees in play pays one pass over the digests and nothing more.
9784
+ * @param reports - the report digests in play.
9785
+ * @param readHops - read one report's hops.
9786
+ * @returns one lane per bound report.
9787
+ */
9788
+ async function worktreeLanes(reports, readHops) {
9789
+ const lanes = [];
9790
+ for (const front of reports) {
9791
+ const binding = front.worktree;
9792
+ if (binding === void 0) continue;
9793
+ lanes.push({
9794
+ report: front.report,
9795
+ path: binding.path,
9796
+ ...binding.branch === void 0 ? {} : { branch: binding.branch },
9797
+ allocated: binding.allocated === true,
9798
+ state: leaseState(await readHops(front.report))
9799
+ });
9800
+ }
9801
+ return lanes.sort((left, right) => (left.branch ?? left.path).localeCompare(right.branch ?? right.path) || left.report.localeCompare(right.report));
9802
+ }
9803
+ /**
8427
9804
  * Build the subtree rooted at one session.
8428
9805
  *
8429
9806
  * A record is included only when its parent chain reaches the root, so a session
@@ -8520,11 +9897,13 @@ async function buildTimeline(deps, root) {
8520
9897
  const bare = buildSubtree(await deps.listSessionRecords(), root);
8521
9898
  const sessions = withTitles(bare, bare.length === 0 ? /* @__PURE__ */ new Map() : await deps.readTitles(bare.map((node) => node.id)));
8522
9899
  const reports = reportsForSubtree(await deps.listReports(), sessions);
9900
+ const worktrees = await worktreeLanes(reports, deps.readHops);
8523
9901
  return {
8524
9902
  root,
8525
9903
  generatedAt: Date.now(),
8526
9904
  sessions,
8527
- reports
9905
+ reports,
9906
+ worktrees
8528
9907
  };
8529
9908
  }
8530
9909
  //#endregion
@@ -8539,6 +9918,57 @@ async function buildTimeline(deps, root) {
8539
9918
  *
8540
9919
  * @module dsh-report-ledger/report/tools
8541
9920
  */
9921
+ /**
9922
+ * The `worktree` parameter a tool advertises, or nothing at all.
9923
+ *
9924
+ * With the feature off the parameter does not exist, so a model cannot ask for
9925
+ * something the deployment will not do. That is a better answer than a parameter
9926
+ * that is always refused, and it keeps the promise that a deployment which does
9927
+ * not want working trees pays nothing for them.
9928
+ * @param runtime - the resolved feature state.
9929
+ * @param description - what the parameter does, for this tool.
9930
+ * @returns the parameter map to spread into a tool's parameters.
9931
+ */
9932
+ function worktreeParam(runtime, description) {
9933
+ return runtime.enabled ? { worktree: {
9934
+ type: "boolean",
9935
+ description
9936
+ } } : {};
9937
+ }
9938
+ /** Provision a checkout, or explain that this deployment does not offer one. */
9939
+ async function requireWorktree(runtime, service, input) {
9940
+ if (!runtime.enabled) throw new Error("report-ledger: this deployment has not enabled git worktree support, so no checkout can be allocated (the composition row sets worktree.enabled)");
9941
+ return await service.provision({
9942
+ subject: input.subject,
9943
+ actor: input.actor,
9944
+ settings: runtime.settings
9945
+ });
9946
+ }
9947
+ /**
9948
+ * The lines describing a checkout that was just provisioned.
9949
+ *
9950
+ * The sharing sentence is not boilerplate. "Its own checkout" reads naturally as
9951
+ * "its own environment", and the difference becomes real the moment an agent
9952
+ * installs a hook or changes a setting — both of which reach every other
9953
+ * checkout, because only the working directory and index are private.
9954
+ * @param prepared - what provisioning produced.
9955
+ * @returns the lines to append to the tool output.
9956
+ */
9957
+ function renderProvision(prepared) {
9958
+ const lines = [`worktree=${prepared.binding.path} branch=${prepared.binding.branch}`];
9959
+ if (prepared.reclaimed !== void 0) lines.push(`reclaimed ${prepared.reclaimed.report}'s checkout at ${prepared.reclaimed.path} to stay within the retained budget; that report remains closed`);
9960
+ for (const skipped of prepared.reclaimSkipped) lines.push(`kept a checkout the budget would otherwise have cleared: ${skipped}`);
9961
+ for (const gone of prepared.stale) lines.push(`recorded that ${gone.report}'s checkout at ${gone.path} was already gone — nothing to remove`);
9962
+ lines.push("private: working directory and index. shared: config, hooks, the object store, and everything outside the checkouts.");
9963
+ return lines;
9964
+ }
9965
+ /** The line describing what a mutation did with a checkout on disk. */
9966
+ function renderWorktreeOutcome(outcome, canRemove) {
9967
+ if (outcome === void 0) return void 0;
9968
+ if (outcome.action === "removed") return "working tree removed";
9969
+ if (outcome.action === "kept") return canRemove ? "working tree kept (close with worktree:\"remove-if-clean\" to remove it)" : "working tree kept";
9970
+ return `working tree NOT removed — git refused: ${outcome.detail ?? "unknown reason"}`;
9971
+ }
8542
9972
  /** Every tool's output is one compact text rendering of its canonical string value. */
8543
9973
  const stringOutput = {
8544
9974
  schema: { type: "string" },
@@ -8561,7 +9991,7 @@ function actorOf(exec) {
8561
9991
  return agent.id;
8562
9992
  }
8563
9993
  /** Render a mutation result as a compact model-facing report. */
8564
- function renderMutation(front, outcomes) {
9994
+ function renderMutation(front, outcomes, prepared) {
8565
9995
  const lines = [`${front.report} [${front.status}] ${front.subject}`, `authors=${front.authors.join(",")} to=${front.to.length === 0 ? "-" : front.to.join(",")} cc=${front.cc.length === 0 ? "-" : front.cc.join(",")} hops=${front.hops}`];
8566
9996
  if (outcomes.length > 0) {
8567
9997
  const delivered = outcomes.filter((outcome) => outcome.status === "delivered").map((outcome) => outcome.targetId);
@@ -8569,6 +9999,7 @@ function renderMutation(front, outcomes) {
8569
9999
  if (delivered.length > 0) lines.push(`delivered to: ${delivered.join(", ")}`);
8570
10000
  if (queued.length > 0) lines.push(`held for absent recipient (delivered automatically when it becomes resident): ${queued.join(", ")}`);
8571
10001
  }
10002
+ if (prepared !== void 0) lines.push(...renderProvision(prepared));
8572
10003
  return lines.join("\n");
8573
10004
  }
8574
10005
  /** Derive a concise report subject from a task description. */
@@ -8578,13 +10009,34 @@ function subjectFrom(task, name) {
8578
10009
  return name === void 0 ? trimmed : `${name}: ${trimmed}`;
8579
10010
  }
8580
10011
  /**
10012
+ * Render what was observed about a peer's first turn.
10013
+ *
10014
+ * A delivery certifies one thing only: the digest reached a live agent. It says
10015
+ * nothing about whether a turn opened, and a turn that dies at step 1 — an
10016
+ * unresolvable prompt variable, no model route, no composition — leaves exactly
10017
+ * the same `delivered` hop and the same `live` roster row as a peer that is hard
10018
+ * at work. This line is the difference between those two, and it exists because
10019
+ * the difference cost a real caller a long, silent wait.
10020
+ * @param receipt - the observation, or `undefined` when the host could not make one.
10021
+ * @param windowMs - the observation window the receipt was bounded by.
10022
+ * @returns one line for the tool output, or `undefined` when there is nothing to say.
10023
+ */
10024
+ function describeReceipt(receipt, windowMs) {
10025
+ if (receipt === void 0) return void 0;
10026
+ if (receipt.kind === "running") return `the peer's first turn is running (observed for ${windowMs} ms after the assignment landed)`;
10027
+ if (receipt.kind === "failed") return `⚠ the peer's first turn FAILED before doing any work: ${receipt.error ?? receipt.reason ?? "unknown error"}`;
10028
+ if (receipt.kind === "unstarted") return "⚠ the assignment is in the peer's inbox but no turn opened — nothing is working on it";
10029
+ if (receipt.reason === "completed") return `the peer's first turn completed within ${windowMs} ms — read the report for its answer`;
10030
+ return `the peer's first turn ended within ${windowMs} ms (${receipt.reason ?? "settled"}) — nothing is working on it now`;
10031
+ }
10032
+ /**
8581
10033
  * Register the report and peer tools on the tools registry.
8582
10034
  * @param ctx - the plugin context (`tools` injected).
8583
10035
  * @param service - the report service backing the tools.
8584
10036
  * @param peers - the peer service backing discovery and creation.
8585
10037
  * @returns a disposer removing every registration.
8586
10038
  */
8587
- function registerReportTools(ctx, service, peers) {
10039
+ function registerReportTools(ctx, service, peers, worktree) {
8588
10040
  const disposers = [];
8589
10041
  disposers.push(ctx.tools.register(defineTool({
8590
10042
  name: "report_author",
@@ -8622,22 +10074,37 @@ function registerReportTools(ctx, service, peers) {
8622
10074
  parent: {
8623
10075
  type: "string",
8624
10076
  description: "Report id this one answers, linking it into the thread."
8625
- }
10077
+ },
10078
+ ...worktreeParam(worktree, "Allocate this report its own git checkout, on a branch named after the report. Only the working directory and index are private; config, hooks and the object store are shared with every other checkout.")
8626
10079
  },
8627
10080
  output: stringOutput,
8628
10081
  execute: async (args, exec) => {
8629
10082
  const input = args;
8630
- const result = await service.author({
10083
+ const actor = actorOf(exec);
10084
+ const provisioned = input.worktree === true ? await requireWorktree(worktree, service, {
8631
10085
  subject: input.subject,
8632
- body: input.body,
8633
- actor: actorOf(exec),
8634
- ...input.to === void 0 ? {} : { to: input.to },
8635
- ...input.cc === void 0 ? {} : { cc: input.cc },
8636
- ...input.task === void 0 ? {} : { task: input.task },
8637
- ...input.artifacts === void 0 ? {} : { artifacts: input.artifacts },
8638
- ...input.parent === void 0 ? {} : { parent: input.parent }
8639
- });
8640
- return renderMutation(result.front, result.outcomes);
10086
+ actor
10087
+ }) : void 0;
10088
+ try {
10089
+ const result = await service.author({
10090
+ subject: input.subject,
10091
+ body: input.body,
10092
+ actor,
10093
+ ...input.to === void 0 ? {} : { to: input.to },
10094
+ ...input.cc === void 0 ? {} : { cc: input.cc },
10095
+ ...input.task === void 0 ? {} : { task: input.task },
10096
+ ...input.artifacts === void 0 ? {} : { artifacts: input.artifacts },
10097
+ ...input.parent === void 0 ? {} : { parent: input.parent },
10098
+ ...provisioned === void 0 ? {} : {
10099
+ reportId: provisioned.report,
10100
+ worktree: provisioned.prepared.binding
10101
+ }
10102
+ });
10103
+ return renderMutation(result.front, result.outcomes, provisioned?.prepared);
10104
+ } catch (error) {
10105
+ if (provisioned !== void 0) await service.rollbackProvision(provisioned);
10106
+ throw error;
10107
+ }
8641
10108
  }
8642
10109
  })));
8643
10110
  disposers.push(ctx.tools.register(defineTool({
@@ -8769,7 +10236,7 @@ function registerReportTools(ctx, service, peers) {
8769
10236
  })));
8770
10237
  disposers.push(ctx.tools.register(defineTool({
8771
10238
  name: "report_list",
8772
- description: "List report digests from the ledger — no bodies, so this is the cheap way to survey a long-running collaboration. Filter by `session` to see every report that touches one agent (authored, addressed, copied, or contributed to), by `status`, or by `task` to pull up one collaboration across every session it touched. The task label is matched case-insensitively and partially, so \"payments\" finds \"payments-rework\". Use it to find the report id you need before reading it, or to check what a teammate is still waiting on.",
10239
+ description: "List report digests from the ledger — no bodies, so this is the cheap way to survey a long-running collaboration. Filter by `session` to see every report that touches one agent (authored, addressed, copied, or contributed to), by `status`, or by `task` to pull up one collaboration across every session it touched. The task label is matched case-insensitively and partially, so \"payments\" finds \"payments-rework\". `worktree` matches a bound working tree by branch or path, which is the way to find out who is working in which checkout. Use it to find the report id you need before reading it, or to check what a teammate is still waiting on.",
8773
10240
  parameters: {
8774
10241
  session: {
8775
10242
  type: "string",
@@ -8788,6 +10255,10 @@ function registerReportTools(ctx, service, peers) {
8788
10255
  type: "string",
8789
10256
  description: "Only reports carrying this collaboration label (partial match, case-insensitive)."
8790
10257
  },
10258
+ worktree: {
10259
+ type: "string",
10260
+ description: "Only reports bound to a working tree whose branch or path contains this text (partial match, case-insensitive)."
10261
+ },
8791
10262
  limit: {
8792
10263
  type: "integer",
8793
10264
  description: "Maximum rows (default 50, capped at 200)."
@@ -8800,9 +10271,13 @@ function registerReportTools(ctx, service, peers) {
8800
10271
  ...input.session === void 0 ? {} : { session: input.session },
8801
10272
  ...input.status === void 0 ? {} : { status: input.status },
8802
10273
  ...input.task === void 0 ? {} : { task: input.task },
10274
+ ...input.worktree === void 0 ? {} : { worktree: input.worktree },
8803
10275
  ...input.limit === void 0 ? {} : { limit: input.limit }
8804
10276
  });
8805
- if (rows.length === 0) return input.task === void 0 ? "no reports match" : `no reports carry a task matching ${JSON.stringify(input.task)}`;
10277
+ if (rows.length === 0) {
10278
+ if (input.worktree !== void 0 && input.worktree.trim() !== "") return `no reports are bound to a working tree matching ${JSON.stringify(input.worktree)}`;
10279
+ return input.task === void 0 ? "no reports match" : `no reports carry a task matching ${JSON.stringify(input.task)}`;
10280
+ }
8806
10281
  const lines = [`${rows.length} report(s):`, ...rows.map(renderRow)];
8807
10282
  const taskFilter = input.task?.trim();
8808
10283
  if (taskFilter !== void 0 && taskFilter !== "") {
@@ -8850,13 +10325,74 @@ function registerReportTools(ctx, service, peers) {
8850
10325
  note: {
8851
10326
  type: "string",
8852
10327
  description: "Why it is finished, or what concluded it. Recorded on the hop."
10328
+ },
10329
+ ...worktree.enabled ? { worktree: {
10330
+ type: "string",
10331
+ enum: ["keep", "remove-if-clean"],
10332
+ description: "What to do with a bound git checkout. keep (the default) leaves it exactly as it is; remove-if-clean deletes it only if it has no uncommitted work, and reports git's refusal otherwise."
10333
+ } } : {}
10334
+ },
10335
+ output: stringOutput,
10336
+ execute: async (args, exec) => {
10337
+ const input = args;
10338
+ const result = await service.close(input.report, actorOf(exec), input.note, { worktree: input.worktree ?? worktree.onClose });
10339
+ return [
10340
+ renderMutation(result.front, result.outcomes),
10341
+ renderWorktreeOutcome(result.worktree, worktree.enabled),
10342
+ "closed — later activity on this report reopens it automatically"
10343
+ ].filter((line) => line !== void 0).join("\n");
10344
+ }
10345
+ })));
10346
+ disposers.push(ctx.tools.register(defineTool({
10347
+ name: "report_release",
10348
+ description: "Hand back a report's claim on its git checkout without concluding the report. Use it when the work in that checkout is done but the conversation is not: the checkout stays on disk exactly as you left it, and the claim — which is what stops another agent from taking the same branch — ends. A claim is released once; closing a report releases it too, so this is only needed while the report stays open.",
10349
+ parameters: {
10350
+ report: {
10351
+ type: "string",
10352
+ required: true,
10353
+ description: "Report id whose checkout claim should end."
10354
+ },
10355
+ note: {
10356
+ type: "string",
10357
+ description: "Why the claim is being handed back. Recorded on the hop."
8853
10358
  }
8854
10359
  },
8855
10360
  output: stringOutput,
8856
10361
  execute: async (args, exec) => {
8857
10362
  const input = args;
8858
- const result = await service.close(input.report, actorOf(exec), input.note);
8859
- return `${renderMutation(result.front, result.outcomes)}\nclosed — later activity on this report reopens it automatically`;
10363
+ const result = await service.release(input.report, actorOf(exec), input.note);
10364
+ const binding = result.front.worktree;
10365
+ return [renderMutation(result.front, result.outcomes), binding === void 0 ? "claim released" : `claim released; the checkout at ${binding.path} is untouched and stays traceable to ${result.front.report}`].join("\n");
10366
+ }
10367
+ })));
10368
+ if (worktree.enabled) disposers.push(ctx.tools.register(defineTool({
10369
+ name: "report_lease",
10370
+ description: "Claim an EXISTING git checkout for a report — the counterpart to report_release. Use it to pick up a checkout that already exists: one a human made, one a previous report left behind, or one a report released earlier. A branch is held by exactly one live report at a time, so a competing claim is refused and the refusal names the holder and its authors. The branch is read from the checkout itself, so pass one only to require it.",
10371
+ parameters: {
10372
+ report: {
10373
+ type: "string",
10374
+ required: true,
10375
+ description: "Report id that will hold the checkout. Only its owners may claim on its behalf."
10376
+ },
10377
+ path: {
10378
+ type: "string",
10379
+ required: true,
10380
+ description: "Absolute path of the checkout to claim."
10381
+ },
10382
+ branch: {
10383
+ type: "string",
10384
+ description: "The branch the checkout must be on. Omit to take whichever branch it is really on."
10385
+ }
10386
+ },
10387
+ output: stringOutput,
10388
+ execute: async (args, exec) => {
10389
+ const input = args;
10390
+ const result = await service.lease(input.report, actorOf(exec), {
10391
+ path: input.path,
10392
+ ...input.branch === void 0 ? {} : { branch: input.branch }
10393
+ });
10394
+ const binding = result.front.worktree;
10395
+ return [renderMutation(result.front, result.outcomes), binding === void 0 ? "claimed" : `claimed ${binding.branch ?? "(detached HEAD)"} at ${binding.path}${binding.head === void 0 ? "" : ` @ ${binding.head.slice(0, 12)}`}`].join("\n");
8860
10396
  }
8861
10397
  })));
8862
10398
  disposers.push(ctx.tools.register(defineTool({
@@ -8950,7 +10486,7 @@ function registerReportTools(ctx, service, peers) {
8950
10486
  })));
8951
10487
  disposers.push(ctx.tools.register(defineTool({
8952
10488
  name: "peer_start",
8953
- description: "Open a new independent agent session — a peer, not a subordinate. A peer is a root session with its own lifecycle that outlives this turn, so it is the right move when the work is long-running, when you want a partner that keeps its own context across days, or when a task should not be nested under you. This creates the session, records that you started it (which is what later lets you address it directly), and hands it the task as a REPORT — so the assignment is durable and traceable, and the peer can answer by contributing to that same report. Do not use it as a cheaper subagent: for a bounded subtask, delegation is the right tool.",
10489
+ description: "Open a new independent agent session — a peer, not a subordinate. A peer is a root session with its own lifecycle that outlives this turn, so it is the right move when the work is long-running, when you want a partner that keeps its own context across days, or when a task should not be nested under you. This creates the session, records that you started it (which is what later lets you address it directly), and hands it the task as a REPORT — so the assignment is durable and traceable, and the peer can answer by contributing to that same report. The output also reports what became of the peer's first turn (running, already ended, or failed): a delivered digest proves only that the peer is resident, never that work started. Do not use this as a cheaper subagent: for a bounded subtask, delegation is the right tool.",
8954
10490
  parameters: {
8955
10491
  task: {
8956
10492
  type: "string",
@@ -8960,36 +10496,62 @@ function registerReportTools(ctx, service, peers) {
8960
10496
  name: {
8961
10497
  type: "string",
8962
10498
  description: "Optional short display name for the peer (60 characters or fewer)."
8963
- }
10499
+ },
10500
+ ...worktreeParam(worktree, "Give the peer its own git checkout, on a branch named after the assignment report, and start it there instead of in your directory. Only the working directory and index are private; config, hooks and the object store are shared with every other checkout.")
8964
10501
  },
8965
10502
  output: stringOutput,
8966
10503
  execute: async (args, exec) => {
8967
10504
  const input = args;
8968
10505
  const actor = actorOf(exec);
8969
- const started = await peers.start(actor, {
8970
- task: input.task,
8971
- ...input.name === void 0 ? {} : { name: input.name }
8972
- });
8973
- const result = await service.author({
8974
- subject: subjectFrom(input.task, started.name),
8975
- body: [
8976
- `本会话由 ${actor} 开启,你是它的**同伴**而非下属:你有自己的生命周期,会比这一轮活得更久。`,
8977
- "",
8978
- `要回复或继续这个话题,用 report_contribute 追加到本汇报——它会成为我们之间的线程;需要直接交代新事情时用 report_author 并 to=[${actor}]。`,
8979
- "",
8980
- "## 任务",
8981
- input.task
8982
- ].join("\n"),
8983
- actor,
8984
- to: [started.sessionId],
8985
- task: started.name
8986
- });
10506
+ const subject = subjectFrom(input.task, input.name);
10507
+ const provisioned = input.worktree === true ? await requireWorktree(worktree, service, {
10508
+ subject,
10509
+ actor
10510
+ }) : void 0;
10511
+ let started;
10512
+ let result;
10513
+ try {
10514
+ started = await peers.start(actor, {
10515
+ task: input.task,
10516
+ ...input.name === void 0 ? {} : { name: input.name },
10517
+ ...provisioned === void 0 ? {} : { cwd: provisioned.prepared.binding.path }
10518
+ });
10519
+ result = await service.author({
10520
+ subject,
10521
+ body: [
10522
+ `本会话由 ${actor} 开启,你是它的**同伴**而非下属:你有自己的生命周期,会比这一轮活得更久。`,
10523
+ ...provisioned === void 0 ? [] : [
10524
+ "",
10525
+ `你的工作目录是一个**独立的 git 检出**:${provisioned.prepared.binding.path}(分支 ${provisioned.prepared.binding.branch})。`,
10526
+ "独立的是工作目录与索引;config、hooks、对象库与其它检出共享,所以装 hook 或改配置会影响所有检出。"
10527
+ ],
10528
+ "",
10529
+ `要回复或继续这个话题,用 report_contribute 追加到本汇报——它会成为我们之间的线程;需要直接交代新事情时用 report_author 并 to=[${actor}]。`,
10530
+ "",
10531
+ "## 任务",
10532
+ input.task
10533
+ ].join("\n"),
10534
+ actor,
10535
+ to: [started.sessionId],
10536
+ task: started.name,
10537
+ ...provisioned === void 0 ? {} : {
10538
+ reportId: provisioned.report,
10539
+ worktree: provisioned.prepared.binding
10540
+ }
10541
+ });
10542
+ } catch (error) {
10543
+ if (provisioned !== void 0) await service.rollbackProvision(provisioned);
10544
+ throw error;
10545
+ }
10546
+ const receipt = describeReceipt(await peers.receipt(started.sessionId, DEFAULT_RECEIPT_MS), DEFAULT_RECEIPT_MS);
8987
10547
  return [
8988
10548
  `opened peer ${started.sessionId}${started.name === void 0 ? "" : ` (name=${started.name})`}`,
8989
10549
  started.cwd === void 0 ? "" : `cwd=${started.cwd}`,
8990
10550
  `peers you have started: ${started.started}`,
8991
10551
  `assignment report: ${result.front.report}`,
10552
+ ...provisioned === void 0 ? [] : renderProvision(provisioned.prepared),
8992
10553
  renderMutation(result.front, result.outcomes),
10554
+ receipt === void 0 ? "" : receipt,
8993
10555
  `Address this peer later with report_send / report_cc to=${started.sessionId}, or see everyone with peer_list.`
8994
10556
  ].filter((line) => line !== "").join("\n");
8995
10557
  }
@@ -9048,6 +10610,107 @@ function toSessionRecord(entry) {
9048
10610
  };
9049
10611
  }
9050
10612
  /**
10613
+ * Resolve the model route a peer is created with.
10614
+ *
10615
+ * This is the difference between a peer that works and a peer that dies silently:
10616
+ * an agent with no route cannot resolve `{{model}}` while its prompt is
10617
+ * assembled, so its first turn ends at step 1 with an `error` reason — before a
10618
+ * single tool call, which is why the ledger's `delivered` and the roster's `live`
10619
+ * both stay perfectly green while nothing whatsoever happens.
10620
+ *
10621
+ * The starter's live route is the right source, in the same order the harness's
10622
+ * own delegation uses: the session's request header records the route actually in
10623
+ * force after request-time selection, creation options are the fallback before
10624
+ * the first request, and only a starter carrying neither falls through to the
10625
+ * deployment default. When even that is absent the peer is refused rather than
10626
+ * created doomed.
10627
+ * @param caller - the live starter agent, when it is resident.
10628
+ * @param fallback - the deployment default model service, when it is mounted.
10629
+ * @returns the route fields to pass as creation options.
10630
+ * @throws when no source can name a provider and model.
10631
+ */
10632
+ function peerRoute(caller, fallback) {
10633
+ const fromHeader = caller?.session.requestHeader()?.config;
10634
+ const created = caller?.options;
10635
+ const selection = fallback?.currentSelection();
10636
+ const provider = fromHeader?.provider ?? created?.provider ?? selection?.provider;
10637
+ const model = fromHeader?.model ?? created?.model ?? selection?.model;
10638
+ if (provider === void 0 || model === void 0) throw new Error("report-ledger: cannot start a peer — no model route could be resolved for it. The starting session carries no provider/model and this deployment exposes no default model selection, so the peer would be created unable to assemble a prompt (its first turn would fail).");
10639
+ const reasoningEffort = fromHeader?.reasoningEffort ?? created?.reasoningEffort ?? selection?.reasoningEffort;
10640
+ return {
10641
+ provider,
10642
+ model,
10643
+ ...reasoningEffort === void 0 ? {} : { reasoningEffort },
10644
+ ...created?.maxTokens === void 0 ? {} : { maxTokens: created.maxTokens }
10645
+ };
10646
+ }
10647
+ /**
10648
+ * Whether a session's log already records an opened turn.
10649
+ * @param session - the live session.
10650
+ * @returns true when at least one `turn/start` was appended.
10651
+ */
10652
+ function hasOpenedTurn(session) {
10653
+ return session.snapshotEvents().some((event) => event !== null && typeof event === "object" && event.type === "turn/start");
10654
+ }
10655
+ /**
10656
+ * Read the last turn's stop reason out of a session log.
10657
+ * @param session - the live session.
10658
+ * @returns the reason kind and, for an error, its message.
10659
+ */
10660
+ function lastTurnReason(session) {
10661
+ const events = session.snapshotEvents();
10662
+ for (let index = events.length - 1; index >= 0; index -= 1) {
10663
+ const event = events[index];
10664
+ if (event === null || typeof event !== "object") continue;
10665
+ const record = event;
10666
+ if (record.type !== "turn/end") continue;
10667
+ const data = record.data;
10668
+ if (data === null || typeof data !== "object") return {};
10669
+ const reason = data.reason;
10670
+ if (reason === null || typeof reason !== "object") return {};
10671
+ const fields = reason;
10672
+ const error = fields.error;
10673
+ const message = error === null || typeof error !== "object" ? void 0 : str(error, "message");
10674
+ return {
10675
+ ...typeof fields.kind === "string" ? { kind: fields.kind } : {},
10676
+ ...message === void 0 ? {} : { message }
10677
+ };
10678
+ }
10679
+ return {};
10680
+ }
10681
+ /**
10682
+ * Wait for one agent to reach quiescence, bounded.
10683
+ *
10684
+ * The bound is what makes the answer usable: a peer that opened a turn and is
10685
+ * still working after it elapses is reported as running, while a turn that dies
10686
+ * (the silent failure mode this exists to expose) settles in milliseconds. The
10687
+ * timer is an observation window only — it is unref'd, so it can never be a
10688
+ * reason for the host to stay alive.
10689
+ * @param agent - the agent to observe.
10690
+ * @param timeoutMs - the window length.
10691
+ * @returns true when quiescence came first, false when the window closed first.
10692
+ */
10693
+ function settledWithin(agent, timeoutMs) {
10694
+ return new Promise((resolve) => {
10695
+ let finished = false;
10696
+ const finish = (settled) => {
10697
+ if (finished) return;
10698
+ finished = true;
10699
+ clearTimeout(timer);
10700
+ resolve(settled);
10701
+ };
10702
+ const timer = setTimeout(() => {
10703
+ finish(false);
10704
+ }, Math.max(timeoutMs, 0));
10705
+ timer.unref?.();
10706
+ agent.whenIdle().then(() => {
10707
+ finish(true);
10708
+ }, () => {
10709
+ finish(true);
10710
+ });
10711
+ });
10712
+ }
10713
+ /**
9051
10714
  * Build every host capability this plugin consumes.
9052
10715
  *
9053
10716
  * Agent lookup goes through `ctx.agents`, and delivery uses the `Agent`'s own
@@ -9093,6 +10756,10 @@ function buildHost(ctx) {
9093
10756
  async describe(sessionId) {
9094
10757
  return (await readTitles([sessionId])).get(sessionId);
9095
10758
  },
10759
+ selfCwd(sessionId) {
10760
+ const cwd = (ctx.agents.get(sessionId)?.session?.header)?.cwd;
10761
+ return typeof cwd === "string" && cwd !== "" ? cwd : void 0;
10762
+ },
9096
10763
  readTitles,
9097
10764
  async listSessionRecords() {
9098
10765
  const query = sessionQuery();
@@ -9128,11 +10795,32 @@ async function readDetail(id) {
9128
10795
  * gate, {@link Config.maxPeersPerAgent} bounds the blast radius, the peer
9129
10796
  * inherits the caller's own working directory (so it cannot be pointed at an
9130
10797
  * unrelated tree), and every creation is attributed in the roster log.
10798
+ *
10799
+ * It is also the only place that must supply what a fresh agent cannot supply
10800
+ * itself. A root session is born with no composition and no model route:
10801
+ *
10802
+ * - **Composition.** The peer joins the starter's standing preset
10803
+ * (`composeFrom`), which is a bind to the SAME generation the starter runs —
10804
+ * not a re-resolution of the preset by id, so a preset edited or deleted since
10805
+ * cannot hand the peer a different world than its starter's. A deployment with
10806
+ * no roster yields no join and no error, exactly like a delegated child.
10807
+ * - **Route.** The peer inherits the starter's provider/model/reasoning effort.
10808
+ * Without it, prompt assembly cannot resolve `{{model}}` and every turn of the
10809
+ * peer dies at step 1 before its first tool call.
10810
+ * - **Preset identity.** The joined preset is recorded in the durable session
10811
+ * header, so a cold read of the peer's log rebuilds the composition it
10812
+ * actually ran under instead of the deployment default.
10813
+ *
10814
+ * `confirmPeer` closes the other half of the same problem: a peer that turns out
10815
+ * to be unable to run must be visible to the agent that just started it, rather
10816
+ * than reported as a successful hand-off.
9131
10817
  * @param ctx - the plugin context.
9132
10818
  * @param host - the session-reading bundle.
9133
10819
  * @returns the peer host capability.
9134
10820
  */
9135
10821
  function buildPeerHost(ctx, host) {
10822
+ const presets = () => ctx.get("agentPresets");
10823
+ const defaultModel = () => ctx.get("agentDefaultModel");
9136
10824
  return {
9137
10825
  selfCwd(sessionId) {
9138
10826
  const cwd = ((ctx.agents.get(sessionId)?.session)?.header)?.cwd;
@@ -9147,9 +10835,21 @@ function buildPeerHost(ctx, host) {
9147
10835
  readTitles: (ids) => host.readTitles(ids),
9148
10836
  listReports: () => listReports(),
9149
10837
  async createPeer(input) {
10838
+ const caller = ctx.agents.get(input.startedBy);
10839
+ if (caller === void 0) throw new Error(`report-ledger: cannot start a peer from ${input.startedBy} — the starting session is not resident, so the peer has no composition and no model route to inherit`);
10840
+ const agentOptions = peerRoute(caller, defaultModel());
10841
+ const agentPreset = presets()?.composedPreset(caller.ctx);
10842
+ const meta = {
10843
+ ...input.cwd === void 0 ? {} : { cwd: input.cwd },
10844
+ ...agentPreset === void 0 ? {} : { agentPreset }
10845
+ };
9150
10846
  const handle = await ctx.agents.create({
9151
10847
  sessionId: input.sessionId,
9152
- ...input.cwd === void 0 ? {} : { meta: { cwd: input.cwd } }
10848
+ ...Object.keys(meta).length === 0 ? {} : { meta },
10849
+ agentOptions,
10850
+ setup: (agentCtx) => {
10851
+ presets()?.composeFrom(agentCtx, caller.ctx);
10852
+ }
9153
10853
  });
9154
10854
  return {
9155
10855
  id: String(handle.agent.id),
@@ -9157,6 +10857,22 @@ function buildPeerHost(ctx, host) {
9157
10857
  handle.agent.steer(message);
9158
10858
  }
9159
10859
  };
10860
+ },
10861
+ async confirmPeer(sessionId, timeoutMs) {
10862
+ const agent = ctx.agents.get(sessionId);
10863
+ if (agent === void 0) return void 0;
10864
+ if (!hasOpenedTurn(agent.session)) return { kind: "unstarted" };
10865
+ if (!await settledWithin(agent, timeoutMs)) return { kind: "running" };
10866
+ const reason = lastTurnReason(agent.session);
10867
+ if (reason.kind === "error") return {
10868
+ kind: "failed",
10869
+ reason: "error",
10870
+ ...reason.message === void 0 ? {} : { error: reason.message }
10871
+ };
10872
+ return {
10873
+ kind: "settled",
10874
+ ...reason.kind === void 0 ? {} : { reason: reason.kind }
10875
+ };
9160
10876
  }
9161
10877
  };
9162
10878
  }
@@ -9169,10 +10885,11 @@ function apply(ctx, config) {
9169
10885
  const host = buildHost(ctx);
9170
10886
  const service = new ReportService(host);
9171
10887
  const peers = new PeerService(buildPeerHost(ctx, host), config?.maxPeersPerAgent ?? 8);
9172
- ctx.effect(() => registerReportTools(ctx, service, peers), "report-ledger: model tools");
10888
+ const worktree = resolveWorktree(config?.worktree);
10889
+ ctx.effect(() => registerReportTools(ctx, service, peers, worktree), "report-ledger: model tools");
9173
10890
  ctx.on("agent/created", (payload) => {
9174
10891
  const sessionId = String(payload?.agent?.id ?? "");
9175
- if (sessionId === "") return;
10892
+ if (sessionId === "") return void 0;
9176
10893
  service.flushForAgent(sessionId).catch((error) => {
9177
10894
  ctx.logger?.warn?.(`report-ledger: deferred delivery to ${sessionId} failed: ${String(error)}`);
9178
10895
  });
@@ -9184,9 +10901,14 @@ function apply(ctx, config) {
9184
10901
  timeline: async (root) => buildTimeline({
9185
10902
  listSessionRecords: () => host.listSessionRecords(),
9186
10903
  readTitles: (ids) => host.readTitles(ids),
9187
- listReports: () => listReports()
10904
+ listReports: () => listReports(),
10905
+ readHops: (report) => readHops(report)
9188
10906
  }, root),
9189
- report: readDetail
10907
+ report: readDetail,
10908
+ worktrees: async () => buildWorktreeReport({
10909
+ listReports: () => listReports(),
10910
+ readHops: (report) => readHops(report)
10911
+ }, worktree.settings, worktree.enabled)
9190
10912
  };
9191
10913
  scoped.effect(() => {
9192
10914
  const disposeReport = webServer.register({
@@ -9199,7 +10921,13 @@ function apply(ctx, config) {
9199
10921
  path: TIMELINE_PATH,
9200
10922
  handler: createTimelineHandler(deps)
9201
10923
  });
10924
+ const disposeWorktrees = webServer.register({
10925
+ kind: "exact",
10926
+ path: WORKTREES_PATH,
10927
+ handler: createWorktreesHandler(deps)
10928
+ });
9202
10929
  return () => {
10930
+ disposeWorktrees();
9203
10931
  disposeTimeline();
9204
10932
  disposeReport();
9205
10933
  };