@staix/agent-hub 0.19.0 → 0.20.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 (41) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +2 -2
  3. package/docs/agent-notes/adapters.md +3 -2
  4. package/docs/agent-notes/bus.md +1 -1
  5. package/docs/agent-notes/daemon.md +6 -2
  6. package/docs/agent-notes/local-worker.md +1 -0
  7. package/docs/agent-notes/tasks.md +4 -2
  8. package/docs/agent-notes/tests.md +4 -3
  9. package/docs/dogfood.md +4 -1
  10. package/docs/memory.md +1 -1
  11. package/docs/operations.md +40 -8
  12. package/docs/quickstart.md +7 -3
  13. package/docs/sdlc.md +12 -0
  14. package/docs/security.md +4 -4
  15. package/docs/specs/2026-09-19-agent-hub-design.md +85 -3
  16. package/docs/verification/2026-10-27-0.20.0.md +75 -0
  17. package/docs/verified.json +24 -16
  18. package/package.json +2 -2
  19. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  20. package/plugins/agent-hub/server.js +103 -7
  21. package/src/adapters/acp.ts +8 -0
  22. package/src/adapters/channel-peer.ts +7 -5
  23. package/src/adapters/claude-implementer.ts +1 -0
  24. package/src/adapters/codex-appserver.ts +11 -1
  25. package/src/adapters/conformance/enrollment.ts +4 -4
  26. package/src/adapters/conformance/factories.ts +2 -1
  27. package/src/adapters/conformance/kit.ts +10 -0
  28. package/src/adapters/local-worker.ts +2 -0
  29. package/src/adapters/pi.ts +7 -0
  30. package/src/adapters/reviewer-print.ts +1 -0
  31. package/src/hub/ci-watch.ts +140 -0
  32. package/src/hub/claude-launch-proof.ts +67 -2
  33. package/src/hub/config-apply.ts +2 -2
  34. package/src/hub/config-trust.ts +1 -0
  35. package/src/hub/daemon.ts +86 -79
  36. package/src/hub/peers.ts +34 -0
  37. package/src/hub/settings.ts +1 -0
  38. package/src/hub/task-shell-default.ts +3 -2
  39. package/src/hub/tasks.ts +139 -7
  40. package/src/local/deny.ts +21 -0
  41. package/src/local/sandbox.ts +3 -1
package/src/hub/tasks.ts CHANGED
@@ -49,6 +49,7 @@ import { coverageGap, renderReviewBrief, type ReviewBrief } from "./review-brief
49
49
  import { definitionOf as builtinDefinitionOf } from "../adapters/registry.ts";
50
50
  import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
51
51
  import { plannerGrooming, nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
52
+ import { CiWatchers, pollCi, type CiOutcome, type CiWatch } from "./ci-watch.ts";
52
53
  import { planningIssueReceipt, linkedReceipt, openPrFor, readPrCostHistory, readReviewReadiness, reviewReadinessFromEvidence, type PrEvidence, type ReviewReadiness, readPlanIssueMetadata, canonicalStage, checklistOf, readSdlcPr, readSpecEdit, sdlcConfig, sdlcReceipt, sdlcStatus, specState, SPEC_STATE_TEXT, stageDefinition, lifecycleStage, type SdlcConfig, type MergeRecord, type PlanIssueMetadata, sdlcGh, type SdlcGh, type SdlcStatus, type SpecEdit, type SpecState } from "./sdlc.ts";
53
54
 
54
55
  export type TaskAssignmentRole = "owner" | "reviewer" | "planner";
@@ -98,6 +99,10 @@ export interface TasksDeps {
98
99
  /** Host-verified existing symbols at this task's admitted completion head; never plan guesses. */
99
100
  memorySymbolNodes?: (task: Task) => MemorySymbolNodes | undefined;
100
101
  sdlc?: { config: () => SdlcConfig; gh?: SdlcGh };
102
+ /** #785 P1b: the per-head CI watcher's bounds; tests shorten them, the defaults are `CI_WATCH_*` in `ci-watch.ts`. */
103
+ ciWatch?: { deadlineMs?: number; pollMs?: number };
104
+ /** #785 P1c: `tasks.auto_pull`, read at each capacity release so a reload applies to the next one; absent or false pulls nothing. */
105
+ autoPull?: () => boolean;
101
106
  briefs?: Briefs;
102
107
  /** Screened live fail notes directly intersecting the task plan or refs; read-only advice. */
103
108
  planFails?: (task: Task) => NativeItem[];
@@ -207,6 +212,7 @@ const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep", "memor
207
212
  QUIET_EVENTS.add("ready held"); // #655: the hub's note that a ready task waits for its reserved owner's WIP slot.
208
213
  QUIET_EVENTS.add("plan observed"); // Host observation does not move the task or replace completion intent.
209
214
  QUIET_EVENTS.add("planner changed"); // Planning authority does not move the execution owner or revoke its handoff.
215
+ QUIET_EVENTS.add("shell branch bound"); // #880: the hub's host-observed branch binding (#845) is bookkeeping; it neither moves nor settles the task.
210
216
  /** The records of the `done (checking)` entry a passed check now completes: what the owner delivered then (#509a). */
211
217
  const latestPendingDeliverables = (task: Task): DeliverableRecord[] | undefined => {
212
218
  const checking = task.history.findLastIndex(entry => entry.event === "done (checking)");
@@ -354,8 +360,17 @@ export class Tasks {
354
360
  private sweepConfig: TaskSweepConfig;
355
361
  private readonly noWriteTurns = new NoWriteTurns();
356
362
  private sweeping = false;
363
+ /** #785 P1b: one bounded CI watcher per exact PR head, and the reviews the hub held as premature, by task id. */
364
+ private readonly ciWatchers: CiWatchers;
365
+ private readonly heldReviews = new Map<number, { head: string; reviewer: PeerId; revision: number }>();
357
366
 
358
367
  constructor(private readonly d: TasksDeps) {
368
+ this.ciWatchers = new CiWatchers({
369
+ ...d.ciWatch,
370
+ poll: watch => pollCi(watch, watch.head, d.sdlc?.config().requiredChecks ?? [], d.sdlc?.gh ?? sdlcGh),
371
+ current: watch => this.ciWatchCurrent(watch),
372
+ finish: (watch, outcome) => this.ciCompleted(watch, outcome),
373
+ });
359
374
  d.board.trackPlanRevisions = () => (d.sdlc?.config().mode ?? "off") !== "off";
360
375
  this.repositories = new TaskRepositories(d.cwd, d.repository);
361
376
  d.board.proposalPlanner = task => this.proposalPlanner(task);
@@ -1092,7 +1107,8 @@ export class Tasks {
1092
1107
  if (id !== undefined) change.ready.add(id);
1093
1108
  changes.set(planner, change);
1094
1109
  };
1095
- for (const [peer, used] of before) if (this.wip(peer) < used) for (const planner of this.taskPlanner(task)) add(planner, peer);
1110
+ const released = new Set<PeerId>();
1111
+ for (const [peer, used] of before) if (this.wip(peer) < used) { released.add(peer); for (const planner of this.taskPlanner(task)) add(planner, peer); }
1096
1112
  if (approved) for (const card of this.d.board.list("backlog")) {
1097
1113
  if (card.deps?.includes(task.id) && !this.waitsFor(card).length) for (const planner of this.taskPlanner(card)) add(planner, undefined, card.id);
1098
1114
  }
@@ -1107,9 +1123,7 @@ export class Tasks {
1107
1123
  }
1108
1124
  const lines: string[] = [];
1109
1125
  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];
1126
+ const top = this.topReservedReady(peer, planner);
1113
1127
  lines.push(`capacity_released: ${peer} WIP ${this.wip(peer)}; reserved ready ${top ? `#${top.id}` : "none"}`);
1114
1128
  }
1115
1129
  for (const id of change.ready) {
@@ -1121,9 +1135,54 @@ export class Tasks {
1121
1135
  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
1136
  if (!this.d.bus.publish(env).includes(planner)) throw new Error("the notice was not queued");
1123
1137
  });
1138
+ // #785 P1c: only this write's own release, never a merged earlier one, and only when the person turned it on.
1139
+ if (this.d.autoPull?.() === true) this.queueAutoPulls(released);
1124
1140
  });
1125
1141
  }
1126
1142
 
1143
+ /** The top card explicitly reserved for `peer` that this planner may pull now; the notice and the opt-in pull read the same rule. `pullable` keeps the staged backlog cards only. */
1144
+ private topReservedReady(peer: PeerId, planner?: PeerId, pullable = false): Task | undefined {
1145
+ return this.d.board.list().filter(card => (pullable ? card.state === "backlog" : ["backlog", "proposed"].includes(card.state)) && !card.owner && card.reserved === peer
1146
+ && (planner === undefined || this.taskPlanner(card).includes(planner)) && !(pullable && this.isPii(card)) && !card.pinned && taskCanPlan(card) && !this.waitsFor(card).length && !this.excluded(card).includes(peer)
1147
+ && (card.release == null || ["planning", "active"].includes(this.d.board.release(card.release)?.state ?? ""))).sort(this.readyOrder)[0];
1148
+ }
1149
+
1150
+ /**
1151
+ * #785 P1c: `tasks.auto_pull`. A slot freed for a peer pulls the one top backlog card explicitly reserved for it, through `pullPlanned` as the hub acting for that card's planner of
1152
+ * record: revision, dependency, reservation, pinned, frozen-release, P0 order and WIP checks all run there unchanged. One card per release. Never an unreserved card, never PII.
1153
+ * Serialized on one chain, so two releases cannot pick the same card; each job re-reads the board, so a card that moved meanwhile is skipped.
1154
+ */
1155
+ private autoPulls: Promise<void> = Promise.resolve();
1156
+ private autoPullsOpen = 0;
1157
+ /** Queued or running auto pulls, so a recovery commit waits for them the way it waits for `checksPending` (#785 P1c): one that is mid-admission would otherwise write after the commit's digest. */
1158
+ autoPullsPending = (): number => this.autoPullsOpen;
1159
+ private queueAutoPulls(released: ReadonlySet<PeerId>): void {
1160
+ for (const peer of released) {
1161
+ this.autoPullsOpen++;
1162
+ this.autoPulls = this.autoPulls.then(() => this.autoPull(peer)).catch(() => { /* autoPull reports its own refusals */ }).finally(() => { this.autoPullsOpen--; });
1163
+ }
1164
+ }
1165
+ /** Whether the hub may still start work on its own: the setting is on, and no recovery, shutdown or storage hold is active. Read at entry and again right before the assignment write. */
1166
+ private autoPullAdmitted(): boolean {
1167
+ return this.d.autoPull?.() === true && !this.d.sweepHeld?.() && !this.d.bus.isRecoveryHeld && !this.d.bus.storageError;
1168
+ }
1169
+ private async autoPull(peer: PeerId): Promise<void> {
1170
+ if (!this.autoPullAdmitted()) return;
1171
+ const card = this.topReservedReady(peer, undefined, true);
1172
+ if (!card) return;
1173
+ // The planner of record is the card's own: its bound planner, else the first current holder, and it must be rostered. A private card never gets here (topReservedReady skips it).
1174
+ const planner = this.taskPlanner(card)[0];
1175
+ if (!planner || !this.d.bus.isRostered(planner)) return;
1176
+ const reason = `auto_pull: capacity released for ${peer}; hub acted for planner ${planner}`;
1177
+ try {
1178
+ await this.pullPlanned(HUB, { id: card.id, revision: card.planningRevision ?? 0, reason }, { onBehalfOf: planner, guard: () => { if (!this.autoPullAdmitted()) throw new PreEffectToolRefusal("auto pull is no longer admitted: tasks.auto_pull was turned off or a recovery, shutdown or storage hold began"); } });
1179
+ } catch (e) {
1180
+ // A guard refusal is the setting or a hold doing its job, not a failure to report.
1181
+ if (!this.autoPullAdmitted()) return;
1182
+ this.releaseNotice(`task ${this.publicTitle(card)}: auto_pull for ${peer} refused: ${e instanceof Error ? e.message : String(e)}`);
1183
+ }
1184
+ }
1185
+
1127
1186
  /** #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()`. */
1128
1187
  wipBreakdown(peers: readonly PeerId[]): Record<string, { wip: number; active: number; reviewWait: number; reserved: number; wipLimit: number }> {
1129
1188
  const grouped = this.d.board.plannerWipBreakdown();
@@ -1165,15 +1224,20 @@ export class Tasks {
1165
1224
  return assign({ ...task, state: "proposed" }, states, routing, { exclude: this.excluded(task), excludeOwners: full, ...(reservedOnly && task.reserved ? { candidates: [task.reserved], namedOwner: task.reserved } : {}), waitsFor: this.waitsFor(task), ...this.weights(task.class, task) });
1166
1225
  }
1167
1226
 
1168
- async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
1227
+ async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }, opts: { onBehalfOf?: PeerId; guard?: () => void } = {}): Promise<Task> {
1169
1228
  // #455: one authority check, made before the screen and again after it (a person may move the planner meanwhile).
1170
- const authorized = () => this.planningAuthority(by);
1229
+ // #785 P1c: `by` is the actor the history records; the hub's opt-in auto pull names the planner it acts for, and that planner's authority is what is checked.
1230
+ if (opts.onBehalfOf !== undefined && by !== HUB) throw new PreEffectToolRefusal("only the hub pulls on a planner's behalf");
1231
+ const planner = opts.onBehalfOf ?? by;
1232
+ // `opts.guard` (the auto pull's own admission, #785 P1c) throws a PreEffectToolRefusal once the pull is no longer wanted; it runs wherever authority is rechecked, so it
1233
+ // also runs after the awaited PII screen and right before the assignment write, where a setting turned off or a recovery that began meanwhile must still stop it.
1234
+ const authorized = () => { this.planningAuthority(planner); opts.guard?.(); };
1171
1235
  authorized();
1172
1236
  if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0 || typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("planner pull needs an id, revision and bounded reason");
1173
1237
  const validate = () => {
1174
1238
  const task = this.d.board.get(input.id);
1175
1239
  if (!task || (task.planningRevision ?? 0) !== input.revision || task.state !== "backlog" || task.owner || !taskCanPlan(task) || task.pinned) throw new PreEffectToolRefusal("planner pull changed, pinned or already started");
1176
- this.planningAuthority(by, task);
1240
+ this.planningAuthority(planner, task);
1177
1241
  const release = task.release == null ? undefined : this.d.board.release(task.release);
1178
1242
  if (task.release != null && (!release || ["frozen", "shipped", "dropped"].includes(release.state))) throw new PreEffectToolRefusal("planner pull requires unfrozen release scope");
1179
1243
  if (this.waitsFor(task).length) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
@@ -1207,6 +1271,7 @@ export class Tasks {
1207
1271
  if (checked.task.history.length !== current.task.history.length || checked.owner !== current.owner) throw new PreEffectToolRefusal("planner pull changed during admission");
1208
1272
  } } });
1209
1273
  }
1274
+ opts.guard?.();
1210
1275
  const pulled = this.d.board.pullPlanned(current.task.id, by, current.owner, input.revision, input.reason.trim(), withheld, this.plannerBinding(current.task, current.assignment, true));
1211
1276
  this.planningRecorded({ by, task: pulled.id, fields: ["pull", "reserved"], revision: pulled.planningRevision! });
1212
1277
  return pulled;
@@ -2705,6 +2770,7 @@ export class Tasks {
2705
2770
  if (task.history.length !== revision) throw new PreEffectToolRefusal("task flow revision changed");
2706
2771
  const cancelled = { reason: input.reason.trim(), ...(input.supersededBy ? { supersededBy: input.supersededBy.trim() } : {}), flowRevision: revision + 1, ...(withheld ? { withheld: true as const } : {}) };
2707
2772
  const capacity = this.capacityBefore(task);
2773
+ this.ciWatchers.cancel(task.id); this.heldReviews.delete(task.id);
2708
2774
  const next = this.d.board.update(task.id, by, "cancelled", { state: "cancelled", reserved: null, cancelled }, cancelled.reason, { ...(withheld ? { withheld: true as const } : {}) }, revision);
2709
2775
  this.writePlannerNotices(next, capacity);
2710
2776
  this.releaseNotice(`task #${task.id}: cancellation cohort cleanup unavailable`, () => this.cohorts.leave(task.id));
@@ -4139,6 +4205,7 @@ export class Tasks {
4139
4205
  const capacity = this.capacityBefore(task);
4140
4206
  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
4207
  this.writePlannerNotices(next, capacity, next.state === "approved");
4208
+ this.ciWatchers.cancel(next.id); this.heldReviews.delete(next.id); // a newer head or a move ends the old head's watcher (#785 P1b)
4142
4209
  this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
4143
4210
  const memorySymbols = this.completionMemorySymbols(next);
4144
4211
  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);
@@ -4151,6 +4218,7 @@ export class Tasks {
4151
4218
  }
4152
4219
  else if (reviewer === USER) this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}: review it in the ahub console (Tasks, select #${next.id}, press r) or with ahub review ${next.id} approved|changes_requested [note]`);
4153
4220
  else this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${reviewer}`, () => this.sendReview(next, reviewer, "", admission));
4221
+ if (reviewer && !(admission?.ready && admission.head === this.reviewHead(next))) this.watchCi(next);
4154
4222
  return next;
4155
4223
  }
4156
4224
 
@@ -4316,6 +4384,7 @@ export class Tasks {
4316
4384
  if (mode === "enforce" && !admission?.ready) {
4317
4385
  // Background roster/quota role changes are retained, but never invent a remotely admitted review request.
4318
4386
  this.releaseNotice(`task ${this.publicTitle(task)}: premature review held; retry the reviewer assignment after current CI and bot replies are verified`);
4387
+ this.holdReview(task, reviewer);
4319
4388
  return false;
4320
4389
  }
4321
4390
 
@@ -4395,6 +4464,67 @@ export class Tasks {
4395
4464
  return this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, ...(this.reviewHead(task) ? { commit: this.reviewHead(task)! } : {}), task: String(task.id), reviewRevision: String(this.reviewRequestRevision(task)) }, ...(this.isPii(task) ? { private: true } : {}) })).includes(reviewer);
4396
4465
  }
4397
4466
 
4467
+ /** #785 P1b: remember a review the hub held as premature, so a green CI at this exact head can re-offer it once. Also watches that head. */
4468
+ private holdReview(task: Task, reviewer: PeerId): void {
4469
+ try {
4470
+ const head = this.reviewHead(task);
4471
+ if (this.isPii(task) || !head || reviewer === USER) return;
4472
+ this.heldReviews.set(task.id, { head, reviewer, revision: this.reviewRequestRevision(task) });
4473
+ this.watchCi(task);
4474
+ } catch { /* optional bookkeeping never fails a review path */ }
4475
+ }
4476
+
4477
+ /** #785 P1b AC3: start the one watcher for this task's exact head. Not for private work, an unlinked PR or a lifecycle that is off; never throws. */
4478
+ private watchCi(task: Task): void {
4479
+ try {
4480
+ const head = this.reviewHead(task), repo = task.sdlc?.repo, pr = task.sdlc?.pr;
4481
+ if (!this.d.sdlc || this.d.sdlc.config().mode === "off" || this.isPii(task) || task.state !== "in_review" || !head || !/^[0-9a-f]{40}$/.test(head) || !repo || !pr) return;
4482
+ this.ciWatchers.start({ id: task.id, head, repo, pr });
4483
+ } catch { /* a watcher that cannot start leaves the review path as it was */ }
4484
+ }
4485
+
4486
+ /** The watcher's own check before every read and every notice: the task must still be in review at this exact head and PR. */
4487
+ private ciWatchCurrent(watch: CiWatch): boolean {
4488
+ const task = this.d.board.get(watch.id);
4489
+ return !!task && task.state === "in_review" && !this.isPii(task) && this.reviewHead(task) === watch.head && task.sdlc?.repo === watch.repo && task.sdlc?.pr === watch.pr
4490
+ && (this.d.sdlc?.config().mode ?? "off") !== "off";
4491
+ }
4492
+
4493
+ /**
4494
+ * #785 P1b: the result of the exact head's CI, told once to the owner and the reviewer (one ids-only task envelope each, `status` weight: `fyi` is recorded but never delivered to the peer),
4495
+ * then a review held as premature is offered once more after a fresh readiness read. A deadline tells the console only.
4496
+ */
4497
+ private async ciCompleted(watch: CiWatch, outcome: CiOutcome): Promise<void> {
4498
+ const task = this.d.board.get(watch.id);
4499
+ if (!task || !this.ciWatchCurrent(watch)) return;
4500
+ const label = `#${task.id} head ${watch.head.slice(0, 12)}`;
4501
+ if (outcome === "deadline") { this.releaseNotice(`task ${label}: CI did not finish within the watch deadline; no further reads`); return; }
4502
+ const line = `ci_completed: ${label} ${outcome}`;
4503
+ for (const peer of new Set([task.owner, task.reviewer])) {
4504
+ if (!peer || peer === HUB) continue;
4505
+ if (peer === USER) { this.releaseNotice(`task ${label}: CI ${outcome} at its exact head`); continue; }
4506
+ this.releaseNotice(`task ${label}: could not tell ${peer} the CI result`, () => this.d.bus.publish(newEnvelope(HUB, `${line}\nRead hub_task_show for the task before acting.`, { to: [peer], kind: "task", priority: "status", refs: { task: String(task.id), commit: watch.head } })));
4507
+ }
4508
+ if (outcome === "failed") return;
4509
+ await this.reofferHeldReview(task, watch);
4510
+ }
4511
+
4512
+ /** The held review of this exact head and request, once: only while it is still the same reviewer, head and revision, and a fresh admission says it is ready. */
4513
+ private async reofferHeldReview(task: Task, watch: CiWatch): Promise<void> {
4514
+ const held = this.heldReviews.get(task.id);
4515
+ if (!held || held.head !== watch.head || task.reviewer !== held.reviewer || this.reviewRequestRevision(task) !== held.revision) return;
4516
+ this.heldReviews.delete(task.id); // once: a later hold needs a new head or a new request
4517
+ try {
4518
+ const admission = await this.reviewRequestAdmission(task, undefined, false, task.owner ?? USER);
4519
+ const current = this.d.board.get(task.id);
4520
+ if (!admission?.ready || !current || !this.ciWatchCurrent(watch) || current.reviewer !== held.reviewer || this.reviewRequestRevision(current) !== held.revision) return;
4521
+ this.releaseNotice(`task ${this.publicTitle(current)}: could not re-offer the held review to ${held.reviewer}`, () => this.sendReview(current, held.reviewer, "(Held earlier because exact-head CI was not green; it is green now.)", admission));
4522
+ } catch { /* still not ready: the hold notice already named why; a reviewer reassignment retries it */ }
4523
+ }
4524
+
4525
+ /** Daemon shutdown: no watcher outlives the hub. */
4526
+ stopCiWatchers(): void { this.ciWatchers.stopAll(); this.heldReviews.clear(); }
4527
+
4398
4528
  private reviewRequestRevision(task: Task): number {
4399
4529
  // Role changes fence older requests; bookkeeping appended after the request does not.
4400
4530
  return task.history.findLastIndex(entry => ["done", "reviewer changed", "reviewer unavailable"].includes(entry.event) || OWNERSHIP_EVENTS.has(entry.event)) + 1;
@@ -4643,6 +4773,7 @@ export class Tasks {
4643
4773
  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 } : {}) };
4644
4774
  if (verdict === "approved") {
4645
4775
  const capacity = this.capacityBefore(task);
4776
+ this.ciWatchers.cancel(task.id); this.heldReviews.delete(task.id);
4646
4777
  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
4778
  this.writePlannerNotices(next, capacity, next.state === "approved");
4648
4779
  if (next.owner) {
@@ -4663,6 +4794,7 @@ export class Tasks {
4663
4794
  this.releaseNotice(`task #${next.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(next, by, parsed, withheld));
4664
4795
  return next;
4665
4796
  }
4797
+ this.ciWatchers.cancel(task.id); this.heldReviews.delete(task.id);
4666
4798
  const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: evidenceOnly ? task.rejections : task.rejections + 1 }, note, flag);
4667
4799
  void this.mirrorVerdict(rejected, by, "changes_requested", reviewedHead ?? rejected.sdlc?.reviewedHead, note, withheld);
4668
4800
  if (!evidenceOnly) this.releaseNotice(`task #${rejected.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(rejected, by, parsed, withheld));
package/src/local/deny.ts CHANGED
@@ -163,6 +163,27 @@ export function hubWriteRegexes(root: string, gitDirs: string[] = [], moduleGit:
163
163
  ];
164
164
  }
165
165
 
166
+ /**
167
+ * Full SBPL rule lines for the shared object store `<common>/objects` (#864). Every checkout of the repository, and the hub's own host add/commit/push (#845), trusts what is
168
+ * there without rehashing it, so a sandboxed command may only ADD loose objects: `git add` and `git commit` keep working, and git skips writing an object that exists. Refused:
169
+ * every write under `objects/info` (alternates, http-alternates, commit-graph) and `objects/pack` (a new pack wins over a loose copy of the same id, since git reads packs
170
+ * first); the `objects`, `objects/info` and `objects/pack` entries themselves (no rename, replace or removal); removing or renaming a fan-out directory; and changing,
171
+ * removing, chmod-ing or renaming over an existing loose object. The cost: `git gc`, `git repack`, `git fetch`, `git maintenance` and `git commit-graph write` do not run in
172
+ * the sandbox. A hardlink to an existing loose object is refused under these rules (verified, into the project and into the command's own temp dir). Residual: an object id
173
+ * that does not exist yet can be planted before git writes it (the attacker must predict its exact content); a local checkout shows it, a push of it is rejected by the
174
+ * receiving side's rehash. Seatbelt ignores `{n}`
175
+ * repeats, so the two fan-out hex digits are written out (verified with the real sandbox-exec).
176
+ */
177
+ export function objectStoreRules(common: string): string[] {
178
+ const objects = `${common}/objects`, o = sbplEscape(objects);
179
+ const re = (s: string) => `(regex ${sbplString(s)})`;
180
+ return [
181
+ `(deny file-write* (subpath ${sbplString(`${objects}/info`)}) (subpath ${sbplString(`${objects}/pack`)}) ${[objects, `${objects}/info`, `${objects}/pack`].map((p) => `(literal ${sbplString(p)})`).join(" ")})`,
182
+ `(deny file-write-unlink ${re(`^${o}/[0-9a-f][0-9a-f]$`)})`,
183
+ `(deny file-write-data file-write-unlink file-write-mode file-write-flags ${re(`^${o}/[0-9a-f][0-9a-f]/[0-9a-f]+$`)})`,
184
+ ];
185
+ }
186
+
166
187
  /**
167
188
  * Seatbelt regex filters equivalent to isDenied. Seatbelt sees absolute paths, so the `local.deny` substrings are
168
189
  * anchored under the project root: a bare "private/" would otherwise match /private/var/... and deny the whole temp tree.
@@ -4,7 +4,7 @@ import { homedir, tmpdir } from "node:os";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { realPath } from "../hub/project.ts";
6
6
  import { hubGitSync } from "../hub/git.ts";
7
- import { denyRegexes, hubWriteRegexes, sbplString } from "./deny.ts";
7
+ import { denyRegexes, hubWriteRegexes, objectStoreRules, sbplString } from "./deny.ts";
8
8
 
9
9
  const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
10
10
  export const OUTPUT_CAP = 20_000;
@@ -184,6 +184,8 @@ export function profile(cwd: string, network: SandboxNetwork, readAllow: string[
184
184
  // .git/modules/ are refused at the depth submodules nest (#281), and the external git dirs get folded
185
185
  // hooks/config rules (#270). The cost: git init, clone and worktree add no longer run inside the sandbox.
186
186
  `(deny file-write* ${hubWriteRegexes(root, gitDirs, moduleGit, worktrees).join(" ")})`,
187
+ // The shared object store: only new loose objects may be added (#864).
188
+ ...(worktrees ? objectStoreRules(dirname(worktrees)) : []),
187
189
  `(deny file-read* file-write* ${creds.map((c) => `(subpath ${q(join(home, c))})`).join(" ")})`,
188
190
  `(deny file-read* file-write* ${denyRegexes(root, deny).join(" ")})`,
189
191
  // Python's own CA bundle (certifi, which pip vendors too): pip, requests and httpx read it instead of the system's (#64).