@staix/agent-hub 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +2 -2
  3. package/docs/agent-notes/daemon.md +2 -0
  4. package/docs/agent-notes/local-worker.md +3 -1
  5. package/docs/agent-notes/tasks.md +3 -1
  6. package/docs/agent-notes/tests.md +1 -0
  7. package/docs/design/task-board.md +1 -1
  8. package/docs/dogfood.md +6 -3
  9. package/docs/operations.md +73 -11
  10. package/docs/quickstart.md +11 -3
  11. package/docs/sdlc.md +1 -1
  12. package/docs/security.md +17 -2
  13. package/docs/verification/2026-11-03-0.29.0.md +59 -0
  14. package/docs/verification/2026-11-03-control-write-stall-windows.json +241 -0
  15. package/docs/verification/2026-11-03-control-write-stall-windows.md +20 -0
  16. package/docs/verification/2026-11-03-live-board-latency.json +556 -0
  17. package/docs/verification/2026-11-03-live-board-latency.md +64 -0
  18. package/docs/verified.json +13 -13
  19. package/package.json +2 -2
  20. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  21. package/plugins/agent-hub/server.js +524 -18
  22. package/src/cli/help.ts +1 -1
  23. package/src/cli/output.ts +5 -0
  24. package/src/cli/recovery-package.ts +9 -0
  25. package/src/hub/board.ts +3 -2
  26. package/src/hub/control-client.ts +16 -1
  27. package/src/hub/control-write-trace.ts +409 -0
  28. package/src/hub/daemon.ts +171 -47
  29. package/src/hub/hub-tools.ts +1 -1
  30. package/src/hub/routing.ts +9 -2
  31. package/src/hub/runtime-capture-prune.ts +4 -1
  32. package/src/hub/sdlc-release-authorization.ts +13 -4
  33. package/src/hub/sdlc-release.ts +23 -10
  34. package/src/hub/sdlc.ts +15 -2
  35. package/src/hub/task-worktree.ts +331 -0
  36. package/src/hub/tasks.ts +162 -17
  37. package/src/local/deny.ts +49 -0
  38. package/src/local/tools.ts +4 -2
  39. package/src/memory/brief.ts +5 -0
  40. package/src/memory/client.ts +5 -2
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
 
@@ -4044,7 +4082,17 @@ export class Tasks {
4044
4082
  }
4045
4083
  if (stagedClaimGap) return next; // An explicit claim may reserve its owner, but no offer or acceptance bypasses review.
4046
4084
  this.studyDispatchCheck(next, by);
4047
- const claimed = this.d.board.update(next.id, by, "accepted", { state: "in_progress" });
4085
+ // #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.
4086
+ let undoWorktree: (() => void) | undefined;
4087
+ try { undoWorktree = this.hubWorktreeFor(next, by); }
4088
+ catch (error) {
4089
+ if (!(error instanceof PreEffectToolRefusal)) throw error;
4090
+ this.releaseNotice(`${error.message}; the claim stays assigned to ${by} and is not started`);
4091
+ return next;
4092
+ }
4093
+ let claimed: Task;
4094
+ try { claimed = this.d.board.update(next.id, by, "accepted", { state: "in_progress" }); }
4095
+ catch (error) { undoWorktree?.(); throw error; }
4048
4096
  this.captureOwnerAcceptance(claimed);
4049
4097
  this.releaseNotice(`task ${this.publicTitle(claimed)} claimed by ${by}`);
4050
4098
  return claimed;
@@ -4080,7 +4128,8 @@ export class Tasks {
4080
4128
  /** A self-claim has no assignment envelope; its tool result carries the same bounded guidance. */
4081
4129
  claimRoleGuidance(task: Task, peer: PeerId): string {
4082
4130
  requireLocalTaskPeer(peer);
4083
- return task.owner === peer && task.state === "in_progress" ? this.roleGuidance(task, peer) : "";
4131
+ // #1129: a claim by a hub-sandboxed peer carries the worktree note as an accept does, since the claim result is all it reads.
4132
+ return task.owner === peer && task.state === "in_progress" ? [this.roleGuidance(task, peer), this.worktreeNote(task, peer)].filter(Boolean).join("\n") : "";
4084
4133
  }
4085
4134
 
4086
4135
  private roleGuidance(task: Task, peer: PeerId, review = false): string {
@@ -4493,6 +4542,7 @@ export class Tasks {
4493
4542
  const capacity = this.capacityBefore(task);
4494
4543
  this.ciWatchers.cancel(task.id); this.heldReviews.delete(task.id);
4495
4544
  const next = this.d.board.update(task.id, by, "cancelled", { state: "cancelled", reserved: null, cancelled }, cancelled.reason, { ...(withheld ? { withheld: true as const } : {}) }, revision);
4545
+ this.releaseHubWorktree(task, task.owner);
4496
4546
  this.writePlannerNotices(next, capacity);
4497
4547
  this.releaseNotice(`task #${task.id}: cancellation cohort cleanup unavailable`, () => this.cohorts.leave(task.id));
4498
4548
  this.offered.delete(task.id);
@@ -5648,10 +5698,18 @@ export class Tasks {
5648
5698
 
5649
5699
  /** The eval record the last approval-time lifecycle read produced for a task; consumed by the verdict that follows it. */
5650
5700
  private readonly evalRecords = new Map<number, string>();
5651
- private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string, findings?: readonly ReviewFindingSet[]): Promise<PrEvidence | undefined> {
5701
+ private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string, findings?: readonly ReviewFindingSet[], refileHead?: RefileHeadGuard): Promise<PrEvidence | undefined> {
5652
5702
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
5653
5703
  if (config.mode === "off") return;
5654
5704
  const receipt = task.sdlc && { ...task.sdlc, ...(reviewedHead ? { reviewedHead } : {}) };
5705
+ const approvalHead = transition === "approval" ? reviewedHead ?? this.ownerReviewHead(task) ?? this.findingHead(task) : undefined;
5706
+ const guardedHead = !!refileHead || !!approvalHead && !!receipt?.repo && !!receipt.pr && !this.isPii(task);
5707
+ // Compare raw head identity on both sides; `task` may carry an uncommitted identityLink.
5708
+ // Completion state/history races stay with completionCurrent's existing post-readback fence.
5709
+ const identity = (value: Task) => JSON.stringify([value.id, value.owner, value.reviewer, this.isPii(value),
5710
+ value.refs.repo, value.refs.commit, value.refs.branch, value.sdlc?.repo, value.sdlc?.pr,
5711
+ value.sdlc?.head, value.sdlc?.localHead, value.sdlc?.reviewedHead]);
5712
+ const beforeHeadRead = guardedHead ? identity(this.need(task.id, false, true)) : undefined;
5655
5713
  const deadline = performance.now() + 20_000;
5656
5714
  const read: SdlcGh = (args, timeout, cwd, cap) => {
5657
5715
  const current = this.need(task.id, false, true);
@@ -5660,6 +5718,16 @@ export class Tasks {
5660
5718
  return remaining > 0 ? (this.d.sdlc?.gh ?? sdlcGh)(args, Math.min(timeout ?? 10_000, remaining), cwd, cap) : Promise.resolve({ code: 1, stdout: "" });
5661
5719
  };
5662
5720
  const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, read, true) : undefined;
5721
+ const currentHead = receipt && !this.isPii(task) && (refileHead || approvalHead) && !evidence?.head
5722
+ ? await readSdlcPrHead(receipt, read) : evidence?.head;
5723
+ if (beforeHeadRead !== undefined) {
5724
+ const current = this.need(task.id, false, true);
5725
+ if (identity(current) !== beforeHeadRead) throw new PreEffectToolRefusal(`task #${task.id}: task or review head changed during PR readback; reload and retry`);
5726
+ }
5727
+ if (refileHead) {
5728
+ if (!refileHead.recorded || !currentHead) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded);
5729
+ if (currentHead !== refileHead.recorded) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded, currentHead);
5730
+ } else if (approvalHead && currentHead && currentHead !== approvalHead) throw this.reviewHeadMoveRefusal(task.id, approvalHead, currentHead);
5663
5731
  const integrity = publicTestIntegrity(receipt && evidence && !this.isPii(task) ? await readTestIntegrity(receipt, evidence, read) : this.unknownTestIntegrity(this.isPii(task)), this.publicMetadataText);
5664
5732
  const evalResult = receipt && !this.isPii(task) ? await readEvalFor(config, evidence, receipt.repo, read) : undefined;
5665
5733
  const current = this.need(task.id, false, true);
@@ -5863,8 +5931,13 @@ export class Tasks {
5863
5931
  const repositoryChanged = this.repositories.scope(task.refs).key !== this.repositories.scope(draft.refs).key;
5864
5932
  const before = new Set(this.overlapHits(task).map((h) => h.task.id));
5865
5933
  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
5934
+ // #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.
5935
+ const undoWorktree = this.hubWorktreeFor(task, task.owner ?? by);
5936
+ let next: Task;
5937
+ try {
5938
+ this.acceptanceCapacity(task, 0, "displace"); // #1080: every gate has passed; synchronous from here to the accept write
5939
+ 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
5940
+ } catch (error) { undoWorktree?.(); throw error; }
5868
5941
  this.captureOwnerAcceptance(next);
5869
5942
  this.d.notify(`task ${this.publicTitle(next)} accepted by ${by}`);
5870
5943
  if (repositoryChanged) this.cohorts.leave(next.id);
@@ -5925,6 +5998,51 @@ export class Tasks {
5925
5998
  /** #572 AC3: what the owner's `hub_task_done` result adds while the project root checkout is on a task branch. */
5926
5999
  checkoutNote = (): string | undefined => { const warning = mainCheckoutWarning(this.d.cwd, this.publicMetadataText); return warning ? `Note: ${warning}.` : undefined; };
5927
6000
 
6001
+ /**
6002
+ * #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
6003
+ * 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
6004
+ * accept before any board write and names the cause and the next step; nothing is left behind.
6005
+ */
6006
+ private hubWorktreeFor(task: Task, peer: PeerId): (() => void) | undefined {
6007
+ const port = this.d.taskWorktree;
6008
+ if (!port || peer === USER || peer === HUB || !port.applies(peer) || this.isPii(task) || !(task.signals.includes("needs_worktree") || writesRepository(task.class))) return undefined;
6009
+ const made = port.create({ peer, task: task.id });
6010
+ 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`);
6011
+ return made.created ? () => { try { port.remove({ peer, task: task.id }); } catch { /* the accept error that got us here is the one reported */ } } : undefined;
6012
+ }
6013
+
6014
+ /** 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`. */
6015
+ worktreeNote(task: Task, peer: PeerId): string | undefined {
6016
+ const found = this.d.taskWorktree?.describe(peer, task.id);
6017
+ 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;
6018
+ }
6019
+
6020
+ /**
6021
+ * #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
6022
+ * 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.
6023
+ */
6024
+ async worktreeHandoff(by: PeerId, id: unknown, refs: unknown): Promise<TaskRefs | undefined> {
6025
+ const port = this.d.taskWorktree;
6026
+ if (!port || by === USER || by === HUB || !port.applies(by)) return undefined;
6027
+ const task = this.need(id, false, true);
6028
+ this.mine(task, by, "owner", true);
6029
+ if (this.isPii(task) || !port.describe(by, task.id)) return undefined;
6030
+ const result = await port.commitAndPush({ peer: by, task: task.id, title: task.title });
6031
+ if (!result.ok) throw new PreEffectToolRefusal(`hub_task_done for task #${task.id}: ${result.reason}`);
6032
+ if (result.note) this.doneNotes.set(task.id, result.note);
6033
+ return { ...cleanRefs(refs), commit: result.commit, branch: result.branch };
6034
+ }
6035
+
6036
+ /** Decline or cancel: clear the hub-made worktree and its empty branch. Uncommitted work is kept and reported, never force-removed. */
6037
+ private releaseHubWorktree(task: Task, peer: PeerId | null | undefined): void {
6038
+ const port = this.d.taskWorktree;
6039
+ if (!port || !peer || peer === USER || peer === HUB || !port.applies(peer)) return;
6040
+ this.releaseNotice(`task #${task.id}: the hub-made worktree could not be cleared`, () => {
6041
+ const removal = port.remove({ peer, task: task.id });
6042
+ if (removal.kept) this.d.notify(`task #${task.id}: ${removal.kept}`);
6043
+ });
6044
+ }
6045
+
5928
6046
  /**
5929
6047
  * `capacity` (#531): the owner has no room right now, so the decline excludes it only from this move's own reroute
5930
6048
  * (below), never from a later reroute or planner pull. Without it, a decline is a lasting refusal (`excluded()`), and so
@@ -5939,6 +6057,7 @@ export class Tasks {
5939
6057
  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
6058
  if (planned) this.offered.delete(task.id);
5941
6059
  this.d.notify(`task ${this.publicTitle(back)} declined${capacity === true ? " (capacity only)" : ""} by ${by}${reason && !this.isPii(back) ? `: ${reason}` : ""}`);
6060
+ this.releaseHubWorktree(task, task.owner ?? by);
5942
6061
  // A capacity decline is not in excluded(), so this move's own reroute leaves the decliner out explicitly.
5943
6062
  try {
5944
6063
  return await this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true, ...(capacity === true && task.owner ? { exclude: [task.owner] } : {}), ...(planned ? { planner: true } : {}) });
@@ -6122,11 +6241,20 @@ export class Tasks {
6122
6241
  let refresh: CompletionRefresh | undefined;
6123
6242
  if (initial.state === "in_review" || initial.state === "approved") {
6124
6243
  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`);
6244
+ const recorded = this.ownerReviewHead(initial) ?? this.findingHead(initial);
6245
+ const currentReviewHead = this.ownerReviewHead(initial) ?? this.reviewHead(initial);
6246
+ if (!commit || !/^[a-f0-9]{40}$/.test(commit) || commit === currentReviewHead) {
6247
+ 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`);
6248
+ }
6127
6249
  refresh = { revision: initial.history.length, commit, state: initial.state };
6128
6250
  }
6129
6251
  const submittedCommit = cleanRefs(refs).commit;
6252
+ if (submittedCommit && this.hasReviewHistory(initial) && !/^[a-f0-9]{40}$/.test(submittedCommit)) {
6253
+ const recorded = this.ownerReviewHead(initial);
6254
+ 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`);
6255
+ }
6256
+ const refileHead: RefileHeadGuard | undefined = !submittedCommit && initial.sdlc?.repo && initial.sdlc.pr && this.hasReviewHistory(initial) && !this.isPii(initial)
6257
+ ? { recorded: this.ownerReviewHead(initial) } : undefined;
6130
6258
  const draft = refresh ?? (submittedCommit && /^[a-f0-9]{40}$/.test(submittedCommit) ? { commit: submittedCommit } : undefined);
6131
6259
  const valid = () => { requestCheck(); return this.completionDraft(this.completionCurrent(id, by, true, refresh), draft); };
6132
6260
  if (initial.state === "approved") { await this.unmergedForReopen(initial, valid); valid(); }
@@ -6150,9 +6278,15 @@ export class Tasks {
6150
6278
  const current = () => this.withLink(valid(), link, refs);
6151
6279
  task = current();
6152
6280
  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;
6281
+ if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { reviewEvidence = await this.lifecycleGate(task, "review", undefined, undefined, refileHead); task = current(); }
6282
+ 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
6283
  task = current();
6284
+ if (refileHead && (this.d.sdlc?.config().mode ?? "off") === "off") {
6285
+ const currentHead = await this.currentPrHeadForReview(task, current);
6286
+ task = current();
6287
+ if (!refileHead.recorded || !currentHead) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded);
6288
+ if (currentHead !== refileHead.recorded) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded, currentHead);
6289
+ }
6156
6290
  const currentSpecTask = () => { const fresh = current(); return { ...fresh, refs: { ...fresh.refs, ...cleanRefs(refs) } }; };
6157
6291
  const specInspection = await this.inspectTaskSpecs(currentSpecTask(), by, specAck, currentSpecTask);
6158
6292
  task = current(); // the host head and task authority are revalidated after spec inspection
@@ -6507,7 +6641,7 @@ export class Tasks {
6507
6641
 
6508
6642
  /** The one place a review request is written: the first reviewer and a replacement get the same text, refs and privacy. */
6509
6643
  /** 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> {
6644
+ 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
6645
  const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
6512
6646
  if (mode === "off" && !this.d.prepareReviewBrief) return undefined;
6513
6647
  let quietDrift = false;
@@ -6544,8 +6678,13 @@ export class Tasks {
6544
6678
  const evidence: ReviewReadiness = this.isPii(task)
6545
6679
  ? { ready: false, blockers: ["private review readiness unavailable"] }
6546
6680
  : 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"] };
6681
+ const currentHead = refileHead && !evidence.head && task.sdlc ? await readSdlcPrHead(task.sdlc, read) : evidence.head;
6547
6682
  const current = guard();
6548
6683
  const admitted: ReviewReadiness = quietDrift ? { ready: false, blockers: ["review readiness unknown: task changed during readback"] } : evidence;
6684
+ if (refileHead) {
6685
+ if (!refileHead.recorded || !currentHead) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded);
6686
+ if (currentHead !== refileHead.recorded) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded, currentHead);
6687
+ }
6549
6688
  if (!admitted.ready) {
6550
6689
  const message = `task ${this.publicTitle(current)}: premature review: ${admitted.blockers.join("; ")}`;
6551
6690
  if (mode === "enforce") throw new (preEffect ? PreEffectToolRefusal : Error)(message);
@@ -6879,7 +7018,7 @@ export class Tasks {
6879
7018
  // notice. The rule is structural (a finding with disposition open), never a reading of the unmet text.
6880
7019
  const codeScope = by !== USER && this.d.reviewScope?.(by) === "code";
6881
7020
  const evidenceOnly = codeScope && verdict === "changes_requested" && !parsed.some(finding => finding.disposition === "open");
6882
- const resolvedHead = this.findingHead(initial);
7021
+ const resolvedHead = this.ownerReviewHead(initial) ?? this.findingHead(initial);
6883
7022
  let findingHead = resolvedHead;
6884
7023
  if (head !== undefined) {
6885
7024
  try { findingHead = sdlcReceipt({ reviewedHead: head }, initial.sdlc, this.d.sdlc?.config() ?? sdlcConfig(undefined)).reviewedHead; }
@@ -6926,7 +7065,7 @@ export class Tasks {
6926
7065
  if (gap.length) note = `${note ?? ""}\nCoverage gap (${gap.length}): ${list(gap)}`.trim();
6927
7066
  }
6928
7067
  }
6929
- if (head !== undefined && resolvedHead && findingHead !== resolvedHead) throw new PreEffectToolRefusal("supplied review head differs from the task resolved head");
7068
+ 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
7069
  // The PII screen reads the note of a review that passes these checks (issue #198); they run again after it.
6931
7070
  const withheld = await this.withholds("review note", parsed.length ? JSON.stringify({ note, findings: parsed }) : note, valid())
6932
7071
  || parsed.length > 0 && !this.findingTextAllowed(initial, parsed);
@@ -6963,6 +7102,12 @@ export class Tasks {
6963
7102
  await this.lifecycleGate(task, "approval", reviewedHead, proposedSets(task)); task = valid();
6964
7103
  const record = this.evalRecords.get(task.id); this.evalRecords.delete(task.id);
6965
7104
  if (record) note = `${note ?? ""}\n${record}`.trim();
7105
+ } else if (verdict === "approved" && this.d.sdlc?.config().mode === "off" && this.d.sdlc?.gh && task.sdlc?.repo && task.sdlc.pr
7106
+ && this.ownerReviewHead(task) && !this.isPii(task)) {
7107
+ const expected = reviewedHead ?? this.ownerReviewHead(task) ?? this.findingHead(task);
7108
+ const currentHead = await this.currentPrHeadForReview(task, valid);
7109
+ task = valid();
7110
+ if (expected && currentHead && currentHead !== expected) throw this.reviewHeadMoveRefusal(task.id, expected, currentHead);
6966
7111
  }
6967
7112
  let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
6968
7113
  if (verdict === "changes_requested" && !evidenceOnly && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
package/src/local/deny.ts CHANGED
@@ -77,6 +77,54 @@ const hooksUnder = (parent: string): string => {
77
77
  return depth > MAX_ANCHOR_DEPTH ? `^.*/${hfsFoldedName("hooks")}(/|$)` : `^${"/[^/]+".repeat(depth)}/${hfsFoldedName("hooks")}(/|$)`;
78
78
  };
79
79
 
80
+ /**
81
+ * #1123: a sandboxed command may not move what a branch, HEAD or a linked worktree points at. `refs/**` (branches, tags, `refs/stash`), `packed-refs*`, `HEAD*` (its lock too) and
82
+ * `worktrees/**` (a linked worktree's admin dir: its HEAD, index, logs) are refused directly under every plain `.git` and under each external git dir (the common dir and a linked
83
+ * worktree's own, whatever they are named). The hub's own git runs outside the sandbox and is unaffected, so #1115's hub commit still lands.
84
+ *
85
+ * Every name must sit DIRECTLY under its git dir. An unanchored `refs` or `worktrees` filter would also match an ANCESTOR of the project with that name, and a project under
86
+ * `~/worktrees/` would lose every `.git` write (the #520 `hooks` lesson). So each rule is adjacent by construction: one literal for `.git` + a short name, or, for the long names
87
+ * (`worktrees`, `packed-refs`, where `.git` + name would pass the 1024-byte literal limit), a front half and a back half that must both match, or a depth anchor for an external dir.
88
+ * A submodule's git dir (`.git/modules/<name>`, nested ones, and `.git/worktrees/<wt>/modules/<name>` for a linked worktree) is covered the same way (`subDirs`, the discovered
89
+ * ones: a git dir that is not discovered is refused whole by the #518/#520 rules), per the person's "deny .git writes" decision: nothing is listed as a residual.
90
+ */
91
+ const refDepth = (d: string): number => d.split("/").filter(Boolean).length;
92
+ /**
93
+ * `^(/[^/]+){depth}/<name>(/|$)`: `name` as the entry directly under `d`, the depth written out because seatbelt ignores `{n}`. The folded names cost (measured, bytes of regex): `refs` 334,
94
+ * `worktrees` 669, so past the depth where the path plus the name would near 1024 it falls back to the unanchored name, which over-refuses only under an ancestor with that name and
95
+ * never under-protects.
96
+ */
97
+ const refUnder = (d: string, name: string): string => {
98
+ const depth = refDepth(d), cost = Buffer.byteLength(hfsFoldedName(name)) + depth * 6 + 8;
99
+ return cost > 1000 ? `^.*/${hfsFoldedName(name)}(/|$)` : `^${"/[^/]+".repeat(depth)}/${hfsFoldedName(name)}(/|$)`;
100
+ };
101
+ function gitRefRules(gitDirs: string[], subDirs: string[] = []): string[] {
102
+ const git = hfsFoldedName(".git");
103
+ const re = (s: string) => `(regex ${sbplString(s)})`;
104
+ const name = (n: string) => hfsFoldedName(n);
105
+ // A linked worktree's own submodule git dirs (`worktrees/<wt>/modules/**`) are governed by the #518/#520 rules, not these.
106
+ const plainSub = `(require-not ${re(`^.*/${git}/worktrees/[^/]+/modules(/|$)`)})`;
107
+ const plain = [
108
+ // `.git` (335 bytes) + `HEAD` or `refs` (334) fit one literal: adjacent by construction. `HEAD.lock` goes with `HEAD`.
109
+ re(`^.*/${git}/${name("HEAD")}(\\.lock)?$`),
110
+ re(`^.*/${git}/${name("refs")}(/|$)`),
111
+ // `.git` + `packed-refs` (803) and `.git` + `worktrees` (669) do not fit one literal. The front literal pins the start of the name right under a `.git`, and for `worktrees` the
112
+ // back literal pins its end; `packed` alone covers `packed-refs` and its lock/new files, which is the intent.
113
+ re(`^.*/${git}/${name("packed")}`),
114
+ `(require-all ${re(`^.*/${git}/${name("work")}`)} ${re(`^.*${name("trees")}(/|$)`)} ${plainSub})`,
115
+ ];
116
+ const external = [...gitDirs, ...subDirs].flatMap((d) => {
117
+ const own = sbplString(d), direct = re(`^${sbplEscape(d)}/[^/]+$`);
118
+ return [
119
+ `(require-all ${direct} ${re(`^.*/${name("HEAD")}(\\.lock)?$`)})`,
120
+ `(require-all ${direct} ${re(`^.*/${name("packed")}`)})`,
121
+ `(require-all (subpath ${own}) ${re(refUnder(d, "refs"))})`,
122
+ `(require-all (subpath ${own}) ${re(refUnder(d, "worktrees"))} (require-not ${re(`^${sbplEscape(d)}/worktrees/[^/]+/modules(/|$)`)}))`,
123
+ ];
124
+ });
125
+ return [...plain, ...external];
126
+ }
127
+
80
128
  export function hubWriteRegexes(root: string, gitDirs: string[] = [], moduleGit: readonly { root: string; entries: string[]; dirs?: string[] }[] = [], worktreesRoot?: string): string[] {
81
129
  const git = hfsFoldedName(".git");
82
130
  const re = (s: string) => `(regex ${sbplString(s)})`;
@@ -157,6 +205,7 @@ export function hubWriteRegexes(root: string, gitDirs: string[] = [], moduleGit:
157
205
  `(require-all ${re(`^.*/${git}/[^/]+$`)} ${CONFIG_WORKTREE_NAME.map(re).join(" ")})`,
158
206
  re(`^.*/${git}/${hfsFoldedName("hooks")}(/|$)`),
159
207
  `(require-all ${re(`^.*/${git}/.*$`)} ${re(`^.*/${hfsFoldedName("commondir")}$`)})`,
208
+ ...gitRefRules(gitDirs, allDirs),
160
209
  ...modules,
161
210
  ...siblings,
162
211
  ...external,
@@ -117,7 +117,7 @@ export const TOOL_SCHEMAS = [
117
117
  fn("write", "Create or overwrite a file inside the project. Needs the user's approval.", { path: str, content: str }, ["path", "content"]),
118
118
  fn("edit", "Replace one exact, unique occurrence of `old` with `new` in a file. Needs the user's approval.", { path: str, old: str, new: str }, ["path", "old", "new"]),
119
119
  fn("bash", "Run a shell command in the project directory, sandboxed: no writes outside the project, no credential reads, no network unless the hub enables it. Follows hub permission policy: proven bounded read-only commands may run automatically in ask-when-needed; other commands ask.", { command: str, timeout_s: { type: "integer", minimum: 1, maximum: 600 } }, ["command"]),
120
- fn("git", "Run git in the project. status, diff, log, show, ls-files, blame, rev-parse run freely; add, commit, restore, checkout, switch, stash, mv, rm, branch, merge, rebase, cherry-pick, revert, tag need approval; everything else (push, fetch, config, remote ...) is refused.", { args: { type: "array", items: str, minItems: 1 } }, ["args"]),
120
+ fn("git", "Run git in the project. status, diff, log, show, ls-files, blame, rev-parse run freely; add, commit, restore, checkout, switch, stash, mv, rm, branch, merge, rebase, cherry-pick, revert, tag need approval; everything else (push, fetch, config, remote ...) is refused. The sandbox does not let you move a branch, tag or HEAD anywhere (commit, branch, tag, stash, switch, merge, rebase and reset fail, submodules included), nor stage with add inside a linked worktree: edit files, and the hub commits and pushes them when you call hub_task_done.", { args: { type: "array", items: str, minItems: 1 } }, ["args"]),
121
121
  fn("hub_send", "Send a message to the other agents now, before your turn ends. Conclusions only. Your final answer is shared anyway; use this for something that cannot wait.", { text: str, to: { type: "array", items: str } }, ["text"]),
122
122
  ];
123
123
 
@@ -235,7 +235,9 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
235
235
  // Sandboxed like bash: flags such as --output or an editor cannot write outside the project or reach the network.
236
236
  const res = await sandboxedExec(["git", "--no-pager", ...args], { cwd: ctx.cwd, profile: ctx.sandboxProfile, readOnlyData: !!ctx.readOnly?.length, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
237
237
  try { ctx.onProcessExit?.(res.code); } catch { /* optional status observer never changes a tool result */ }
238
- return `${res.output}\n(exit ${res.code})`;
238
+ // #1123: the profile refuses writes to refs, HEAD, packed-refs and a linked worktree's admin dir, so a commit, branch, tag, stash or switch cannot succeed here. Say so, with the next step.
239
+ const refused = res.code !== 0 && /(?:\.git\/(?:refs|HEAD|packed-refs|worktrees)\b|cannot lock ref|index\.lock)[^\n]*(?:Operation not permitted|not permitted)/i.test(res.output);
240
+ return `${res.output}${refused ? "\nnote: the sandbox does not let you move a branch, tag or HEAD, or stage inside a linked worktree (#1123). Leave your edits in the working tree and finish with hub_task_done. For implementation work the hub stages, commits and pushes them for you; for work that changes no files, or a private task, there is nothing for it to commit, so do not try to commit it yourself." : ""}\n(exit ${res.code})`;
239
241
  }
240
242
  case "doc_read": {
241
243
  if (!ctx.docRead) return "error: doc_read is not enabled for this project (local.doc_read in .agenthub/config.local.json)";
@@ -1,4 +1,5 @@
1
1
  import { buildIntentBrief, type IntentBriefOptions, type IntentBriefSource } from "@staix/agent-mem";
2
+ import { controlWriteSpan } from "../hub/control-write-trace.ts";
2
3
  import type { PeerId } from "../hub/envelope.ts";
3
4
  import type { MemoryClient } from "./client.ts";
4
5
 
@@ -70,6 +71,10 @@ export class Briefs {
70
71
  ) {}
71
72
 
72
73
  async forTask(peer: PeerId, task: BriefTask, priority: readonly NativeItem[] = []): Promise<string | undefined> {
74
+ return controlWriteSpan("memory", () => this.forTaskBody(peer, task, priority));
75
+ }
76
+
77
+ private async forTaskBody(peer: PeerId, task: BriefTask, priority: readonly NativeItem[]): Promise<string | undefined> {
73
78
  // A brief snapshots its configured cap once, before either optional source is selected.
74
79
  const maximum = typeof this.maxItems === "function" ? this.maxItems() : this.maxItems;
75
80
  let corpusRevision: string | undefined;
@@ -1,5 +1,6 @@
1
1
  import packageJson from "../../package.json";
2
2
  import { createHash } from "node:crypto";
3
+ import { controlWriteSpan } from "../hub/control-write-trace.ts";
3
4
  import { CaptureStore, SearchIndex, looksSecret, memoryBase, readCaptureConfig, type CaptureOptions as StoreCaptureOptions, type MemoryStore, type CandidateStore, type View } from "@staix/agent-mem";
4
5
 
5
6
  export interface CaptureEvent {
@@ -54,8 +55,10 @@ export class MemoryClient {
54
55
  }
55
56
  close(): void { this.closed = true; this.releaseCapture(); }
56
57
  private run<T>(work: (o: MemoryClientOptions) => T): T | undefined {
57
- try { if (!this.options || this.closed) return undefined; this.options.check(); return work(this.options); }
58
- catch { this.releaseCapture(); this.options?.log?.("amem operation unavailable"); return undefined; }
58
+ return controlWriteSpan("memory", () => {
59
+ try { if (!this.options || this.closed) return undefined; this.options.check(); return work(this.options); }
60
+ catch { this.releaseCapture(); this.options?.log?.("amem operation unavailable"); return undefined; }
61
+ });
59
62
  }
60
63
  private rows(o: MemoryClientOptions): View[] {
61
64
  const store = o.store;