@staix/agent-hub 0.28.0 → 0.30.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 (90) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +5 -3
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +2 -1
  5. package/docs/agent-notes/bus.md +1 -0
  6. package/docs/agent-notes/daemon.md +4 -1
  7. package/docs/agent-notes/local-worker.md +3 -1
  8. package/docs/agent-notes/tasks.md +7 -3
  9. package/docs/agent-notes/tests.md +3 -0
  10. package/docs/design/adapters.md +56 -0
  11. package/docs/design/agent-hub-architecture.md +12 -0
  12. package/docs/design/bus.md +8 -0
  13. package/docs/design/task-board.md +19 -1
  14. package/docs/dogfood.md +13 -7
  15. package/docs/operations.md +177 -20
  16. package/docs/quickstart.md +18 -3
  17. package/docs/sdlc.md +2 -2
  18. package/docs/security.md +45 -4
  19. package/docs/smoke.md +13 -2
  20. package/docs/specs/2026-10-12-office-view-design.md +86 -3
  21. package/docs/verification/2026-11-03-0.29.0.md +59 -0
  22. package/docs/verification/2026-11-03-0.30.0.md +54 -0
  23. package/docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md +42 -0
  24. package/docs/verification/2026-11-03-agy-1.4.12.md +90 -0
  25. package/docs/verification/2026-11-03-agy-tui-close.md +49 -0
  26. package/docs/verification/2026-11-03-control-write-stall-windows.json +241 -0
  27. package/docs/verification/2026-11-03-control-write-stall-windows.md +20 -0
  28. package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
  29. package/docs/verification/2026-11-03-live-board-latency.json +556 -0
  30. package/docs/verification/2026-11-03-live-board-latency.md +64 -0
  31. package/docs/verification/2026-11-03-pi-150-compat.md +40 -0
  32. package/docs/verification/2026-11-03-pi-title-api.md +26 -0
  33. package/docs/verification/2026-11-03-qwen-0265-acp.md +55 -0
  34. package/docs/verification/2026-11-03-qwen-0265-dual-output.md +40 -0
  35. package/docs/verification/dogfood/2026-11-03.md +3095 -0
  36. package/docs/verification/pi-150-model-free.ts +57 -0
  37. package/docs/verification/pi-150-native.mjs +21 -0
  38. package/docs/verification/pi-150-title-api.mjs +31 -0
  39. package/docs/verified.json +14 -14
  40. package/package.json +2 -2
  41. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  42. package/plugins/agent-hub/server.js +715 -45
  43. package/src/adapters/compat.ts +10 -10
  44. package/src/adapters/definitions/builtin-metadata.ts +2 -2
  45. package/src/adapters/local-worker.ts +22 -6
  46. package/src/adapters/pi.ts +92 -10
  47. package/src/adapters/registry-types.ts +4 -0
  48. package/src/adapters/registry.ts +2 -0
  49. package/src/cli/autonomy.ts +66 -1
  50. package/src/cli/console-state.ts +6 -2
  51. package/src/cli/help.ts +3 -1
  52. package/src/cli/identity.ts +8 -1
  53. package/src/cli/main.ts +39 -0
  54. package/src/cli/output.ts +5 -0
  55. package/src/cli/recovery-package.ts +9 -0
  56. package/src/cli/worktree-tidy.ts +158 -0
  57. package/src/cli/worktrees-output.ts +43 -0
  58. package/src/hub/board.ts +31 -5
  59. package/src/hub/bus.ts +4 -4
  60. package/src/hub/conductor.ts +4 -3
  61. package/src/hub/config-apply.ts +2 -1
  62. package/src/hub/control-client.ts +16 -1
  63. package/src/hub/control-write-trace.ts +409 -0
  64. package/src/hub/daemon.ts +408 -66
  65. package/src/hub/dogfood-read.ts +62 -9
  66. package/src/hub/hub-tools.ts +12 -6
  67. package/src/hub/manager.ts +9 -1
  68. package/src/hub/peer-doing.ts +33 -3
  69. package/src/hub/peers.ts +7 -0
  70. package/src/hub/routing.ts +9 -2
  71. package/src/hub/runtime-capture-prune.ts +4 -1
  72. package/src/hub/sdlc-release-authorization.ts +13 -4
  73. package/src/hub/sdlc-release.ts +23 -10
  74. package/src/hub/sdlc.ts +15 -2
  75. package/src/hub/settings.ts +3 -0
  76. package/src/hub/task-worktree.ts +331 -0
  77. package/src/hub/tasks.ts +192 -25
  78. package/src/hub/ui.ts +2 -0
  79. package/src/hub/worktree-prs.ts +100 -0
  80. package/src/hub/worktree-shelf.ts +246 -0
  81. package/src/local/deny.ts +49 -0
  82. package/src/local/tools.ts +4 -2
  83. package/src/memory/brief.ts +5 -0
  84. package/src/memory/capture.ts +1 -1
  85. package/src/memory/client.ts +5 -2
  86. package/src/pi/extension.ts +43 -3
  87. package/src/ui/index.html +133 -10
  88. package/src/ui/office-model.ts +64 -8
  89. package/src/ui/office-space.ts +1 -0
  90. package/src/ui/plugin.ts +14 -3
package/src/hub/tasks.ts CHANGED
@@ -68,13 +68,14 @@ import { parseClaimVerdict, claimListId, CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEY
68
68
  import type { Bus } from "./bus.ts";
69
69
  import { HUB, newEnvelope, NOTE_KINDS, noteLine, sanitize, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
70
70
  import type { PiiVerdict, ScreenItem, ScreenRecord } from "./inference.ts";
71
- import { assign, isRemotePeerId, detectSignals, peerEntry, predictSplit, relayCandidates, type Assignment, type InstanceRoutingOptions, type PeerCapabilities, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
71
+ import { assign, isRemotePeerId, detectSignals, peerEntry, predictSplit, relayCandidates, writesRepository, type Assignment, type InstanceRoutingOptions, type PeerCapabilities, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
72
72
  import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
73
73
  import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
74
74
  import { realPath } from "./project.ts";
75
75
  import { TaskRepositories } from "./task-repository.ts";
76
76
  import { mainCheckoutWarning } from "./main-checkout.ts";
77
77
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
78
+ import type { TaskWorktreePort } from "./task-worktree.ts";
78
79
  import { coverageGap, renderReviewBrief, renderSpecTaskReport, type BriefFile, type ReviewBrief } from "./review-brief.ts";
79
80
  import { DEFAULT_SPECS_CONFIG, type SpecsConfig } from "./specs-config.ts";
80
81
  import type { SpecsCheckReport, SpecsCoverReport } from "./specs.ts";
@@ -82,7 +83,7 @@ import { definitionOf as builtinDefinitionOf } from "../adapters/registry.ts";
82
83
  import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
83
84
  import { plannerGrooming, nextSweep, plannedStartPending, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
84
85
  import { CiWatchers, pollCi, type CiOutcome, type CiWatch } from "./ci-watch.ts";
85
- 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";
86
+ import { planningIssueReceipt, linkedReceipt, openPrFor, readPrCostHistory, readReviewReadiness, reviewReadinessFromEvidence, type PrEvidence, type ReviewReadiness, readPlanIssueMetadata, canonicalStage, checklistOf, readSdlcPr, readSdlcPrHead, 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";
86
87
 
87
88
  function requireLocalTaskPeer(peer: unknown): void {
88
89
  if (isRemotePeerId(peer)) throw new PreEffectToolRefusal("remote peers have no local task authority");
@@ -217,6 +218,8 @@ export interface TasksDeps {
217
218
  review?: { adaptive: boolean; min_reviews: number; pool?: readonly string[]; rerotate?: boolean; same_family?: "fallback" | "never" };
218
219
  /** Peers whose recent deliveries all failed, with the reason (issue #89); routing skips them. */
219
220
  capabilities?: () => Record<PeerId, PeerCapabilities>;
221
+ /** #1115: the hub makes, commits and clears the task worktree of a hub-sandboxed peer. Absent: such a peer keeps the #1081 refusal. */
222
+ taskWorktree?: TaskWorktreePort;
220
223
  repository?: string;
221
224
  offerRead?: (peer: PeerId, envelopeId: string) => boolean;
222
225
  offerPullOnly?: (peer: PeerId) => boolean;
@@ -429,6 +432,7 @@ export interface ExperimentView { attempts: ExperimentAttemptView[]; counts: { a
429
432
  comparison?: { planHash: string; baselineAttemptId: string; variantAttemptId: string; approved: boolean; private: boolean; projection: ReturnType<typeof comparisonProjection> | "[pii]" } }
430
433
  interface ExperimentContext { task: Task; campaign: ResearchCampaignRecord; private: boolean; key: string; fence: ExperimentWriteFence }
431
434
  interface CompletionRefresh { revision: number; commit: string; state: "in_review" | "approved" }
435
+ interface RefileHeadGuard { recorded?: string }
432
436
  interface IdentityLink { repo: string; pr: number; basis: { commit?: string; branch?: string } }
433
437
 
434
438
  /** What one live check of a task's files says as a whole (#317): `unchanged` only when EVERY file measured unchanged. A change outranks a missing file, which outranks one the hub could not read. */
@@ -1335,6 +1339,38 @@ export class Tasks {
1335
1339
 
1336
1340
  private reviewHead(task: Task): string | undefined { return task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead; }
1337
1341
 
1342
+ private recordedReviewHead(task: Task): string | undefined {
1343
+ const isFullHead = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{40}$/.test(value);
1344
+ const historyHead = task.history.findLast(entry => (entry.event === "approved" || entry.event === "changes_requested") && isFullHead(entry.reviewedHead))?.reviewedHead;
1345
+ return [task.sdlc?.reviewedHead, historyHead].find(isFullHead);
1346
+ }
1347
+
1348
+ /** An owner ref, when present, outranks host-derived receipt.head values for review identity. */
1349
+ private ownerReviewHead(task: Task): string | undefined {
1350
+ return /^[a-f0-9]{40}$/.test(task.refs.commit ?? "") ? task.refs.commit : this.recordedReviewHead(task);
1351
+ }
1352
+
1353
+ private hasReviewHistory(task: Task): boolean {
1354
+ return task.state === "changes_requested" || !!task.sdlc?.reviewedHead || task.history.some(entry => entry.event === "approved" || entry.event === "changes_requested");
1355
+ }
1356
+
1357
+ private reviewHeadMoveRefusal(taskId: number, recorded: string | undefined, current?: string): PreEffectToolRefusal {
1358
+ const known = recorded ? `recorded review head ${recorded}` : "a recorded full review head";
1359
+ const cause = current ? `${known} differs from current head of PR ${current}`
1360
+ : `current PR head could not be verified against ${known}`;
1361
+ const next = current ? `refs.commit set to ${current}` : "refs.commit set to the current full SHA";
1362
+ return new PreEffectToolRefusal(`task #${taskId}: ${cause}; owner must re-file hub_task_done with ${next} before review`);
1363
+ }
1364
+
1365
+ private async currentPrHeadForReview(task: Task, validate: () => Task): Promise<string | undefined> {
1366
+ const receipt = task.sdlc, gh = this.d.sdlc?.gh;
1367
+ if (this.isPii(task) || !receipt?.repo || !receipt.pr || !gh) return undefined;
1368
+ const identity = (value: Task) => JSON.stringify([value.id, value.state, value.owner, value.reviewer, value.history.length, this.isPii(value), value.refs, value.sdlc]);
1369
+ const before = identity(task), head = await readSdlcPrHead(receipt, gh), current = validate();
1370
+ if (identity(current) !== before) throw new PreEffectToolRefusal(`task #${task.id}: task or review head changed during PR readback; reload and retry`);
1371
+ return head;
1372
+ }
1373
+
1338
1374
  private reviewPoolOptions(task: Task) {
1339
1375
  const head = this.reviewHead(task);
1340
1376
  const round = task.state === "in_review" ? task.history.slice(task.history.findLastIndex(entry => entry.event === "done") + 1) : [];
@@ -1377,16 +1413,18 @@ export class Tasks {
1377
1413
  * sandboxed peer from accepting work, failing at `git worktree add` (#270) and leaving an empty branch ref behind (#621, #565). Unknown capability is
1378
1414
  * not a refusal, and a PII task skips the check as `requireCapabilities` does.
1379
1415
  */
1380
- private declaredNeedCheck(task: Pick<Task, "signals" | "id"> & Partial<Pick<Task, "signals">>, peer: PeerId): void {
1416
+ private declaredNeedCheck(task: Pick<Task, "signals" | "id"> & Partial<Pick<Task, "signals" | "class">>, peer: PeerId): void {
1381
1417
  if (!this.d.capabilities || this.isPii(task as Task)) return;
1382
- const wantsNetwork = task.signals?.includes("needs_network") === true, wantsWorktree = task.signals?.includes("needs_worktree") === true;
1418
+ // #1129: write-class work needs the hub's worktree whether or not it says so, because a hub-sandboxed owner cannot commit.
1419
+ const declaredWorktree = task.signals?.includes("needs_worktree") === true;
1420
+ const wantsNetwork = task.signals?.includes("needs_network") === true, wantsWorktree = declaredWorktree || (task.class !== undefined && writesRepository(task.class));
1383
1421
  if (!wantsNetwork && !wantsWorktree) return;
1384
1422
  const caps = peerEntry(this.d.capabilities(), peer);
1385
1423
  if (wantsNetwork && caps?.network === false) {
1386
1424
  throw new PreEffectToolRefusal(`task #${task.id} needs the network and ${peer} has none: ${caps.networkReason ?? "its shell cannot reach the network"}; decline it so the hub offers it to a peer with network, or ask the person to enable it`);
1387
1425
  }
1388
1426
  if (wantsWorktree && caps?.worktree === false) {
1389
- throw new PreEffectToolRefusal(`task #${task.id} needs its own git worktree and ${peer} cannot create one: ${caps.worktreeReason ?? "it cannot create the task worktree"}; nothing was created, so no branch ref is left behind; decline it so the hub offers it to a peer that can, this will not change by waiting`);
1427
+ throw new PreEffectToolRefusal(`task #${task.id} ${declaredWorktree ? "needs its own git worktree" : "changes the repository, so the hub must make its git worktree and commit for it"} and ${peer} cannot create one: ${caps.worktreeReason ?? "it cannot create the task worktree"}; nothing was created, so no branch ref is left behind; decline it so the hub offers it to a peer that can, this will not change by waiting`);
1390
1428
  }
1391
1429
  }
1392
1430
 
@@ -3281,6 +3319,8 @@ export class Tasks {
3281
3319
 
3282
3320
  async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; role?: unknown; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId; after?: unknown; urgent?: unknown; needs_network?: unknown; needs_worktree?: unknown; backlog?: unknown; draft?: unknown; goal?: unknown }, external?: GitHubSource, created?: () => void, fence?: () => string | undefined): Promise<Task> {
3283
3321
  const requestCheck = this.requestCheck(by, fence);
3322
+ const plan = cleanPlan(input.plan, this.d.profileData ?? this.d.profile ?? "dev");
3323
+ this.validatePlanSources(plan);
3284
3324
  requireLocalTaskPeer(input.owner);
3285
3325
  external = external ? { ...external } : undefined;
3286
3326
  if (external && (!this.personOrConductor(by) || input.backlog !== true)) throw new PreEffectToolRefusal("external intake requires person-owned backlog staging");
@@ -3317,7 +3357,6 @@ export class Tasks {
3317
3357
  let baseGiven: TaskClass | undefined;
3318
3358
  try { if (given !== undefined) baseGiven = profileClass(given, this.d.profileData ?? profile); } catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
3319
3359
  const text = { title, detail: String(input.detail ?? "").slice(0, 8000), refs: cleanRefs(input.refs) };
3320
- const plan = cleanPlan(input.plan, this.d.profileData ?? profile);
3321
3360
  // The plan reaches other owners, so a PII pattern in it makes the task a PII task like one in the detail would.
3322
3361
  const screenDetail = [text.detail, text.refs.repo, external?.repo, external?.url].filter(value => value !== undefined && value !== "").join("\n");
3323
3362
  const signals = this.signalsFor({ ...text, detail: [screenDetail, planText(plan)].join("\n"), plan });
@@ -3393,10 +3432,14 @@ export class Tasks {
3393
3432
  }
3394
3433
  }
3395
3434
 
3396
- async returnUntouchedToBacklog(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
3435
+ async returnUntouchedToBacklog(by: PeerId, input: { id: number; revision: number; reason: string; fields?: unknown }): Promise<Task> {
3397
3436
  this.plannerWriteAuthority(by);
3398
3437
  if (!Number.isSafeInteger(input.id) || input.id < 1 || !Number.isSafeInteger(input.revision) || input.revision < 0
3399
3438
  || typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("untouched return needs an id, revision and bounded reason");
3439
+ let parsed: PlanningFields;
3440
+ try { parsed = planningPatch(input.fields); } catch (error) { throw new PreEffectToolRefusal(`${(error as Error).message}; use fields.reserved with an agent peer id or null, or omit fields`); }
3441
+ if (Object.keys(parsed).some(key => key !== "reserved")) throw new PreEffectToolRefusal("untouched return fields may contain only reserved; remove every other field, or omit fields");
3442
+ const patch: Pick<PlanningFields, "reserved"> = Object.hasOwn(parsed, "reserved") ? { reserved: parsed.reserved } : {};
3400
3443
  const validate = () => {
3401
3444
  const task = this.need(input.id, false, true);
3402
3445
  this.plannerWriteAuthority(by, task);
@@ -3404,6 +3447,14 @@ export class Tasks {
3404
3447
  if (by !== USER && task.pinned) throw new PreEffectToolRefusal("pinned work requires the person");
3405
3448
  const release = task.release == null ? undefined : this.d.board.release(task.release);
3406
3449
  if (release && (["shipped", "dropped"].includes(release.state) || by !== USER && release.state === "frozen")) throw new PreEffectToolRefusal("settled or frozen release scope cannot return");
3450
+ if (Object.hasOwn(patch, "reserved")) {
3451
+ if (by !== USER && this.hasPersonReservation(task)) throw new PreEffectToolRefusal("a person's reservation is retained; ask the person to change it");
3452
+ const reserved = patch.reserved, definitionOf = this.d.adapterCatalog?.definitionOf ?? builtinDefinitionOf;
3453
+ if (reserved && !definitionOf(reserved, this.d.instances?.()) && !this.d.bus.knownPeers().includes(reserved) && !this.d.routing().classes[task.class]?.peers.includes(reserved)) throw new PreEffectToolRefusal("reserved owner is not a supported or configured peer; choose a peer from ahub status");
3454
+ if (reserved && !this.d.bus.isRostered(reserved)) throw new PreEffectToolRefusal("reserved owner is off roster; choose a rostered peer from ahub status or ask the person to update the roster");
3455
+ if (reserved && this.excluded(task).includes(reserved)) throw new PreEffectToolRefusal(`${reserved} is excluded from this task (it declined it or was escalated away from it for good); a reservation for it can never be honoured. Only a person's ahub task assign lifts the exclusion`);
3456
+ if (reserved) this.requireCapabilities(task, reserved);
3457
+ }
3407
3458
  return task;
3408
3459
  };
3409
3460
  const task = validate(), flow = task.history.length;
@@ -3412,14 +3463,14 @@ export class Tasks {
3412
3463
  const confirmed = validate();
3413
3464
  if (confirmed.history.length !== flow) throw new PreEffectToolRefusal("task changed during untouched return");
3414
3465
  const capacity = this.capacityBefore(confirmed);
3415
- 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);
3466
+ const returned = this.d.board.update(confirmed.id, by, "backlog returned untouched", { state: "backlog", owner: null, reviewer: null, ...patch }, input.reason.trim(), { ...(withheld ? { withheld: true } : {}) }, flow);
3416
3467
  this.writePlannerNotices(returned, capacity);
3417
3468
  this.offered.delete(confirmed.id);
3418
3469
  this.cohorts.leave(confirmed.id);
3419
3470
  this.withdrawRoleRequest(confirmed, returned, "owner");
3420
3471
  this.withdrawRoleRequest(confirmed, returned, "reviewer");
3421
3472
  this.incomingBacklog.add(confirmed.id);
3422
- this.planningRecorded({ by, task: confirmed.id, fields: ["return"], revision: confirmed.planningRevision ?? 0 });
3473
+ this.planningRecorded({ by, task: confirmed.id, fields: ["return", ...Object.keys(patch)], revision: returned.planningRevision ?? 0 });
3423
3474
  await this.releaseHeld(confirmed.owner); // its owner's slot is free (#655)
3424
3475
  return returned;
3425
3476
  }
@@ -4044,7 +4095,17 @@ export class Tasks {
4044
4095
  }
4045
4096
  if (stagedClaimGap) return next; // An explicit claim may reserve its owner, but no offer or acceptance bypasses review.
4046
4097
  this.studyDispatchCheck(next, by);
4047
- const claimed = this.d.board.update(next.id, by, "accepted", { state: "in_progress" });
4098
+ // #1129: a self-claim of write-class work is an accept too, so a hub-sandboxed claimant gets the hub's worktree here, before the write. When the hub cannot make one the claim stays an assignment, like the missing-spec case above: `hub_task_accept` starts it and refuses with the same cause.
4099
+ let undoWorktree: (() => void) | undefined;
4100
+ try { undoWorktree = this.hubWorktreeFor(next, by); }
4101
+ catch (error) {
4102
+ if (!(error instanceof PreEffectToolRefusal)) throw error;
4103
+ this.releaseNotice(`${error.message}; the claim stays assigned to ${by} and is not started`);
4104
+ return next;
4105
+ }
4106
+ let claimed: Task;
4107
+ try { claimed = this.d.board.update(next.id, by, "accepted", { state: "in_progress" }); }
4108
+ catch (error) { undoWorktree?.(); throw error; }
4048
4109
  this.captureOwnerAcceptance(claimed);
4049
4110
  this.releaseNotice(`task ${this.publicTitle(claimed)} claimed by ${by}`);
4050
4111
  return claimed;
@@ -4080,7 +4141,8 @@ export class Tasks {
4080
4141
  /** A self-claim has no assignment envelope; its tool result carries the same bounded guidance. */
4081
4142
  claimRoleGuidance(task: Task, peer: PeerId): string {
4082
4143
  requireLocalTaskPeer(peer);
4083
- return task.owner === peer && task.state === "in_progress" ? this.roleGuidance(task, peer) : "";
4144
+ // #1129: a claim by a hub-sandboxed peer carries the worktree note as an accept does, since the claim result is all it reads.
4145
+ return task.owner === peer && task.state === "in_progress" ? [this.roleGuidance(task, peer), this.worktreeNote(task, peer)].filter(Boolean).join("\n") : "";
4084
4146
  }
4085
4147
 
4086
4148
  private roleGuidance(task: Task, peer: PeerId, review = false): string {
@@ -4493,6 +4555,7 @@ export class Tasks {
4493
4555
  const capacity = this.capacityBefore(task);
4494
4556
  this.ciWatchers.cancel(task.id); this.heldReviews.delete(task.id);
4495
4557
  const next = this.d.board.update(task.id, by, "cancelled", { state: "cancelled", reserved: null, cancelled }, cancelled.reason, { ...(withheld ? { withheld: true as const } : {}) }, revision);
4558
+ this.releaseHubWorktree(task, task.owner);
4496
4559
  this.writePlannerNotices(next, capacity);
4497
4560
  this.releaseNotice(`task #${task.id}: cancellation cohort cleanup unavailable`, () => this.cohorts.leave(task.id));
4498
4561
  this.offered.delete(task.id);
@@ -5648,10 +5711,18 @@ export class Tasks {
5648
5711
 
5649
5712
  /** The eval record the last approval-time lifecycle read produced for a task; consumed by the verdict that follows it. */
5650
5713
  private readonly evalRecords = new Map<number, string>();
5651
- private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string, findings?: readonly ReviewFindingSet[]): Promise<PrEvidence | undefined> {
5714
+ private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string, findings?: readonly ReviewFindingSet[], refileHead?: RefileHeadGuard): Promise<PrEvidence | undefined> {
5652
5715
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
5653
5716
  if (config.mode === "off") return;
5654
5717
  const receipt = task.sdlc && { ...task.sdlc, ...(reviewedHead ? { reviewedHead } : {}) };
5718
+ const approvalHead = transition === "approval" ? reviewedHead ?? this.ownerReviewHead(task) ?? this.findingHead(task) : undefined;
5719
+ const guardedHead = !!refileHead || !!approvalHead && !!receipt?.repo && !!receipt.pr && !this.isPii(task);
5720
+ // Compare raw head identity on both sides; `task` may carry an uncommitted identityLink.
5721
+ // Completion state/history races stay with completionCurrent's existing post-readback fence.
5722
+ const identity = (value: Task) => JSON.stringify([value.id, value.owner, value.reviewer, this.isPii(value),
5723
+ value.refs.repo, value.refs.commit, value.refs.branch, value.sdlc?.repo, value.sdlc?.pr,
5724
+ value.sdlc?.head, value.sdlc?.localHead, value.sdlc?.reviewedHead]);
5725
+ const beforeHeadRead = guardedHead ? identity(this.need(task.id, false, true)) : undefined;
5655
5726
  const deadline = performance.now() + 20_000;
5656
5727
  const read: SdlcGh = (args, timeout, cwd, cap) => {
5657
5728
  const current = this.need(task.id, false, true);
@@ -5660,6 +5731,16 @@ export class Tasks {
5660
5731
  return remaining > 0 ? (this.d.sdlc?.gh ?? sdlcGh)(args, Math.min(timeout ?? 10_000, remaining), cwd, cap) : Promise.resolve({ code: 1, stdout: "" });
5661
5732
  };
5662
5733
  const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, read, true) : undefined;
5734
+ const currentHead = receipt && !this.isPii(task) && (refileHead || approvalHead) && !evidence?.head
5735
+ ? await readSdlcPrHead(receipt, read) : evidence?.head;
5736
+ if (beforeHeadRead !== undefined) {
5737
+ const current = this.need(task.id, false, true);
5738
+ if (identity(current) !== beforeHeadRead) throw new PreEffectToolRefusal(`task #${task.id}: task or review head changed during PR readback; reload and retry`);
5739
+ }
5740
+ if (refileHead) {
5741
+ if (!refileHead.recorded || !currentHead) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded);
5742
+ if (currentHead !== refileHead.recorded) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded, currentHead);
5743
+ } else if (approvalHead && currentHead && currentHead !== approvalHead) throw this.reviewHeadMoveRefusal(task.id, approvalHead, currentHead);
5663
5744
  const integrity = publicTestIntegrity(receipt && evidence && !this.isPii(task) ? await readTestIntegrity(receipt, evidence, read) : this.unknownTestIntegrity(this.isPii(task)), this.publicMetadataText);
5664
5745
  const evalResult = receipt && !this.isPii(task) ? await readEvalFor(config, evidence, receipt.repo, read) : undefined;
5665
5746
  const current = this.need(task.id, false, true);
@@ -5708,6 +5789,8 @@ export class Tasks {
5708
5789
  const metadata = acceptRefs(refs);
5709
5790
  const task = this.need(id, false, true);
5710
5791
  this.mine(task, by, "owner", true);
5792
+ const proposedPlan = plan == null ? undefined : cleanPlan(plan, (task.profile ?? "dev") === (this.d.profile ?? "dev") ? this.d.profileData ?? task.profile ?? "dev" : task.profile ?? "dev");
5793
+ this.validatePlanSources(proposedPlan && Object.keys(proposedPlan).length ? proposedPlan : task.plan);
5711
5794
  this.acceptanceCapacity(task, 0, "check"); // #1080: an urgent task may still take unaccepted offers; `accept` does that, after its gates
5712
5795
  if (metadata && !this.isPii(task) && (!this.nameable(metadata.repo!) || looksSecret(metadata.repo!) || this.aboutPrivatePath("", metadata.repo!, task))) throw new PreEffectToolRefusal("this repository metadata is private or sensitive and is not kept");
5713
5796
  const draft = metadata ? { ...task, refs: { ...task.refs, ...metadata } } : task;
@@ -5822,6 +5905,12 @@ export class Tasks {
5822
5905
  throw new PreEffectToolRefusal(`planner WIP capacity for ${owner}: limit ${limit}; open tasks ${ids(open)} (${parts.join("; ")})${pending ? `; pending claims ${pending}` : ""}; ${next}`);
5823
5906
  }
5824
5907
 
5908
+ private validatePlanSources(plan: TaskPlan = {}): void {
5909
+ for (const id of plan.sources ?? []) if (!this.d.intake?.sourceKnown(id)) {
5910
+ throw new PreEffectToolRefusal(`unknown source ${id}; list registered ids with ahub source list`);
5911
+ }
5912
+ }
5913
+
5825
5914
  /**
5826
5915
  * With a plan, the owners of overlapping tasks get it as a ride-along line, and an overlap only the plan reveals is
5827
5916
  * announced like one found at assignment (issue #31).
@@ -5833,6 +5922,11 @@ export class Tasks {
5833
5922
  this.mine(task, by, "owner", true);
5834
5923
  this.ready(task, true);
5835
5924
  if (task.state !== "proposed" && task.state !== "changes_requested" && task.state !== "in_progress") throw new PreEffectToolRefusal(`task #${task.id} is ${task.state}: cannot move to in_progress`);
5925
+ // Models send null or {} for an optional field they leave empty: neither replaces a plan.
5926
+ const given = plan == null ? undefined : cleanPlan(plan, (task.profile ?? "dev") === (this.d.profile ?? "dev") ? this.d.profileData ?? task.profile ?? "dev" : task.profile ?? "dev");
5927
+ // File-list intent still replaces the old lists; omitted budget keys retain the planner-reviewed scope.
5928
+ const cleaned = given && Object.keys(given).length ? { ...cleanCostPlan(task.plan ?? {}), ...given } : undefined;
5929
+ this.validatePlanSources(cleaned ?? task.plan);
5836
5930
  this.studyDispatchCheck(task, task.owner ?? undefined);
5837
5931
  this.declaredNeedCheck(task, task.owner ?? by); // the owner's capability, also when the console user accepts on its behalf (`mine` admits USER)
5838
5932
  this.acceptanceCapacity(task, 0, "check");
@@ -5843,10 +5937,6 @@ export class Tasks {
5843
5937
  this.reviewedAdmissions.delete(task.id);
5844
5938
  if (!readback || readback.flow !== task.history.length || readback.owner !== task.owner) throw new PreEffectToolRefusal("current plan-review readback required before acceptance");
5845
5939
  }
5846
- // Models send null or {} for an optional field they leave empty: neither replaces a plan.
5847
- const given = plan == null ? undefined : cleanPlan(plan, (task.profile ?? "dev") === (this.d.profile ?? "dev") ? this.d.profileData ?? task.profile ?? "dev" : task.profile ?? "dev");
5848
- // File-list intent still replaces the old lists; omitted budget keys retain the planner-reviewed scope.
5849
- const cleaned = given && Object.keys(given).length ? { ...cleanCostPlan(task.plan ?? {}), ...given } : undefined;
5850
5940
  if (this.d.sdlc?.config().mode === "enforce" && task.sdlc?.planReview && !task.sdlc.planReview.scope
5851
5941
  && given && Object.keys(cleanCostPlan(given)).length) throw new PreEffectToolRefusal("legacy plan cost scope is unspecified; record a scoped planner review before acceptance");
5852
5942
  if (this.d.sdlc?.config().mode === "enforce" && task.sdlc?.planReview?.scope
@@ -5863,8 +5953,13 @@ export class Tasks {
5863
5953
  const repositoryChanged = this.repositories.scope(task.refs).key !== this.repositories.scope(draft.refs).key;
5864
5954
  const before = new Set(this.overlapHits(task).map((h) => h.task.id));
5865
5955
  this.studyDispatchCheck(task, task.owner ?? undefined);
5866
- this.acceptanceCapacity(task, 0, "displace"); // #1080: every gate has passed; synchronous from here to the accept write
5867
- const next = this.linkOnWrite(this.d.board.update(task.id, by, "accepted", { state: "in_progress", ...(cleaned ? { plan: cleaned } : {}), ...(metadata ? { refs: metadata } : {}) })); // #723: refs.repo may have just arrived
5956
+ // #1115: a hub-sandboxed peer cannot run `git worktree add`, so for work that declares a worktree the hub makes it here, before any write. Anything that throws below clears it again.
5957
+ const undoWorktree = this.hubWorktreeFor(task, task.owner ?? by);
5958
+ let next: Task;
5959
+ try {
5960
+ this.acceptanceCapacity(task, 0, "displace"); // #1080: every gate has passed; synchronous from here to the accept write
5961
+ next = this.linkOnWrite(this.d.board.update(task.id, by, "accepted", { state: "in_progress", ...(cleaned ? { plan: cleaned } : {}), ...(metadata ? { refs: metadata } : {}) })); // #723: refs.repo may have just arrived
5962
+ } catch (error) { undoWorktree?.(); throw error; }
5868
5963
  this.captureOwnerAcceptance(next);
5869
5964
  this.d.notify(`task ${this.publicTitle(next)} accepted by ${by}`);
5870
5965
  if (repositoryChanged) this.cohorts.leave(next.id);
@@ -5925,6 +6020,51 @@ export class Tasks {
5925
6020
  /** #572 AC3: what the owner's `hub_task_done` result adds while the project root checkout is on a task branch. */
5926
6021
  checkoutNote = (): string | undefined => { const warning = mainCheckoutWarning(this.d.cwd, this.publicMetadataText); return warning ? `Note: ${warning}.` : undefined; };
5927
6022
 
6023
+ /**
6024
+ * #1115, #1129: the hub makes the worktree of a hub-sandboxed owner for write-class work (`implement`, `bulk_edit`) and for any work that declares `needs_worktree`. Returns a function that clears it again, or
6025
+ * undefined when there is nothing to clear (another peer, a read-only class that did not declare it, a PII task, or a worktree an earlier accept already made). A failure refuses the
6026
+ * accept before any board write and names the cause and the next step; nothing is left behind.
6027
+ */
6028
+ private hubWorktreeFor(task: Task, peer: PeerId): (() => void) | undefined {
6029
+ const port = this.d.taskWorktree;
6030
+ if (!port || peer === USER || peer === HUB || !port.applies(peer) || this.isPii(task) || !(task.signals.includes("needs_worktree") || writesRepository(task.class))) return undefined;
6031
+ const made = port.create({ peer, task: task.id });
6032
+ if (!made.ok) throw new PreEffectToolRefusal(`task #${task.id} needs its own git worktree and the hub could not make one for ${peer}: ${made.reason}; nothing was created, so no branch ref is left behind. Decline the task so the hub offers it to a peer that makes its own worktree`);
6033
+ return made.created ? () => { try { port.remove({ peer, task: task.id }); } catch { /* the accept error that got us here is the one reported */ } } : undefined;
6034
+ }
6035
+
6036
+ /** The hub-made worktree of the task's owner, for the accept answer: where to edit, and what the hub does at `hub_task_done`. */
6037
+ worktreeNote(task: Task, peer: PeerId): string | undefined {
6038
+ const found = this.d.taskWorktree?.describe(peer, task.id);
6039
+ return found ? `Worktree: the hub made ${found.path} on branch ${found.branch} for this task. Edit files there only; do not run git. At hub_task_done the hub stages, commits and pushes it for you.` : undefined;
6040
+ }
6041
+
6042
+ /**
6043
+ * #1115: at `hub_task_done` the hub commits and pushes a hub-sandboxed peer's worktree and answers the refs the done carries. Undefined when the peer made no
6044
+ * hub worktree for this task (it commits itself). A refused commit or push is a pre-effect refusal that names the cause and the next step: the board is untouched.
6045
+ */
6046
+ async worktreeHandoff(by: PeerId, id: unknown, refs: unknown): Promise<TaskRefs | undefined> {
6047
+ const port = this.d.taskWorktree;
6048
+ if (!port || by === USER || by === HUB || !port.applies(by)) return undefined;
6049
+ const task = this.need(id, false, true);
6050
+ this.mine(task, by, "owner", true);
6051
+ if (this.isPii(task) || !port.describe(by, task.id)) return undefined;
6052
+ const result = await port.commitAndPush({ peer: by, task: task.id, title: task.title });
6053
+ if (!result.ok) throw new PreEffectToolRefusal(`hub_task_done for task #${task.id}: ${result.reason}`);
6054
+ if (result.note) this.doneNotes.set(task.id, result.note);
6055
+ return { ...cleanRefs(refs), commit: result.commit, branch: result.branch };
6056
+ }
6057
+
6058
+ /** Decline or cancel: clear the hub-made worktree and its empty branch. Uncommitted work is kept and reported, never force-removed. */
6059
+ private releaseHubWorktree(task: Task, peer: PeerId | null | undefined): void {
6060
+ const port = this.d.taskWorktree;
6061
+ if (!port || !peer || peer === USER || peer === HUB || !port.applies(peer)) return;
6062
+ this.releaseNotice(`task #${task.id}: the hub-made worktree could not be cleared`, () => {
6063
+ const removal = port.remove({ peer, task: task.id });
6064
+ if (removal.kept) this.d.notify(`task #${task.id}: ${removal.kept}`);
6065
+ });
6066
+ }
6067
+
5928
6068
  /**
5929
6069
  * `capacity` (#531): the owner has no room right now, so the decline excludes it only from this move's own reroute
5930
6070
  * (below), never from a later reroute or planner pull. Without it, a decline is a lasting refusal (`excluded()`), and so
@@ -5939,6 +6079,7 @@ export class Tasks {
5939
6079
  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 } : {}) } : {});
5940
6080
  if (planned) this.offered.delete(task.id);
5941
6081
  this.d.notify(`task ${this.publicTitle(back)} declined${capacity === true ? " (capacity only)" : ""} by ${by}${reason && !this.isPii(back) ? `: ${reason}` : ""}`);
6082
+ this.releaseHubWorktree(task, task.owner ?? by);
5942
6083
  // A capacity decline is not in excluded(), so this move's own reroute leaves the decliner out explicitly.
5943
6084
  try {
5944
6085
  return await this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true, ...(capacity === true && task.owner ? { exclude: [task.owner] } : {}), ...(planned ? { planner: true } : {}) });
@@ -6122,11 +6263,20 @@ export class Tasks {
6122
6263
  let refresh: CompletionRefresh | undefined;
6123
6264
  if (initial.state === "in_review" || initial.state === "approved") {
6124
6265
  const commit = cleanRefs(refs).commit;
6125
- if (!commit || !/^[a-f0-9]{40}$/.test(commit) || commit === this.reviewHead(initial))
6126
- throw new PreEffectToolRefusal(`task #${initial.id} is already ${initial.state}; re-file requires a different full head SHA`);
6266
+ const recorded = this.ownerReviewHead(initial) ?? this.findingHead(initial);
6267
+ const currentReviewHead = this.ownerReviewHead(initial) ?? this.reviewHead(initial);
6268
+ if (!commit || !/^[a-f0-9]{40}$/.test(commit) || commit === currentReviewHead) {
6269
+ throw new PreEffectToolRefusal(`task #${initial.id} is already ${initial.state}${recorded ? ` with recorded review head ${recorded}` : ""}; re-file requires a different full head SHA. Owner must re-file hub_task_done with refs.commit set to the new full SHA`);
6270
+ }
6127
6271
  refresh = { revision: initial.history.length, commit, state: initial.state };
6128
6272
  }
6129
6273
  const submittedCommit = cleanRefs(refs).commit;
6274
+ if (submittedCommit && this.hasReviewHistory(initial) && !/^[a-f0-9]{40}$/.test(submittedCommit)) {
6275
+ const recorded = this.ownerReviewHead(initial);
6276
+ throw new PreEffectToolRefusal(`task #${initial.id}: re-file after review needs refs.commit to be a full lowercase SHA${recorded ? ` (recorded review head ${recorded})` : ""}; owner must re-file hub_task_done with the new full SHA in refs.commit`);
6277
+ }
6278
+ const refileHead: RefileHeadGuard | undefined = !submittedCommit && initial.sdlc?.repo && initial.sdlc.pr && this.hasReviewHistory(initial) && !this.isPii(initial)
6279
+ ? { recorded: this.ownerReviewHead(initial) } : undefined;
6130
6280
  const draft = refresh ?? (submittedCommit && /^[a-f0-9]{40}$/.test(submittedCommit) ? { commit: submittedCommit } : undefined);
6131
6281
  const valid = () => { requestCheck(); return this.completionDraft(this.completionCurrent(id, by, true, refresh), draft); };
6132
6282
  if (initial.state === "approved") { await this.unmergedForReopen(initial, valid); valid(); }
@@ -6150,9 +6300,15 @@ export class Tasks {
6150
6300
  const current = () => this.withLink(valid(), link, refs);
6151
6301
  task = current();
6152
6302
  let reviewEvidence: PrEvidence | undefined;
6153
- if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { reviewEvidence = await this.lifecycleGate(task, "review"); task = current(); }
6154
- const reviewAdmission = task.reviewer || this.d.prepareReviewBrief ? await this.reviewRequestAdmission(task, refs, true, by, reviewEvidence, undefined, staged => this.withLink(this.completionDraft(staged, draft), link, refs), !task.reviewer) : undefined;
6303
+ if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { reviewEvidence = await this.lifecycleGate(task, "review", undefined, undefined, refileHead); task = current(); }
6304
+ const reviewAdmission = task.reviewer || this.d.prepareReviewBrief ? await this.reviewRequestAdmission(task, refs, true, by, reviewEvidence, undefined, staged => this.withLink(this.completionDraft(staged, draft), link, refs), !task.reviewer, refileHead) : undefined;
6155
6305
  task = current();
6306
+ if (refileHead && (this.d.sdlc?.config().mode ?? "off") === "off") {
6307
+ const currentHead = await this.currentPrHeadForReview(task, current);
6308
+ task = current();
6309
+ if (!refileHead.recorded || !currentHead) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded);
6310
+ if (currentHead !== refileHead.recorded) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded, currentHead);
6311
+ }
6156
6312
  const currentSpecTask = () => { const fresh = current(); return { ...fresh, refs: { ...fresh.refs, ...cleanRefs(refs) } }; };
6157
6313
  const specInspection = await this.inspectTaskSpecs(currentSpecTask(), by, specAck, currentSpecTask);
6158
6314
  task = current(); // the host head and task authority are revalidated after spec inspection
@@ -6507,7 +6663,7 @@ export class Tasks {
6507
6663
 
6508
6664
  /** The one place a review request is written: the first reviewer and a replacement get the same text, refs and privacy. */
6509
6665
  /** Review admission is separate from brief construction (#228). All refusals precede the requesting done's writes. */
6510
- private async reviewRequestAdmission(task: Task, refs?: TaskRefs, preEffect = true, actor: PeerId = task.owner ?? USER, previous?: PrEvidence, sameRequest?: () => boolean, stage: (task: Task) => Task = task => task, prepareOnly = false): Promise<ReviewReadiness | undefined> {
6666
+ private async reviewRequestAdmission(task: Task, refs?: TaskRefs, preEffect = true, actor: PeerId = task.owner ?? USER, previous?: PrEvidence, sameRequest?: () => boolean, stage: (task: Task) => Task = task => task, prepareOnly = false, refileHead?: RefileHeadGuard): Promise<ReviewReadiness | undefined> {
6511
6667
  const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
6512
6668
  if (mode === "off" && !this.d.prepareReviewBrief) return undefined;
6513
6669
  let quietDrift = false;
@@ -6544,8 +6700,13 @@ export class Tasks {
6544
6700
  const evidence: ReviewReadiness = this.isPii(task)
6545
6701
  ? { ready: false, blockers: ["private review readiness unavailable"] }
6546
6702
  : task.sdlc ? reviewReadinessFromEvidence(previous, expected, this.d.sdlc?.config().requiredChecks ?? []) ?? await readReviewReadiness(task.sdlc, expected, read, this.d.sdlc?.config().requiredChecks ?? []) : { ready: false, blockers: ["review PR identity missing"] };
6703
+ const currentHead = refileHead && !evidence.head && task.sdlc ? await readSdlcPrHead(task.sdlc, read) : evidence.head;
6547
6704
  const current = guard();
6548
6705
  const admitted: ReviewReadiness = quietDrift ? { ready: false, blockers: ["review readiness unknown: task changed during readback"] } : evidence;
6706
+ if (refileHead) {
6707
+ if (!refileHead.recorded || !currentHead) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded);
6708
+ if (currentHead !== refileHead.recorded) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded, currentHead);
6709
+ }
6549
6710
  if (!admitted.ready) {
6550
6711
  const message = `task ${this.publicTitle(current)}: premature review: ${admitted.blockers.join("; ")}`;
6551
6712
  if (mode === "enforce") throw new (preEffect ? PreEffectToolRefusal : Error)(message);
@@ -6879,7 +7040,7 @@ export class Tasks {
6879
7040
  // notice. The rule is structural (a finding with disposition open), never a reading of the unmet text.
6880
7041
  const codeScope = by !== USER && this.d.reviewScope?.(by) === "code";
6881
7042
  const evidenceOnly = codeScope && verdict === "changes_requested" && !parsed.some(finding => finding.disposition === "open");
6882
- const resolvedHead = this.findingHead(initial);
7043
+ const resolvedHead = this.ownerReviewHead(initial) ?? this.findingHead(initial);
6883
7044
  let findingHead = resolvedHead;
6884
7045
  if (head !== undefined) {
6885
7046
  try { findingHead = sdlcReceipt({ reviewedHead: head }, initial.sdlc, this.d.sdlc?.config() ?? sdlcConfig(undefined)).reviewedHead; }
@@ -6926,7 +7087,7 @@ export class Tasks {
6926
7087
  if (gap.length) note = `${note ?? ""}\nCoverage gap (${gap.length}): ${list(gap)}`.trim();
6927
7088
  }
6928
7089
  }
6929
- if (head !== undefined && resolvedHead && findingHead !== resolvedHead) throw new PreEffectToolRefusal("supplied review head differs from the task resolved head");
7090
+ if (head !== undefined && resolvedHead && findingHead !== resolvedHead) throw new PreEffectToolRefusal(`task #${initial.id}: supplied review head ${findingHead} differs from recorded task head ${resolvedHead}; owner must re-file hub_task_done with refs.commit set to the supplied full SHA before review`);
6930
7091
  // The PII screen reads the note of a review that passes these checks (issue #198); they run again after it.
6931
7092
  const withheld = await this.withholds("review note", parsed.length ? JSON.stringify({ note, findings: parsed }) : note, valid())
6932
7093
  || parsed.length > 0 && !this.findingTextAllowed(initial, parsed);
@@ -6963,6 +7124,12 @@ export class Tasks {
6963
7124
  await this.lifecycleGate(task, "approval", reviewedHead, proposedSets(task)); task = valid();
6964
7125
  const record = this.evalRecords.get(task.id); this.evalRecords.delete(task.id);
6965
7126
  if (record) note = `${note ?? ""}\n${record}`.trim();
7127
+ } else if (verdict === "approved" && this.d.sdlc?.config().mode === "off" && this.d.sdlc?.gh && task.sdlc?.repo && task.sdlc.pr
7128
+ && this.ownerReviewHead(task) && !this.isPii(task)) {
7129
+ const expected = reviewedHead ?? this.ownerReviewHead(task) ?? this.findingHead(task);
7130
+ const currentHead = await this.currentPrHeadForReview(task, valid);
7131
+ task = valid();
7132
+ if (expected && currentHead && currentHead !== expected) throw this.reviewHeadMoveRefusal(task.id, expected, currentHead);
6966
7133
  }
6967
7134
  let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
6968
7135
  if (verdict === "changes_requested" && !evidenceOnly && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
package/src/hub/ui.ts CHANGED
@@ -149,7 +149,9 @@ export function startDashboard(options: DashboardOptions) {
149
149
  return reject(403, "live-test dashboard cannot write person-attributed content; use peer tools");
150
150
  }
151
151
  if (input.action === "sdlc_queue" || input.action === "sdlc_release") server.timeout(req, 35);
152
+ else if (input.action === "worktrees_refresh_prs") server.timeout(req, 45); // #1057: bounded gh reads (25 s) and two git passes
152
153
  else if (input.action === "backlog_split") server.timeout(req, Math.ceil((BACKLOG_SPLIT_SCREEN_TIMEOUT_MS + HUB_DB_BUSY_TIMEOUT_MS + 5_000) / 1000) + 5);
154
+ else if (input.action === "intake") server.timeout(req, 95); // the CLI's 90 s intake cap plus the HTTP response margin
153
155
  return json(await options.action(input, sessionOf(cookie)));
154
156
  } catch {
155
157
  // Exceptions may quote private task text or backend details. Never echo them to the page.
@@ -0,0 +1,100 @@
1
+ import { readSdlcPr, type SdlcGh } from "./sdlc.ts";
2
+ import type { PrObserver, WorktreeFact } from "./worktree-shelf.ts";
3
+
4
+ /**
5
+ * #1057 D1: the explicit "refresh PR states" action. It is the ONLY code that lets the worktree shelf learn a PR state from GitHub, through `readSdlcPr`
6
+ * (the same readback the guarded merge uses), and it runs only when a person asks. A panel refresh, `ahub worktrees` and the inbox alert never reach it, so
7
+ * they make no GitHub call (AC5): `worktree-shelf.ts` imports nothing from here and holds no GitHub access.
8
+ *
9
+ * What it reads is kept in memory, keyed by repository and PR number, and offered back to the shelf as a `PrObserver`.
10
+ * ponytail: the observations live in the daemon's memory, so a restart forgets them and those rows read `unknown` again until the next refresh; the upgrade is a
11
+ * `worktree_pr` table beside `worktree_override`, which is not worth a schema change before live use shows people refresh often.
12
+ */
13
+ export const REFRESH_MAX_PRS = 8, REFRESH_CALL_MS = 8_000, REFRESH_TOTAL_MS = 25_000, OBSERVATIONS_MAX = 512;
14
+
15
+ /**
16
+ * When GitHub says a PR that is no longer open was merged or closed, in ms. A second, narrow read (`pr view <n> --json mergedAt,closedAt`) that only a refresh makes and
17
+ * only for a PR `readSdlcPr` found merged or closed, so the shared readback the guarded merge and the queue use is unchanged. A missing, malformed or non-finite time is undefined,
18
+ * never 0: a merged PR with no change time is `unknown` to the partition, not settled.
19
+ */
20
+ export async function readPrChangeTime(repo: string, pr: number, state: "merged" | "closed", gh: SdlcGh): Promise<number | undefined> {
21
+ // An owner and a name, each starting with a letter or digit: `..`, `.x` and `-x` are never a repository, so a hostile slug cannot reach gh as a path or a flag.
22
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9_.][A-Za-z0-9_.-]*$/.test(repo) || repo.split("/").some(part => part === "." || part === "..") || !Number.isSafeInteger(pr) || pr < 1) return undefined;
23
+ try {
24
+ const view = await gh(["pr", "view", String(pr), "--repo", repo, "--json", "mergedAt,closedAt"], 8_000, undefined, 4096);
25
+ if (view.code !== 0) return undefined;
26
+ const value = JSON.parse(view.stdout) as { mergedAt?: unknown; closedAt?: unknown } | null;
27
+ const text = state === "merged" ? value?.mergedAt ?? value?.closedAt : value?.closedAt;
28
+ const at = typeof text === "string" && text.length <= 40 ? Date.parse(text) : NaN;
29
+ return Number.isFinite(at) && at > 0 ? at : undefined;
30
+ } catch { return undefined; }
31
+ }
32
+
33
+ export interface PrObservation { state: "open" | "merged" | "closed"; changedAt?: number; at: number }
34
+ export interface RefreshResult {
35
+ /** PRs read and stored. */
36
+ read: number;
37
+ /** PRs GitHub could not answer for (unreadable, no access, a gh failure): their state stays what it was. */
38
+ failed: number;
39
+ /** PRs not asked about because the cap or the time budget was reached. */
40
+ skipped: number;
41
+ }
42
+
43
+ /** A bounded, insertion-ordered map of what the last refreshes read. */
44
+ export class PrObservations {
45
+ private readonly seen = new Map<string, PrObservation>();
46
+ record(repo: string, pr: number, observation: PrObservation): void {
47
+ const key = `${repo}#${pr}`;
48
+ this.seen.delete(key); this.seen.set(key, observation);
49
+ while (this.seen.size > OBSERVATIONS_MAX) this.seen.delete(this.seen.keys().next().value!);
50
+ }
51
+ /** The shelf's view of it: state and change time only. */
52
+ observer: PrObserver = (repo, pr) => {
53
+ const found = this.seen.get(`${repo}#${pr}`);
54
+ return found ? { state: found.state, ...(found.changedAt !== undefined ? { changedAt: found.changedAt } : {}) } : undefined;
55
+ };
56
+ /** When the newest observation was read, or undefined when none was. */
57
+ get lastAt(): number | undefined { let at: number | undefined; for (const value of this.seen.values()) at = Math.max(at ?? 0, value.at); return at; }
58
+ get size(): number { return this.seen.size; }
59
+ }
60
+
61
+ /** The PRs a refresh would ask about: bound tasks with a repository and a PR that the board does not already show as merged, oldest activity first. */
62
+ export function refreshTargets(facts: readonly WorktreeFact[], limit = REFRESH_MAX_PRS): { repo: string; pr: number }[] {
63
+ const seen = new Set<string>(), out: { repo: string; pr: number }[] = [];
64
+ const candidates = facts.filter(f => !f.main && !f.instance && f.task?.repo !== undefined && f.task.pr !== undefined && f.pr.state !== "merged")
65
+ .sort((a, b) => a.lastActiveAt - b.lastActiveAt);
66
+ for (const fact of candidates) {
67
+ const key = `${fact.task!.repo}#${fact.task!.pr}`;
68
+ if (seen.has(key)) continue;
69
+ seen.add(key); out.push({ repo: fact.task!.repo!, pr: fact.task!.pr! });
70
+ }
71
+ return out.slice(0, limit);
72
+ }
73
+
74
+ /**
75
+ * Read each target once, one at a time, under one total budget. `gh` is the host's runner (`sdlcGh` bound to the project), never an argument a request supplies.
76
+ * `readSdlcPr` validates the repository and PR number itself and builds a fixed read-only argv. Every failure is counted and the old state stands.
77
+ */
78
+ export async function refreshPrStates(
79
+ targets: readonly { repo: string; pr: number }[],
80
+ gh: SdlcGh,
81
+ store: PrObservations,
82
+ options: { now?: () => number; totalMs?: number; callMs?: number; max?: number } = {},
83
+ ): Promise<RefreshResult> {
84
+ const now = options.now ?? Date.now, started = now(), total = options.totalMs ?? REFRESH_TOTAL_MS, perCall = options.callMs ?? REFRESH_CALL_MS;
85
+ const left = () => Math.max(0, started + total - now());
86
+ const bounded: SdlcGh = async (args, timeoutMs, cwd, maxOutputBytes) => left() <= 0 ? { code: 1, stdout: "" } : gh(args, Math.max(1, Math.min(timeoutMs ?? perCall, perCall, left())), cwd, maxOutputBytes);
87
+ const asked = targets.slice(0, options.max ?? REFRESH_MAX_PRS);
88
+ let read = 0, failed = 0, done = 0;
89
+ for (const target of asked) {
90
+ if (left() <= 0) break;
91
+ done++;
92
+ const evidence = await readSdlcPr({ stage: "pr", repo: target.repo, pr: target.pr }, bounded).catch(() => undefined);
93
+ if (!evidence) { failed++; continue; }
94
+ const state = evidence.state === "MERGED" ? "merged" : evidence.state === "CLOSED" ? "closed" : "open";
95
+ const changedAt = state === "open" ? undefined : await readPrChangeTime(target.repo, target.pr, state, bounded);
96
+ store.record(target.repo, target.pr, { state, ...(changedAt !== undefined ? { changedAt } : {}), at: now() });
97
+ read++;
98
+ }
99
+ return { read, failed, skipped: targets.length - done };
100
+ }