@staix/agent-hub 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +4 -4
  3. package/docs/agent-notes/README.md +6 -0
  4. package/docs/agent-notes/adapters.md +10 -8
  5. package/docs/agent-notes/daemon.md +5 -2
  6. package/docs/agent-notes/local-worker.md +2 -1
  7. package/docs/agent-notes/tasks.md +4 -1
  8. package/docs/agent-notes/tests.md +6 -3
  9. package/docs/dogfood.md +9 -3
  10. package/docs/memory.md +1 -1
  11. package/docs/operations.md +66 -28
  12. package/docs/quickstart.md +9 -5
  13. package/docs/security.md +22 -13
  14. package/docs/smoke.md +3 -3
  15. package/docs/specs/2026-09-19-agent-hub-design.md +16 -5
  16. package/docs/verification/2026-10-25-remote-docker-transport.md +10 -0
  17. package/docs/verification/2026-10-26-agy-1.4.7.md +69 -0
  18. package/docs/verification/2026-10-26-claude-implementer-2.1.310.md +69 -0
  19. package/docs/verification/2026-10-26-headless-claude-shell-context.md +62 -0
  20. package/docs/verification/2026-10-26-jcode-0.99.1.md +49 -0
  21. package/docs/verification/2026-10-26-kimi-232-reviewer-compat.md +53 -0
  22. package/docs/verification/2026-10-26-pi-140-compat.md +41 -0
  23. package/docs/verification/2026-10-26-qwen-0261-acp.md +60 -0
  24. package/docs/verification/2026-10-27-0.19.0.md +86 -0
  25. package/docs/verification/claude-permission-transport-native.py +187 -0
  26. package/docs/verification/claude-shell-wrapper-host.ts +20 -0
  27. package/docs/verification/claude-shell-wrapper-native.py +145 -0
  28. package/docs/verification/pi-140-model-free.ts +57 -0
  29. package/docs/verification/pi-140-native.mjs +21 -0
  30. package/docs/verified.json +14 -14
  31. package/package.json +2 -2
  32. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  33. package/plugins/agent-hub/server.js +273 -68
  34. package/src/adapters/agy/entry.ts +1 -1
  35. package/src/adapters/claude-channel.ts +7 -3
  36. package/src/adapters/claude-implementer.ts +5 -3
  37. package/src/adapters/compat.ts +32 -18
  38. package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
  39. package/src/adapters/definitions/builtin-metadata.ts +2 -2
  40. package/src/adapters/definitions/native-factories.ts +7 -2
  41. package/src/adapters/definitions/reviewer-variants.ts +2 -2
  42. package/src/adapters/pi.ts +39 -3
  43. package/src/adapters/qwen-dual.ts +25 -0
  44. package/src/adapters/registry-types.ts +6 -2
  45. package/src/adapters/registry.ts +1 -0
  46. package/src/cli/agents.ts +69 -7
  47. package/src/cli/claude-shell-wrapper.ts +54 -0
  48. package/src/cli/main.ts +30 -24
  49. package/src/cli/output.ts +4 -0
  50. package/src/hub/agent-drift.ts +61 -0
  51. package/src/hub/board.ts +14 -2
  52. package/src/hub/check-runner.ts +148 -18
  53. package/src/hub/claude-shell-wrapper.ts +349 -0
  54. package/src/hub/config-edit.ts +2 -2
  55. package/src/hub/config-tier.ts +2 -2
  56. package/src/hub/daemon.ts +197 -35
  57. package/src/hub/host-executable.ts +20 -1
  58. package/src/hub/instances.ts +18 -2
  59. package/src/hub/lifecycle.ts +1 -1
  60. package/src/hub/operational-authority.ts +7 -0
  61. package/src/hub/operational-view.ts +6 -10
  62. package/src/hub/read-only-shell-proof.ts +7 -6
  63. package/src/hub/review-scope.ts +13 -0
  64. package/src/hub/sdlc-metrics.ts +6 -4
  65. package/src/hub/task-shell-command.ts +85 -0
  66. package/src/hub/task-shell-default.ts +78 -0
  67. package/src/hub/task-shell-proof.ts +171 -0
  68. package/src/hub/task-shell-sandbox.ts +61 -0
  69. package/src/hub/tasks.ts +139 -13
  70. package/src/local/deny.ts +91 -11
  71. package/src/local/sandbox.ts +67 -28
  72. package/src/local/shell-environment.ts +10 -0
  73. package/src/ui/index.html +1 -0
package/src/hub/tasks.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { canonicalMemorySymbolNode, normalizeMemorySymbolInput, MEMORY_SYMBOL_LIMIT, type MemorySymbolNodes } from "./memory-symbols.ts";
2
+ import { createHash } from "node:crypto";
3
+ import { taskShellBindingValid, type TaskShellBinding } from "./task-shell-command.ts";
2
4
  import { buildTaskCodeSymbols, publicTaskCodeSymbols, taskCodeSymbolsDisjoint, HOST_CODE_SYMBOLS } from "./task-code-symbols.ts";
3
5
  import { normalizePlanSymbol, symbolsOverlap } from "./symbol-normalizer.ts";
4
6
  import { publicTestIntegrity, readTestIntegrity, testIntegrityBlockers, type TestIntegrity } from "./test-integrity.ts";
@@ -16,6 +18,7 @@ import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS, actualTaskDates, planningPatch, type P
16
18
  import { readMergeQueue, type DeliveryTask } from "./sdlc-queue.ts";
17
19
  import { guardedMerge, mergeModeFor, mergeView, type MergeView } from "./sdlc-merge.ts";
18
20
  import { mirrorVerdict as mirrorToGitHub, type MirrorVerdict } from "./sdlc-mirror.ts";
21
+ import { CODE_SCOPE_LINE, evidenceOnlyNotice, type ReviewScope } from "./review-scope.ts";
19
22
  import { DEFAULT_MAX_NITS, parseReviewFindings, findingsForHead, findingHead as resolveFindingHead, findingTally, type ReviewFinding, type ReviewFindingSet } from "./review-findings.ts";
20
23
  import { readReleaseReadiness } from "./sdlc-release.ts";
21
24
  import { loadRoleCatalog, taskRole, taskRoleText, type RoleCatalog } from "./roles.ts";
@@ -104,6 +107,8 @@ export interface TasksDeps {
104
107
  privateTurn?: (peer: PeerId) => boolean;
105
108
  /** #223 R4: the hub's reading of a verdict's context (`fresh` only for a reviewer session started for this review). */
106
109
  reviewContext?: (by: PeerId, task: number) => "fresh" | "continuing" | undefined;
110
+ /** #852: what a reviewer reads. `code` (the fixed sandboxed reviewers by default, or a local `review_scope`) reads the code only; unset or `full` also runs tests and reads CI. */
111
+ reviewScope?: (peer: PeerId) => ReviewScope;
107
112
  /** #223 R2: a reviewer-only instance gets review facts, not the builder's done summary. */
108
113
  reviewOnly?: (peer: PeerId) => boolean;
109
114
  /** #223: captured when a verdict call starts; a non-undefined result refuses the verdict at every later check. */
@@ -698,6 +703,8 @@ export class Tasks {
698
703
  ...(h.selfReview ? { selfReview: true } : {}),
699
704
  ...(h.reviewerFamily ? { reviewerFamily: screenHistoryText(h.reviewerFamily, "reviewer family") } : {}),
700
705
  ...(h.reviewerReason ? { reviewerReason: h.reviewerReason } : {}),
706
+ ...(h.reviewScope === "code" ? { reviewScope: "code" as const } : {}),
707
+ ...(h.evidenceOnly ? { evidenceOnly: true as const } : {}),
701
708
  ...(h.unmetCriteria?.length ? { unmetCriteria: h.unmetCriteria } : {}),
702
709
  ...(h.deliverables?.length ? { deliverables: publicDeliverables(h.deliverables, this.nameable) } : {}),
703
710
  ...(h.reviewBlocked ? { reviewBlocked: true } : {}),
@@ -775,7 +782,7 @@ export class Tasks {
775
782
  if (h.owner === undefined || h.owner !== owner) since = i;
776
783
  owner = h.owner;
777
784
  });
778
- return new Set(task.history.slice(since + 1).filter((h) => h.event === "changes_requested").map((h) => h.by));
785
+ return new Set(task.history.slice(since + 1).filter((h) => h.event === "changes_requested" && h.evidenceOnly !== true).map((h) => h.by));
779
786
  }
780
787
 
781
788
  /** Workload is board/queue data only: wip() never calls health() or weights(). */
@@ -872,7 +879,7 @@ export class Tasks {
872
879
  if (!OWNERSHIP_EVENTS.has(given.event) || given.event === "unassigned" || given.owner !== peer || given.profile !== profile || given.by === peer) return [];
873
880
  const next = t.history.findIndex((h, j) => j > i && OWNERSHIP_EVENTS.has(h.event));
874
881
  const span = t.history.slice(i + 1, next < 0 ? undefined : next + 1);
875
- if (span.some((h) => failed.includes(h.event))) return [{ outcome: "failed" }];
882
+ if (span.some((h) => failed.includes(h.event) && h.evidenceOnly !== true)) return [{ outcome: "failed" }];
876
883
  if (next >= 0 || t.state !== "approved") return []; // handed on without a failure (a relay), or still open
877
884
  const accepted = span.find((h) => h.event === "accepted" && h.by === peer);
878
885
  // The work stage ends at its first done call: checks and an integration step are not the work itself.
@@ -1038,6 +1045,85 @@ export class Tasks {
1038
1045
  wip(peer: PeerId, except?: number): number {
1039
1046
  return this.d.board.plannerWip(peer, except);
1040
1047
  }
1048
+
1049
+ /** Host-observed task branch/root binding (#845), never a tool-call branch claim or an execution grant. */
1050
+ bindShellBranch(peer: PeerId, id: number, binding: TaskShellBinding & { worktree: string }, current: () => boolean): boolean {
1051
+ try {
1052
+ if (!Number.isSafeInteger(id) || id < 1 || !taskShellBindingValid(binding) || !this.publicMetadataText(binding.branch)
1053
+ || !current() || this.d.privateTurn?.(peer)) return false;
1054
+ const task = this.d.board.get(id), root = this.d.executionRoot?.(peer);
1055
+ if (!task || task.owner !== peer || !["in_progress", "changes_requested"].includes(task.state) || this.isPii(task)
1056
+ || !root || realPath(root) === realPath(this.d.cwd) || realPath(root) !== realPath(binding.worktree)) return false;
1057
+ const worktree = createHash("sha256").update(realPath(root)).digest("hex");
1058
+ const previous = task.history.findLast(entry => entry.by === HUB && entry.event === "shell branch bound");
1059
+ if (previous) {
1060
+ const saved: unknown = JSON.parse(previous.note ?? "");
1061
+ if (!saved || typeof saved !== "object" || Array.isArray(saved)) return false;
1062
+ const record = saved as Record<string, unknown>;
1063
+ if (record.branch !== binding.branch || record.worktree !== worktree) return false;
1064
+ }
1065
+ if (task.refs.branch !== undefined && task.refs.branch !== binding.branch) return false;
1066
+ const scope = this.repositories.scope(task.refs);
1067
+ const others = this.d.board.list().filter(row => row.id !== id && !isClosedTaskState(row.state))
1068
+ .map(row => ({ row, scope: this.repositories.scope(row.refs) }));
1069
+ if (scope.key !== this.repositories.project.key || others.some(other => other.scope.key === scope.key && other.row.refs.branch === binding.branch)) return false;
1070
+ if (!current() || this.d.privateTurn?.(peer) || this.d.board.get(id)?.history.length !== task.history.length) return false;
1071
+ if (previous) return true;
1072
+ let recorded = false;
1073
+ this.releaseNotice(`task #${id}: execution branch binding unavailable`, () => {
1074
+ this.d.board.update(id, HUB, "shell branch bound", { refs: { branch: binding.branch } }, JSON.stringify({ branch: binding.branch, worktree }), {}, task.history.length);
1075
+ recorded = true;
1076
+ });
1077
+ return recorded;
1078
+ } catch { return false; }
1079
+ }
1080
+ private capacityBefore(task: Task): Map<PeerId, number> {
1081
+ return new Map([...new Set([task.owner, task.reserved])].filter((peer): peer is PeerId => !!peer).map(peer => [peer, this.wip(peer)]));
1082
+ }
1083
+
1084
+ /** Write-driven wake-ups (#785): one unsettled ids-only task notice per planner, never a polling pull. */
1085
+ private writePlannerNotices(task: Task, before: ReadonlyMap<PeerId, number>, approved = false): void {
1086
+ this.releaseNotice(`task #${task.id}: could not notify planner about ready work`, () => {
1087
+ const changes = new Map<PeerId, { capacity: Set<PeerId>; ready: Set<number> }>();
1088
+ const add = (planner: PeerId, peer?: PeerId, id?: number) => {
1089
+ if (!this.d.bus.isRostered(planner)) return;
1090
+ const change = changes.get(planner) ?? { capacity: new Set<PeerId>(), ready: new Set<number>() };
1091
+ if (peer) change.capacity.add(peer);
1092
+ if (id !== undefined) change.ready.add(id);
1093
+ changes.set(planner, change);
1094
+ };
1095
+ for (const [peer, used] of before) if (this.wip(peer) < used) for (const planner of this.taskPlanner(task)) add(planner, peer);
1096
+ if (approved) for (const card of this.d.board.list("backlog")) {
1097
+ if (card.deps?.includes(task.id) && !this.waitsFor(card).length) for (const planner of this.taskPlanner(card)) add(planner, undefined, card.id);
1098
+ }
1099
+ for (const [planner, change] of changes) this.releaseNotice(`could not notify planner ${planner} about ready work`, () => {
1100
+ const pending = this.d.bus.pendingEnvelopes(planner).filter(env => env.from === HUB && env.kind === "task" && env.body.startsWith("Planner work ready:"));
1101
+ // The bus retains these hub-written lines across restart; an in-memory cache would lose unsettled events.
1102
+ for (const env of pending) for (const line of env.body.split("\n")) {
1103
+ const capacity = /^capacity_released: (\S+) WIP \d+; reserved ready (?:#\d+|none)$/.exec(line);
1104
+ if (capacity) change.capacity.add(capacity[1]!);
1105
+ const ready = /^backlog_ready: #(\d+)$/.exec(line);
1106
+ if (ready && Number.isSafeInteger(Number(ready[1]))) change.ready.add(Number(ready[1]));
1107
+ }
1108
+ const lines: string[] = [];
1109
+ for (const peer of change.capacity) {
1110
+ const top = this.d.board.list().filter(card => ["backlog", "proposed"].includes(card.state) && !card.owner && card.reserved === peer
1111
+ && this.taskPlanner(card).includes(planner) && !card.pinned && taskCanPlan(card) && !this.waitsFor(card).length && !this.excluded(card).includes(peer)
1112
+ && (card.release == null || ["planning", "active"].includes(this.d.board.release(card.release)?.state ?? ""))).sort(this.readyOrder)[0];
1113
+ lines.push(`capacity_released: ${peer} WIP ${this.wip(peer)}; reserved ready ${top ? `#${top.id}` : "none"}`);
1114
+ }
1115
+ for (const id of change.ready) {
1116
+ const card = this.d.board.get(id);
1117
+ if (card?.state === "backlog" && this.taskPlanner(card).includes(planner) && !this.waitsFor(card).length) lines.push(`backlog_ready: #${id}`);
1118
+ else change.ready.delete(id);
1119
+ }
1120
+ for (const env of pending) this.d.bus.withdraw(env.id);
1121
+ const env = newEnvelope(HUB, sanitize(`Planner work ready:\n${lines.join("\n")}\nRead the current board before planning or pulling work.`), { to: [planner], kind: "task" });
1122
+ if (!this.d.bus.publish(env).includes(planner)) throw new Error("the notice was not queued");
1123
+ });
1124
+ });
1125
+ }
1126
+
1041
1127
  /** #784 AC3: the read-only breakdown of `wip` for status, the console and the dashboard: one grouped query for every peer. Admission still reads `wip()`. */
1042
1128
  wipBreakdown(peers: readonly PeerId[]): Record<string, { wip: number; active: number; reviewWait: number; reserved: number; wipLimit: number }> {
1043
1129
  const grouped = this.d.board.plannerWipBreakdown();
@@ -1380,7 +1466,9 @@ export class Tasks {
1380
1466
  if (entry.planning.pull) {
1381
1467
  const release = task.release == null ? undefined : this.d.board.release(task.release);
1382
1468
  if (task.state !== "proposed" || task.owner || taskHasStarted(task) || by !== USER && (task.pinned || release && ["frozen", "shipped", "dropped"].includes(release.state))) throw new PreEffectToolRefusal("planner pull undo is no longer eligible");
1469
+ const capacity = this.capacityBefore(task);
1383
1470
  const returned = this.d.board.undoPlannedPull(id, by, revision);
1471
+ this.writePlannerNotices(returned, capacity);
1384
1472
  this.offered.delete(id); this.incomingBacklog.add(id);
1385
1473
  this.planningRecorded({ by, task: id, fields: ["pull", "reserved"], revision: returned.planningRevision! });
1386
1474
  return returned;
@@ -1760,7 +1848,9 @@ export class Tasks {
1760
1848
  this.plannerWriteAuthority(by);
1761
1849
  const confirmed = validate();
1762
1850
  if (confirmed.history.length !== flow) throw new PreEffectToolRefusal("task changed during untouched return");
1851
+ const capacity = this.capacityBefore(confirmed);
1763
1852
  const returned = this.d.board.update(confirmed.id, by, "backlog returned untouched", { state: "backlog", owner: null, reviewer: null }, input.reason.trim(), { ...(withheld ? { withheld: true } : {}) }, flow);
1853
+ this.writePlannerNotices(returned, capacity);
1764
1854
  this.offered.delete(confirmed.id);
1765
1855
  this.cohorts.leave(confirmed.id);
1766
1856
  this.withdrawRoleRequest(confirmed, returned, "owner");
@@ -1783,7 +1873,9 @@ export class Tasks {
1783
1873
  if (!pull && taskHasStarted(task)) throw new PreEffectToolRefusal(`task #${n}: started work cannot return to backlog`);
1784
1874
  const pulled = task.history.findLast(entry => entry.event === "backlog pulled" || entry.event === "backlog returned");
1785
1875
  const restoreReservation = !pull && pulled?.planning?.pull && !task.history.slice(task.history.lastIndexOf(pulled) + 1).some(entry => entry.event === "reserved" || entry.planning?.fields.includes("reserved"));
1876
+ const capacity = this.capacityBefore(task);
1786
1877
  const next = this.d.board.update(n, by, pull ? "backlog pulled" : "backlog returned", { state: pull ? "proposed" : "backlog", owner: null, reviewer: null, ...(restoreReservation ? { reserved: pulled.planning!.previous.reserved ?? null } : {}) });
1878
+ this.writePlannerNotices(next, capacity);
1787
1879
  this.offered.delete(n);
1788
1880
  if (!pull) { this.incomingBacklog.add(n); return next; }
1789
1881
  const waits = this.waitsFor(next);
@@ -2612,7 +2704,9 @@ export class Tasks {
2612
2704
  const task = validate();
2613
2705
  if (task.history.length !== revision) throw new PreEffectToolRefusal("task flow revision changed");
2614
2706
  const cancelled = { reason: input.reason.trim(), ...(input.supersededBy ? { supersededBy: input.supersededBy.trim() } : {}), flowRevision: revision + 1, ...(withheld ? { withheld: true as const } : {}) };
2707
+ const capacity = this.capacityBefore(task);
2615
2708
  const next = this.d.board.update(task.id, by, "cancelled", { state: "cancelled", reserved: null, cancelled }, cancelled.reason, { ...(withheld ? { withheld: true as const } : {}) }, revision);
2709
+ this.writePlannerNotices(next, capacity);
2616
2710
  this.releaseNotice(`task #${task.id}: cancellation cohort cleanup unavailable`, () => this.cohorts.leave(task.id));
2617
2711
  this.offered.delete(task.id);
2618
2712
  this.releaseNotice(`task #${task.id}: cancellation stall cleanup unavailable`, () => this.d.board.clearStall(task.id));
@@ -2863,7 +2957,7 @@ export class Tasks {
2863
2957
  }
2864
2958
  const rows = this.d.board.list();
2865
2959
  const tasks: MetricTask[] = rows.map(task => ({ id: task.id, private: this.isPii(task), history: task.history.map(entry => ({ at: entry.at, event: entry.event,
2866
- ...(entry.reviewCost?.head ? { requestHead: entry.reviewCost.head } : {}), ...(entry.reviewFindings?.head ? { verdictHead: entry.reviewFindings.head } : {}) })) }));
2960
+ ...(entry.reviewCost?.head ? { requestHead: entry.reviewCost.head } : {}), ...(entry.reviewFindings?.head ? { verdictHead: entry.reviewFindings.head } : {}), ...(entry.evidenceOnly ? { evidenceOnly: true as const } : {}) })) }));
2867
2961
  const visibleIds = new Set(rows.filter(task => !this.isPii(task)).map(task => task.id));
2868
2962
  const byKind: Record<string, number> = {};
2869
2963
  for (const stall of this.stalled()) if (visibleIds.has(stall.task)) byKind[stall.cause.kind] = (byKind[stall.cause.kind] ?? 0) + 1;
@@ -3688,12 +3782,17 @@ export class Tasks {
3688
3782
  const task = this.need(id, true, true);
3689
3783
  this.mine(task, by, "owner", true);
3690
3784
  // The owner it is declined for goes on the entry, also when the console declines for it: `excluded()` keeps it out (#207).
3785
+ const capacityBefore = this.capacityBefore(task);
3691
3786
  const planned = this.plannerPullRecord(task)?.pull === true;
3692
3787
  const back = this.d.board.update(task.id, by, "declined", { ...(["in_progress", "changes_requested"].includes(task.state) ? { state: "proposed" as const } : {}), ...(planned ? { reserved: null } : {}) }, reason, task.owner ? { from: task.owner, ...(capacity === true ? { capacity: true as const } : {}) } : {});
3693
3788
  if (planned) this.offered.delete(task.id);
3694
3789
  this.d.notify(`task ${this.publicTitle(back)} declined${capacity === true ? " (capacity only)" : ""} by ${by}${reason && !this.isPii(back) ? `: ${reason}` : ""}`);
3695
3790
  // A capacity decline is not in excluded(), so this move's own reroute leaves the decliner out explicitly.
3696
- return this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true, ...(capacity === true && task.owner ? { exclude: [task.owner] } : {}), ...(planned ? { planner: true } : {}) });
3791
+ try {
3792
+ return await this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true, ...(capacity === true && task.owner ? { exclude: [task.owner] } : {}), ...(planned ? { planner: true } : {}) });
3793
+ } finally {
3794
+ this.writePlannerNotices(this.d.board.get(task.id) ?? back, capacityBefore);
3795
+ }
3697
3796
  }
3698
3797
 
3699
3798
  /**
@@ -4037,7 +4136,9 @@ export class Tasks {
4037
4136
  const completedRefs = cleanRefs(refs);
4038
4137
  this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
4039
4138
  const noWrites = this.noWritesRecorded({ ...task, refs: { ...task.refs, ...completedRefs } });
4139
+ const capacity = this.capacityBefore(task);
4040
4140
  const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...completedRefs } }), state: reviewer ? "in_review" : "approved", ...(selection ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { ...this.planObservation(task), withheld: withheld || undefined, ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection), ...(reviewCost ? { reviewCost } : {}), ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
4141
+ this.writePlannerNotices(next, capacity, next.state === "approved");
4041
4142
  this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
4042
4143
  const memorySymbols = this.completionMemorySymbols(next);
4043
4144
  this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\nChanged-symbol memory anchors: ${memorySymbols.state === "known" ? memorySymbols.nodes.length : "unknown (no complete code facts)"}\n${summary ?? ""}`, withheld, memorySymbols);
@@ -4235,9 +4336,12 @@ export class Tasks {
4235
4336
  ? "No check ran for this class: verify the outcome yourself."
4236
4337
  : "No check ran for this class: run the affected tests yourself.";
4237
4338
  // #824: the hub's own exact-head CI evidence, from the admission this request already carries (no new gh call). Nothing here comes from the builder's summary or a recorded claim.
4238
- const verified = this.hubVerifiedBlock(task, admission);
4339
+ const codeScoped = this.d.reviewScope?.(reviewer) === "code";
4340
+ const verified = codeScoped ? "" : this.hubVerifiedBlock(task, admission);
4239
4341
  const ciPassed = admission?.ready === true && !this.isPii(task);
4240
- const checkResult = check ? `Check result: ${(check.note ?? "").split("\n")[0]}`
4342
+ // #852 AC2: a code-scope reviewer's sandbox cannot run tests or read CI, so its Hub-verified and check lines are replaced by one fixed line.
4343
+ const checkResult = codeScoped ? CODE_SCOPE_LINE
4344
+ : check ? `Check result: ${(check.note ?? "").split("\n")[0]}`
4241
4345
  : ciPassed ? "CI passed at this exact head (see the Hub-verified line above); do not list test execution or CI evidence as unmet: name the specific missing test or behaviour instead."
4242
4346
  : noCheckWording;
4243
4347
  const checklistTemplates = profile.review_checklist ?? [
@@ -4431,6 +4535,11 @@ export class Tasks {
4431
4535
  let parsed: ReviewFinding[];
4432
4536
  try { parsed = parseReviewFindings(findings, { person: by === USER, maxNits: this.d.sdlc?.config().maxNits ?? DEFAULT_MAX_NITS }); }
4433
4537
  catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "invalid structured findings"); }
4538
+ // #852 AC3 (amended): a code-scope reviewer's changes_requested is always recorded, never refused. Without an open structured finding it is
4539
+ // evidence-only: it does not count toward rejections or escalation, the task returns to its owner as usual, and the conductor gets one ids-only
4540
+ // notice. The rule is structural (a finding with disposition open), never a reading of the unmet text.
4541
+ const codeScope = by !== USER && this.d.reviewScope?.(by) === "code";
4542
+ const evidenceOnly = codeScope && verdict === "changes_requested" && !parsed.some(finding => finding.disposition === "open");
4434
4543
  const resolvedHead = this.findingHead(initial);
4435
4544
  let findingHead = resolvedHead;
4436
4545
  if (head !== undefined) {
@@ -4512,7 +4621,7 @@ export class Tasks {
4512
4621
  }
4513
4622
  if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "approval", reviewedHead, proposedSets(task)); task = valid(); }
4514
4623
  let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
4515
- if (verdict === "changes_requested" && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
4624
+ if (verdict === "changes_requested" && !evidenceOnly && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
4516
4625
  const input: CandidateInput = { project: this.d.store?.project ?? this.d.project, kind: "fail", approach: this.publicTitle(task), symptom: items.join("\n"),
4517
4626
  cause: "unknown: review unmet items do not establish a cause", retryWhen: "unknown: a person must review the unmet items before accepting",
4518
4627
  evidenceRefs: [`task:${task.id}`, `reviewed-head:${reviewedHead ?? task.refs.commit ?? "unknown"}`] };
@@ -4531,9 +4640,11 @@ export class Tasks {
4531
4640
  // #507b: unmet items that start with a recorded criterion id name that criterion. The ids come from the checklist on the board as it is at this
4532
4641
  // write (the `task` just rechecked by valid()), never from the caller; a PII task carries none, its checklist being private.
4533
4642
  const unmetCriteria = pii ? [] : unmetCriteriaIds(items, checklistOf(task.sdlc));
4534
- const flag = { ...this.planObservation(task), withheld: withheld || undefined, ...(report ? { reviewFindings: report } : {}), ...(unmetCriteria.length ? { unmetCriteria } : {}), ...(boundDeliverables ? { deliverables: boundDeliverables } : {}), ...(reviewContext ? { reviewContext } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
4643
+ const flag = { ...this.planObservation(task), withheld: withheld || undefined, ...(report ? { reviewFindings: report } : {}), ...(unmetCriteria.length ? { unmetCriteria } : {}), ...(boundDeliverables ? { deliverables: boundDeliverables } : {}), ...(reviewContext ? { reviewContext } : {}), ...(codeScope ? { reviewScope: "code" as const } : {}), ...(evidenceOnly ? { evidenceOnly: true as const } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
4535
4644
  if (verdict === "approved") {
4645
+ const capacity = this.capacityBefore(task);
4536
4646
  const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0, ...(reviewedHead && task.sdlc ? { sdlc: { ...task.sdlc, reviewedHead, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), "merge") } } : {}) }, note, flag);
4647
+ this.writePlannerNotices(next, capacity, next.state === "approved");
4537
4648
  if (next.owner) {
4538
4649
  this.d.board.recordOutcome(next.owner, next.class, true);
4539
4650
  this.d.board.recordReview({ implementer: next.owner, reviewer: by, class: next.class, kind: "approved", task: next.id });
@@ -4552,17 +4663,19 @@ export class Tasks {
4552
4663
  this.releaseNotice(`task #${next.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(next, by, parsed, withheld));
4553
4664
  return next;
4554
4665
  }
4555
- const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note, flag);
4666
+ const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: evidenceOnly ? task.rejections : task.rejections + 1 }, note, flag);
4556
4667
  void this.mirrorVerdict(rejected, by, "changes_requested", reviewedHead ?? rejected.sdlc?.reviewedHead, note, withheld);
4557
- this.releaseNotice(`task #${rejected.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(rejected, by, parsed, withheld));
4668
+ if (!evidenceOnly) this.releaseNotice(`task #${rejected.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(rejected, by, parsed, withheld));
4558
4669
  if (candidate && !pii && !this.d.privateTurn?.(by) && this.candidateTextAllowed(JSON.stringify({ candidate, refs: rejected.refs, plan: rejected.plan }), rejected)) {
4559
4670
  try { this.d.memoryCheck?.(); this.d.candidates?.put({ ...candidate, evidenceRefs: [...candidate.evidenceRefs, `review:${rejected.id}:${rejected.history.length}`] }, "public"); } catch { /* a saved review remains authoritative if the optional sidecar fails */ }
4560
4671
  }
4561
4672
  this.cohorts.withdraw(rejected.id); // open again: its done intent no longer counts (issue #107)
4562
- if (rejected.owner) this.d.board.recordOutcome(rejected.owner, rejected.class, false);
4563
- this.contradict(rejected);
4673
+ // Evidence-only is not a verdict on the owner's work: no failed outcome, no contradiction of an earlier approval, and no escalation count.
4674
+ if (rejected.owner && !evidenceOnly) this.d.board.recordOutcome(rejected.owner, rejected.class, false);
4675
+ if (!evidenceOnly) this.contradict(rejected);
4564
4676
  this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`, withheld);
4565
- if (rejected.rejections >= (this.d.escalateAfter?.() ?? ESCALATE_AFTER)) {
4677
+ if (evidenceOnly) this.tellConductorEvidenceOnly(rejected, by);
4678
+ if (!evidenceOnly && rejected.rejections >= (this.d.escalateAfter?.() ?? ESCALATE_AFTER)) {
4566
4679
  let moved: Task;
4567
4680
  try {
4568
4681
  moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`, "rejections");
@@ -4587,6 +4700,19 @@ export class Tasks {
4587
4700
  return reopened;
4588
4701
  }
4589
4702
 
4703
+ /**
4704
+ * #852 AC3: one task-weight notice to the configured conductor for a verdict recorded as evidence-only. Ids only (task and reviewer), so nothing of
4705
+ * the review or the task reaches it; a PII task's notice is private. With no conductor the person sees one console notice instead.
4706
+ */
4707
+ private tellConductorEvidenceOnly(task: Task, reviewer: PeerId): void {
4708
+ const conductors = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor).filter(actor => actor !== reviewer);
4709
+ const text = evidenceOnlyNotice(task.id, reviewer);
4710
+ if (!conductors.length) { this.releaseNotice(text); return; }
4711
+ this.releaseNotice(`task #${task.id}: could not tell the conductor a code-scope review was evidence-only`, () => {
4712
+ this.d.bus.publish(newEnvelope(HUB, sanitize(text), { to: conductors, kind: "task", refs: { task: String(task.id) }, ...(this.isPii(task) ? { private: true } : {}) }));
4713
+ });
4714
+ }
4715
+
4590
4716
  /** Task-level escalation: the next attached peer in the class's escalate_to takes over, with the history. */
4591
4717
  async escalate(by: PeerId, id: unknown, why = "by hand", reason: TaskMoveReason | undefined = by === HUB ? undefined : "manual", preEffect = false): Promise<Task> {
4592
4718
  let task = this.need(id, true, preEffect);
package/src/local/deny.ts CHANGED
@@ -31,6 +31,17 @@ const HFS_IGNORABLE = `(${HFS_IGNORABLE_POINTS.join("|")})`;
31
31
  /** A regex source matching `name` with any number of HFS+-ignorable code points interleaved, at the ends included. */
32
32
  const hfsFoldedName = (name: string) => `${HFS_IGNORABLE}*${[...name].map((c) => sbplEscape(c)).join(`${HFS_IGNORABLE}*`)}${HFS_IGNORABLE}*`;
33
33
 
34
+ /**
35
+ * `config.worktree`, the per-worktree config git reads beside `config` (#518), folded in BOTH halves as two NAME-only regex sources that a rule must both match. One SBPL regex
36
+ * literal dies at 1024 bytes in UTF-8 and every folded character costs about 65 bytes, so the whole name (1071) cannot be one literal, and a byte class cannot hold the
37
+ * multi-byte code points (the engine is byte-oriented, verified), so the fold stays an alternation. The first source pins the front (a folded `config`, a dot, then the rest of
38
+ * the name), the second the end (a folded `worktree`). Together they match every spelling of `config.worktree` with ignorable code points anywhere in it, and also
39
+ * `config.<anything>worktree` in the same directory, which is over-refused on purpose and names no file git uses.
40
+ * The root a rule is scoped to is NEVER part of these literals: it goes in its own `require-all` filter, so a long project or git path cannot push a folded name over the limit
41
+ * (#518 review: a 450-byte path made a 1083-byte literal when the root and the name shared one).
42
+ */
43
+ const CONFIG_WORKTREE_NAME: readonly [string, string] = [`^.*/${hfsFoldedName("config")}\\.[^/]*$`, `^.*/[^/]*${hfsFoldedName("worktree")}$`];
44
+
34
45
  /**
35
46
  * Seatbelt rules for the hub's and git's own names (#270). `.agenthub` is refused anywhere under the project root.
36
47
  * `.git` is refused at any depth, the name itself included, so a planted repository cannot arrive by creation,
@@ -53,32 +64,101 @@ const hfsFoldedName = (name: string) => `${HFS_IGNORABLE}*${[...name].map((c) =>
53
64
  * long project path would trip. ponytail: the modules rules do contain the (plain) modules root; a project path
54
65
  * over roughly 600 bytes with submodules would exceed the limit — fold the root's segments if one ever shows up.
55
66
  */
56
- export function hubWriteRegexes(root: string, gitDirs: string[] = [], moduleGit?: { root: string; entries: string[] }): string[] {
67
+ /**
68
+ * `hooks` as the entry directly under `parent`, and everything below it, anchored by DEPTH instead of by the unanchored any-prefix name: that one also matches an ancestor of the
69
+ * project that happens to be named `hooks`, and paired with a plain root prefix it refused every write below the root (#520). Seatbelt ignores a `{n}` repeat, so the depth is
70
+ * written out as that many `/[^/]+` terms (verified with the real sandbox-exec), which keeps the literal independent of the path's characters and costs 6 bytes a component.
71
+ * A path with more than MAX_ANCHOR_DEPTH components would push the folded name past the 1024-byte limit, which stops every sandboxed command, so it falls back to the
72
+ * unanchored name: that over-refuses only when an ancestor is named `hooks`, never under-protects. Pair it with a plain `^<parent>/` filter.
73
+ */
74
+ const MAX_ANCHOR_DEPTH = 80;
75
+ const hooksUnder = (parent: string): string => {
76
+ const depth = parent.split("/").filter(Boolean).length;
77
+ return depth > MAX_ANCHOR_DEPTH ? `^.*/${hfsFoldedName("hooks")}(/|$)` : `^${"/[^/]+".repeat(depth)}/${hfsFoldedName("hooks")}(/|$)`;
78
+ };
79
+
80
+ export function hubWriteRegexes(root: string, gitDirs: string[] = [], moduleGit: readonly { root: string; entries: string[]; dirs?: string[] }[] = [], worktreesRoot?: string): string[] {
57
81
  const git = hfsFoldedName(".git");
58
82
  const re = (s: string) => `(regex ${sbplString(s)})`;
59
83
  const external = gitDirs.flatMap((d) => [
60
84
  `(require-all ${re(`^${sbplEscape(d)}/[^/]+$`)} ${re(`^.*/(${hfsFoldedName("config")}|${hfsFoldedName("hooks")})$`)})`,
85
+ `(require-all ${re(`^${sbplEscape(d)}/[^/]+$`)} ${CONFIG_WORKTREE_NAME.map(re).join(" ")})`,
61
86
  `(subpath ${sbplString(`${d}/hooks`)})`,
62
87
  `(require-all (subpath ${sbplString(d)}) ${re(`^.*/${hfsFoldedName("commondir")}$`)})`,
63
88
  ]);
64
- const modules = moduleGit
65
- ? [
66
- // Every name matches whole components only: `(.*/)?` forces a boundary, so a submodule named `catalogs`
67
- // or `prefs` keeps the refusal and one named `webhooks` keeps its ordinary writes (#281 round 2).
68
- `(require-all ${re(`^${sbplEscape(moduleGit.root)}/(.*/)?${hfsFoldedName("config")}$`)} (require-not ${re(`^${sbplEscape(moduleGit.root)}/(.*/)?(refs|logs)/`)}))`,
69
- `(require-all ${re(`^${sbplEscape(moduleGit.root)}/(.*/)?${hfsFoldedName("hooks")}(/|$)`)} (require-not ${re(`^${sbplEscape(moduleGit.root)}/(.*/)?(refs|logs)/`)}))`,
70
- // A submodule git dir as a directory entry: no rename, replace, symlink or recreate. The basename is
71
- // folded (HFS+ opens the folded spelling as the real entry); the plain prefix is the path as discovered.
72
- ...moduleGit.entries.map((d) => re(`^${sbplEscape(dirname(d))}/${hfsFoldedName(basename(d))}$`)),
73
- ]
89
+ // Every modules tree (#518, #520): the common dir's, a linked worktree's own, each sibling's, and the `modules/` of every discovered submodule git dir. Nothing is exempt by name:
90
+ // a `refs` or `logs` component does not make a path safe, because a later unsandboxed `git submodule update --init` reuses any git dir found at `<modules>/<name>` and runs its
91
+ // config and hooks. So under a modules root, everything that is not inside a DISCOVERED git dir is refused (that is a plant, a new or a renamed git dir), and inside each discovered
92
+ // git dir `D` the protected files are anchored to `D` itself: `D/config`, `D/config.worktree`, `D/hooks` and what is below it, and `D/worktrees/*/config.worktree`.
93
+ // Ordinary git work writes `D/refs`, `D/logs`, `D/objects`, `D/index`, `D/HEAD`, ... and none of those is refused, so no refs/logs exception is needed.
94
+ // Each literal holds either a path or a folded name, never both (the 1024-byte limit, #518 review); the folded names sit in `^.*/<name>$` filters beside a plain anchor.
95
+ const allDirs = moduleGit.flatMap((tree) => tree.dirs ?? []);
96
+ const modules = moduleGit.flatMap((tree) => {
97
+ const root = sbplEscape(tree.root);
98
+ const found = tree.dirs ?? []; // absent means nothing is exempt: everything under the root is refused, which fails closed
99
+ const inside = found.map((d) => `(require-not ${re(`^${sbplEscape(d)}(/|$)`)})`).join(" ");
100
+ const anchored = found.flatMap((d) => {
101
+ const dir = sbplEscape(d);
102
+ return [
103
+ `(require-all ${re(`^${dir}/[^/]+$`)} ${re(`^.*/${hfsFoldedName("config")}$`)})`,
104
+ `(require-all ${re(`^${dir}/[^/]+$`)} ${CONFIG_WORKTREE_NAME.map(re).join(" ")})`,
105
+ `(require-all ${re(`^${dir}/`)} ${re(hooksUnder(d))})`,
106
+ // A linked worktree of a submodule keeps its own config.worktree one level down.
107
+ `(require-all ${re(`^${dir}/worktrees/[^/]+/[^/]+$`)} ${CONFIG_WORKTREE_NAME.map(re).join(" ")})`,
108
+ // The directories on the way to those files are directory-entry targets, so none can be moved out of the protected path, edited and put back (#520 review): `worktrees`
109
+ // and each `worktrees/<name>` (no rename, replace, symlink or removal), and the `hooks` directory itself. The root and the folded name are separate filters.
110
+ `(require-all ${re(`^${dir}/[^/]+$`)} ${re(`^.*/${hfsFoldedName("worktrees")}$`)})`,
111
+ re(`^${dir}/worktrees/[^/]+$`),
112
+ `(require-all ${re(`^${dir}/[^/]+$`)} ${re(`^.*/${hfsFoldedName("hooks")}$`)})`,
113
+ // A submodule's own linked worktrees keep their `modules/` at `D/worktrees/<wt>/modules` (#520 review), inside the exemption above. Everything under one that is not inside a
114
+ // DISCOVERED git dir is refused, whether the worktree exists yet or not (the pattern needs no discovery), and the `modules` entry itself cannot be renamed or replaced.
115
+ `(require-all ${re(`^${dir}/worktrees/[^/]+/modules/.`)} ${allDirs.filter((f) => f.startsWith(`${d}/worktrees/`)).map((f) => `(require-not ${re(`^${sbplEscape(f)}(/|$)`)})`).join(" ")})`,
116
+ `(require-all ${re(`^${dir}/worktrees/[^/]+/[^/]+$`)} ${re(`^.*/${hfsFoldedName("modules")}$`)})`,
117
+ ];
118
+ });
119
+ return [
120
+ // Everything under the root that is not inside a discovered git dir. The root's own entry is a directory entry rule below, so this is the contents.
121
+ `(require-all ${re(`^${root}/.`)} ${inside})`,
122
+ ...anchored,
123
+ // A submodule git dir as a directory entry: no rename, replace, symlink or recreate. The basename is folded (HFS+ opens the folded spelling as the real entry);
124
+ // the directory and the folded name are separate filters (a ~520-byte git path went over the limit when they shared one, #518 review).
125
+ ...tree.entries.map((d) => `(require-all ${re(`^${sbplEscape(dirname(d))}/[^/]+$`)} ${re(`^.*/${hfsFoldedName(basename(d))}$`)})`),
126
+ ];
127
+ });
128
+ // Every linked worktree's git dir under `<common>/worktrees/` by pattern, not by discovery (#518): a sibling's `modules/` tree that does not exist yet, or a sibling created
129
+ // after this profile was built, is as protected as one that does. The sandbox can write the common dir, so it could otherwise pre-create them. The worktree name is one
130
+ // plain component (a folded spelling of `worktrees` or of a sibling's name on HFS+ is the same residual as the folded `modules` component above).
131
+ const siblings = worktreesRoot
132
+ ? (() => {
133
+ const w = sbplEscape(worktreesRoot);
134
+ return [
135
+ // The ancestors of every protected git dir, as directory entries: no rename, replace, symlink or recreate. Without them the container or a worktree git dir could be moved
136
+ // out of the protected path, its submodule config or hooks edited, and moved back (#518 review). Git itself never renames or removes these during ordinary work.
137
+ // The container's folded name and the directory it sits in are separate filters (one literal holding both grows with the path); a worktree git dir has any name.
138
+ `(require-all ${re(`^${sbplEscape(dirname(worktreesRoot))}/[^/]+$`)} ${re(`^.*/${hfsFoldedName(basename(worktreesRoot))}$`)})`,
139
+ re(`^${w}/[^/]+$`),
140
+ // A worktree git dir's own `config` and `config.worktree`: direct children only.
141
+ `(require-all ${re(`^${w}/[^/]+/[^/]+$`)} ${re(`^.*/${hfsFoldedName("config")}$`)})`,
142
+ `(require-all ${re(`^${w}/[^/]+/[^/]+$`)} ${CONFIG_WORKTREE_NAME.map(re).join(" ")})`,
143
+ // A worktree git dir's own `hooks` directory and everything in it, which git runs for that worktree, anchored by depth so an ancestor named `hooks` is not matched (#520).
144
+ // Its `modules/` is a different component, so this never reaches a submodule's own tree.
145
+ `(require-all ${re(`^${w}/[^/]+/`)} ${re(hooksUnder(`${worktreesRoot}/x`))})`,
146
+ // Every write under any worktree's `modules/` that is not inside a DISCOVERED submodule git dir (#520): a plant, or a tree that does not exist yet. The discovered git dirs
147
+ // (the sandboxed worktree's own, and any sibling's that exists) are exempt here and get their anchored rules from their own tree above. No name is exempt.
148
+ `(require-all ${re(`^${w}/[^/]+/modules/.`)} ${moduleGit.flatMap((tree) => tree.dirs ?? []).filter((d) => d.startsWith(`${worktreesRoot}/`)).map((d) => `(require-not ${re(`^${sbplEscape(d)}(/|$)`)})`).join(" ")})`,
149
+ `(require-all ${re(`^${w}/[^/]+/[^/]+$`)} ${re(`^.*/${hfsFoldedName("modules")}$`)})`,
150
+ ];
151
+ })()
74
152
  : [];
75
153
  return [
76
154
  `(require-all (subpath ${sbplString(root)}) ${re(`^.*/${hfsFoldedName(".agenthub")}(/|$)`)})`,
77
155
  re(`^.*/${git}$`),
78
156
  re(`^.*/${git}/${hfsFoldedName("config")}$`),
157
+ `(require-all ${re(`^.*/${git}/[^/]+$`)} ${CONFIG_WORKTREE_NAME.map(re).join(" ")})`,
79
158
  re(`^.*/${git}/${hfsFoldedName("hooks")}(/|$)`),
80
159
  `(require-all ${re(`^.*/${git}/.*$`)} ${re(`^.*/${hfsFoldedName("commondir")}$`)})`,
81
160
  ...modules,
161
+ ...siblings,
82
162
  ...external,
83
163
  ];
84
164
  }
@@ -48,38 +48,76 @@ function externalGitDirs(root: string): string[] {
48
48
  return [...new Set(dirs)].filter((d) => !d.startsWith(`${root}/`));
49
49
  }
50
50
 
51
+ /** `<common>/worktrees`: where every linked worktree keeps its git dir. The rules for it are patterns, so they need no worktree to exist (#518). */
52
+ function worktreesRoot(root: string): string | undefined {
53
+ const out = hubGitSync(["-C", root, "rev-parse", "--git-common-dir"], { encoding: "utf8" });
54
+ return out.status === 0 && out.stdout.trim() ? join(resolve(root, out.stdout.trim()), "worktrees") : undefined;
55
+ }
56
+
51
57
  /**
52
- * The project's submodule git directories live under the common dir's `modules/` tree (the worktree case included:
53
- * it is the main repo's `.git/modules`). Returns the modules root and every directory entry that must not be
54
- * renamed, replaced, symlinked or recreated: each discovered git dir (a directory holding HEAD and config) and
55
- * every component between it and the modules root (#281). Discovered in the hub process, outside the sandbox.
58
+ * One modules tree: its root, the submodule git dirs found directly under it (`dirs`, each a directory holding HEAD and config, however it is named) and every directory entry
59
+ * that must not be renamed, replaced, symlinked or recreated (the root, each git dir and every component between them, #281). Everything else under the root is refused to
60
+ * write (#520): a git dir that is not discovered here is a plant, whatever its name.
56
61
  */
57
- function moduleGitDirs(root: string): { root: string; entries: string[] } | undefined {
58
- const out = hubGitSync(["-C", root, "rev-parse", "--git-common-dir"], { encoding: "utf8" });
59
- if (out.status !== 0) return undefined;
60
- const modulesRoot = join(resolve(root, out.stdout.trim()), "modules");
61
- const dirs: string[] = [];
62
- const walk = (dir: string, depth: number): void => {
63
- if (depth > 8) return; // submodules do not nest deeper in practice; the regex rules still cover the names
64
- let entries: Dirent[];
65
- try {
66
- entries = readdirSync(dir, { withFileTypes: true });
67
- } catch {
68
- return; // no modules tree (no submodules): nothing to discover
62
+ export interface ModuleGit { root: string; entries: string[]; dirs?: string[] }
63
+
64
+ /** The deepest submodule nesting the profile follows: a git dir below it is not exempt, so a write there fails closed instead of going unprotected. */
65
+ const MODULE_DEPTH = 8;
66
+
67
+ /**
68
+ * The submodule git directories the project can reach, as modules trees. A plain checkout keeps them under the common dir's `modules/`; a LINKED worktree keeps its own under that
69
+ * worktree's git dir, `<common>/worktrees/<wt>/modules/`, so the trees are the common dir's, this worktree's own git dir's (whether or not it exists yet, so it cannot be pre-created)
70
+ * and every sibling worktree's that already has one: the common dir is writable from here (#518). Every discovered git dir also gets a tree of its own for its `modules/`, existing
71
+ * or not, so a submodule of a submodule is covered the same way (#520). Discovered in the hub process, outside the sandbox.
72
+ */
73
+ function moduleGitDirs(root: string): ModuleGit[] {
74
+ const out = hubGitSync(["-C", root, "rev-parse", "--git-common-dir", "--absolute-git-dir"], { encoding: "utf8" });
75
+ if (out.status !== 0) return [];
76
+ const [commonRaw, ownRaw] = out.stdout.trim().split("\n");
77
+ if (!commonRaw || !ownRaw) return [];
78
+ const common = resolve(root, commonRaw), own = resolve(root, ownRaw);
79
+ const roots = new Set<string>([join(common, "modules"), join(own, "modules")]);
80
+ try {
81
+ for (const e of readdirSync(join(common, "worktrees"), { withFileTypes: true })) {
82
+ if (!e.isDirectory()) continue;
83
+ const modules = join(common, "worktrees", e.name, "modules");
84
+ if (existsSync(modules)) roots.add(modules);
69
85
  }
70
- const names = new Set(entries.map((e) => e.name));
71
- if (names.has("HEAD") && names.has("config")) {
72
- dirs.push(dir);
73
- const nested = join(dir, "modules");
74
- if (names.has("modules")) walk(nested, depth + 1); // a submodule of a submodule
75
- return;
86
+ } catch { /* no linked worktrees */ }
87
+ const trees: ModuleGit[] = [];
88
+ const tree = (modulesRoot: string, nesting: number): void => {
89
+ const dirs: string[] = [];
90
+ const walk = (dir: string, depth: number): void => {
91
+ if (depth > 8) return; // a path this deep is not a submodule layout git makes
92
+ let entries: Dirent[];
93
+ try {
94
+ entries = readdirSync(dir, { withFileTypes: true });
95
+ } catch {
96
+ return; // no modules tree (no submodules): nothing to discover
97
+ }
98
+ const names = new Set(entries.map((e) => e.name));
99
+ if (names.has("HEAD") && names.has("config")) { dirs.push(dir); return; }
100
+ for (const e of entries) if (e.isDirectory()) walk(join(dir, e.name), depth + 1);
101
+ };
102
+ walk(modulesRoot, 0);
103
+ // Past the nesting limit nothing is exempt, so a write below it is refused rather than left unprotected.
104
+ const found = nesting >= MODULE_DEPTH ? [] : dirs;
105
+ const entries = new Set<string>([modulesRoot]);
106
+ for (const d of found) for (let p = d; p.length > modulesRoot.length; p = dirname(p)) entries.add(p);
107
+ trees.push({ root: modulesRoot, entries: [...entries], dirs: found });
108
+ for (const d of found) {
109
+ tree(join(d, "modules"), nesting + 1); // a submodule of a submodule, whether or not it exists yet
110
+ // A submodule is a repository: its linked worktrees keep their own modules trees at `<d>/worktrees/<wt>/modules`. Existing ones are discovered here; ones that do not exist yet
111
+ // are covered by pattern in hubWriteRegexes (#520 review).
112
+ try {
113
+ for (const e of readdirSync(join(d, "worktrees"), { withFileTypes: true })) {
114
+ if (e.isDirectory() && existsSync(join(d, "worktrees", e.name, "modules"))) tree(join(d, "worktrees", e.name, "modules"), nesting + 1);
115
+ }
116
+ } catch { /* no linked worktrees */ }
76
117
  }
77
- for (const e of entries) if (e.isDirectory()) walk(join(dir, e.name), depth + 1);
78
118
  };
79
- walk(modulesRoot, 0);
80
- const entries = new Set<string>([modulesRoot]);
81
- for (const d of dirs) for (let p = d; p.length > modulesRoot.length; p = dirname(p)) entries.add(p);
82
- return { root: modulesRoot, entries: [...entries] };
119
+ for (const modulesRoot of roots) tree(modulesRoot, 0);
120
+ return trees;
83
121
  }
84
122
 
85
123
  /** The system directories a deny-default profile lets commands read and run from: dyld, frameworks, toolchains. */
@@ -111,6 +149,7 @@ export function profile(cwd: string, network: SandboxNetwork, readAllow: string[
111
149
  const root = realPath(cwd);
112
150
  const gitDirs = externalGitDirs(root);
113
151
  const moduleGit = moduleGitDirs(root);
152
+ const worktrees = worktreesRoot(root);
114
153
  const creds = [".ssh", ".aws", ".gnupg", ".config/gh", ".config/gcloud", ".kube", ".docker", ".netrc", ".npmrc", ".omniroute", ".claude", ".codex", ".kimi-code", "Library/Keychains"];
115
154
  const dev = developerDir();
116
155
  const readable = [root, ...HOME_READABLE.map((p) => join(home, p)), ...readAllow.map(inHome), ...gitDirs, ...(dev ? [dev] : [])];
@@ -144,7 +183,7 @@ export function profile(cwd: string, network: SandboxNetwork, readAllow: string[
144
183
  // at any depth (creation, rename, symlink, gitfile), a submodule git dir's config and hooks under
145
184
  // .git/modules/ are refused at the depth submodules nest (#281), and the external git dirs get folded
146
185
  // hooks/config rules (#270). The cost: git init, clone and worktree add no longer run inside the sandbox.
147
- `(deny file-write* ${hubWriteRegexes(root, gitDirs, moduleGit).join(" ")})`,
186
+ `(deny file-write* ${hubWriteRegexes(root, gitDirs, moduleGit, worktrees).join(" ")})`,
148
187
  `(deny file-read* file-write* ${creds.map((c) => `(subpath ${q(join(home, c))})`).join(" ")})`,
149
188
  `(deny file-read* file-write* ${denyRegexes(root, deny).join(" ")})`,
150
189
  // Python's own CA bundle (certifi, which pip vendors too): pip, requests and httpx read it instead of the system's (#64).
@@ -0,0 +1,10 @@
1
+ import { homedir } from "node:os";
2
+
3
+ export interface HostShellEnvironment extends Record<string, string> { PATH: string; HOME: string; LANG: string; TERM: string }
4
+
5
+ /** The #816 executor's fixed baseline, shared by the proof and host wrapper; never inherit a native agent's environment.
6
+ * ponytail: Seatbelt's accepted source stays byte-identical and adds TMPDIR separately; a future shared-executor
7
+ * extraction must preserve the runtime environment parity test and explicitly revise that source's byte guard. */
8
+ export function hostShellEnvironment(extra: Readonly<Record<string, string>> = {}): HostShellEnvironment {
9
+ return { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: homedir(), LANG: process.env.LANG ?? "en_US.UTF-8", TERM: "dumb", ...extra };
10
+ }
package/src/ui/index.html CHANGED
@@ -1674,6 +1674,7 @@ function render(snapshot) {
1674
1674
  if (peer.pullOnly && adapterDisplay[id]?.pullOnlyRead) more.append(el('p', 'Last hub_inbox read: ' + (typeof peer.lastInboxAt === 'number' ? Math.max(0, Math.floor((Date.now() - peer.lastInboxAt) / 1000)) + 's ago' : 'never'), 'meta'));
1675
1675
  if (peer.paused) more.append(el('p', peer.paused, 'meta'));
1676
1676
  if (peer.requestedModel) more.append(el('p', 'Requested: ' + peer.requestedModel, 'meta'));
1677
+ if (peer.reviewScope === 'code' || peer.reviewScope === 'full') more.append(el('p', 'Review scope: ' + (peer.reviewScope === 'code' ? 'code only (cannot run tests or read CI; the merger gates on exact-head CI)' : 'full'), 'meta'));
1677
1678
  if (typeof peer.modelSource === 'string' && peer.modelSource) more.append(el('p', 'Model source: ' + peer.modelSource, 'meta'));
1678
1679
  for (const backend of peer.backends || []) {
1679
1680
  if (backend.requestedModel) more.append(el('p', backend.kind.toUpperCase() + ': ' + (backend.actualModel || 'model not reported') + ' (' + backend.state + ')', 'meta'));