@staix/agent-hub 0.13.0 → 0.15.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 (92) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +19 -7
  3. package/docs/agent-notes/README.md +4 -0
  4. package/docs/agent-notes/adapters.md +18 -4
  5. package/docs/agent-notes/budget.md +5 -4
  6. package/docs/agent-notes/bus.md +1 -0
  7. package/docs/agent-notes/daemon.md +19 -7
  8. package/docs/agent-notes/tasks.md +15 -7
  9. package/docs/agent-notes/tests.md +4 -4
  10. package/docs/dogfood.md +18 -10
  11. package/docs/events.md +21 -7
  12. package/docs/operations.md +131 -27
  13. package/docs/profiles.md +1 -0
  14. package/docs/quickstart.md +13 -3
  15. package/docs/sdlc.md +40 -2
  16. package/docs/security.md +45 -14
  17. package/docs/specs/2026-09-19-agent-hub-design.md +112 -10
  18. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +14 -0
  19. package/docs/verification/2026-10-23-0.14.0.md +69 -0
  20. package/docs/verification/2026-10-23-0.15.0.md +80 -0
  21. package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
  22. package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
  23. package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
  24. package/docs/verified.json +34 -17
  25. package/package.json +1 -1
  26. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  27. package/plugins/agent-hub/server.js +1078 -327
  28. package/src/adapters/acp.ts +43 -7
  29. package/src/adapters/claude-channel.ts +26 -4
  30. package/src/adapters/claude-implementer.ts +243 -0
  31. package/src/adapters/claude-print-child.ts +90 -0
  32. package/src/adapters/claude-task-sessions.ts +80 -0
  33. package/src/adapters/codex-review-isolation.ts +18 -7
  34. package/src/adapters/compat.ts +4 -0
  35. package/src/adapters/conformance/enrollment.ts +2 -1
  36. package/src/adapters/conformance/fakes/acp-server.ts +3 -1
  37. package/src/adapters/definitions/builtin-metadata.ts +15 -3
  38. package/src/adapters/definitions/native-channel-state.ts +1 -1
  39. package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
  40. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
  41. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  42. package/src/adapters/definitions/native-factories.ts +8 -0
  43. package/src/adapters/kimi-wire-usage.ts +303 -0
  44. package/src/adapters/registry-types.ts +7 -3
  45. package/src/adapters/registry.ts +14 -2
  46. package/src/adapters/reviewer-print.ts +64 -60
  47. package/src/cli/console-config.ts +123 -0
  48. package/src/cli/console-state.ts +28 -1
  49. package/src/cli/console.ts +25 -1
  50. package/src/cli/facts-hook.ts +9 -5
  51. package/src/cli/help.ts +6 -4
  52. package/src/cli/launch.ts +10 -4
  53. package/src/cli/main.ts +52 -9
  54. package/src/cli/output.ts +75 -2
  55. package/src/cli/reset.ts +1 -1
  56. package/src/cli/settings-edit.ts +89 -0
  57. package/src/cli/status-lines.ts +1 -1
  58. package/src/cli/terminal-recovery.ts +1 -6
  59. package/src/hub/action-enforce.ts +82 -0
  60. package/src/hub/action-policy.ts +22 -10
  61. package/src/hub/board.ts +48 -3
  62. package/src/hub/budget.ts +5 -0
  63. package/src/hub/bus.ts +5 -5
  64. package/src/hub/claude-launch-proof.ts +33 -0
  65. package/src/hub/config-apply.ts +5 -0
  66. package/src/hub/config-edit-host.ts +173 -0
  67. package/src/hub/config-edit.ts +282 -0
  68. package/src/hub/config-tier.ts +89 -0
  69. package/src/hub/control-client.ts +24 -3
  70. package/src/hub/daemon.ts +450 -71
  71. package/src/hub/deliverable-files.ts +131 -0
  72. package/src/hub/deliverables.ts +125 -0
  73. package/src/hub/delivery-journal.ts +18 -2
  74. package/src/hub/events.ts +3 -1
  75. package/src/hub/hub-tools.ts +21 -4
  76. package/src/hub/instances.ts +19 -6
  77. package/src/hub/kimi-usage.ts +144 -0
  78. package/src/hub/manager.ts +3 -3
  79. package/src/hub/peers.ts +7 -0
  80. package/src/hub/permission-payload.ts +15 -0
  81. package/src/hub/routing.ts +118 -3
  82. package/src/hub/sdlc-metrics.ts +260 -0
  83. package/src/hub/sdlc.ts +5 -3
  84. package/src/hub/settings.ts +1 -1
  85. package/src/hub/spec-read.ts +31 -0
  86. package/src/hub/task-code-group.ts +52 -0
  87. package/src/hub/tasks.ts +374 -37
  88. package/src/hub/token-window.ts +33 -0
  89. package/src/hub/ui.ts +3 -0
  90. package/src/hub/usage-rollup.ts +74 -6
  91. package/src/hub/workflow.ts +16 -0
  92. package/src/ui/index.html +388 -3
package/src/hub/tasks.ts CHANGED
@@ -4,7 +4,8 @@ import { publicTestIntegrity, readTestIntegrity, testIntegrityBlockers, type Tes
4
4
  import { completePlanInventory, planConformance, planConformanceBlockers, planFingerprint, type PlanConformance } from "./plan-conformance.ts";
5
5
  import { RELEASE_TRAIN_HISTORY_LIMIT, releaseTrainData, releaseTrainReadiness, releaseMetrics } from "./sdlc-release.ts";
6
6
  import { cleanCostPlan, costMetrics, type CostPlan, type ReviewCost } from "./cost-governance.ts";
7
- import { specRefs } from "./spec-read.ts";
7
+ import { qualifiedTitleRepos, siblingTitleRefs, specRefs } from "./spec-read.ts";
8
+ import { sdlcMetrics, type BandResult, type MetricTask, type MetricsReport } from "./sdlc-metrics.ts";
8
9
  import type { InstancesConfig } from "./instances.ts";
9
10
  import { composeRememberText } from "./memory-fail.ts";
10
11
  import { VERSION } from "../version.ts";
@@ -20,7 +21,9 @@ import { correctionInput, resolveVisibleMemory } from "./memory-candidates.ts";
20
21
  import type { MemoryScreen } from "./read-tools.ts";
21
22
  import { withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
22
23
  import { profileClass, profileData, profileTaskLabel, projectProfile, type ProfileName, type ProfileSelection, type ProjectProfile } from "./profiles.ts";
23
- import { criteriaView, workflowHash } from "./workflow.ts";
24
+ import { criteriaView, unmetCriteriaIds, workflowHash } from "./workflow.ts";
25
+ import { approvedDeliverables, latestDeliverables, parseDeliverableClaims, publicDeliverables, type DeliverableRecord, type DeliverableStatus } from "./deliverables.ts";
26
+ import { checkDeliverables, DeliverableRefusal, hashDeliverables, vetDeliverables, type DeliverablePorts } from "./deliverable-files.ts";
24
27
  import { isAbsolute, relative } from "node:path";
25
28
  import type { Briefs, NativeItem } from "../memory/brief.ts";
26
29
  import type { MemoryClient } from "../memory/client.ts";
@@ -65,6 +68,13 @@ export interface TasksDeps {
65
68
  profile?: ProfileName;
66
69
  profileData?: ProjectProfile;
67
70
  piiPaths?: string[];
71
+ /** The project's secrets denylist (`local.deny`), for the deliverable file reader (#509a). */
72
+ deny?: string[];
73
+ /**
74
+ * #509a: the root a peer's files live under: its verified execution root (the daemon's `ownerWriteRoot`), which for a declared worktree
75
+ * instance is not the project checkout. `undefined` means the root is not proven right now. Absent (library use): the project root.
76
+ */
77
+ executionRoot?: (peer: PeerId) => string | undefined;
68
78
  catalog?: RoleCatalog;
69
79
  /** Role ids whose contract was prepared for the current native session. */
70
80
  sessionRoles?: (peer: PeerId) => string[];
@@ -91,6 +101,8 @@ export interface TasksDeps {
91
101
  reviewOnly?: (peer: PeerId) => boolean;
92
102
  /** #223: captured when a verdict call starts; a non-undefined result refuses the verdict at every later check. */
93
103
  reviewAuthority?: (by: PeerId, task: number) => (() => string | undefined) | undefined;
104
+ /** Host-captured current-child authority, rechecked before an admitted async tool first commits effects. */
105
+ requestAuthority?: (by: PeerId) => (() => string | undefined) | undefined;
94
106
  memory?: MemoryClient;
95
107
  /** Captured native profile fence at our memory operation boundaries; no board mutation. */
96
108
  memoryCheck?: () => void;
@@ -182,6 +194,11 @@ const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
182
194
  const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep", "memory reference started"]);
183
195
  QUIET_EVENTS.add("ready held"); // #655: the hub's note that a ready task waits for its reserved owner's WIP slot.
184
196
  QUIET_EVENTS.add("plan observed"); // Host observation does not move the task or replace completion intent.
197
+ /** The records of the `done (checking)` entry a passed check now completes: what the owner delivered then (#509a). */
198
+ const latestPendingDeliverables = (task: Task): DeliverableRecord[] | undefined => {
199
+ const checking = task.history.findLastIndex(entry => entry.event === "done (checking)");
200
+ return checking >= 0 && !task.history.slice(checking + 1).some(entry => entry.event === "done") ? task.history[checking]!.deliverables : undefined;
201
+ };
185
202
 
186
203
  /**
187
204
  * Thrown when an assignment's board write is saved but a publish or notify after it failed (#297): the move is
@@ -357,6 +374,12 @@ export class Tasks {
357
374
  };
358
375
 
359
376
  /** One bounded screen call. A screener that fails is no verdict, handled as PII (fail closed). */
377
+ private requestCheck(by: PeerId): () => void {
378
+ const authority = this.d.requestAuthority?.(by);
379
+ const check = () => { const reason = authority?.(); if (reason) throw new PreEffectToolRefusal(reason); };
380
+ check(); return check;
381
+ }
382
+
360
383
  private async screenCall(text: string): Promise<PiiVerdict> {
361
384
  return (await this.d.piiScreen?.(text).catch(() => undefined)) ?? { label: "unknown", miss: "failed", ms: 0 };
362
385
  }
@@ -558,6 +581,31 @@ export class Tasks {
558
581
  }
559
582
 
560
583
  /** Host-authored proposals contain only cleared IDs, integer task references and fixed instruction targets. */
584
+ /**
585
+ * #736: how many separate pieces of work the tasks are. Tasks that share a PR, a branch in one repository, or a
586
+ * supersession (a cancelled task names its replacement) are one piece of work, as an approve-as-step follow-up is: a
587
+ * finding carried between them is the same finding, not a recurrence.
588
+ */
589
+ private workUnits(rows: readonly Task[]): number {
590
+ const parent = new Map<number, number>();
591
+ const find = (id: number): number => { let at = id; while (parent.has(at)) at = parent.get(at)!; return at; };
592
+ const join = (a: number, b: number) => { const x = find(a), y = find(b); if (x !== y) parent.set(x, y); };
593
+ const first = new Map<string, number>(), ids = new Set(rows.map(row => row.id));
594
+ for (const row of rows) {
595
+ // Repository identity comes from the board's own resolver, so aliases match and an outside path never falls back to this project.
596
+ const prRepo = row.sdlc?.pr ? this.repositories.scope(row.sdlc.repo ? { repo: row.sdlc.repo, paths: row.refs.paths } : row.refs).key : "";
597
+ const branchRepo = row.refs.branch ? this.repositories.scope(row.refs).key : "";
598
+ for (const key of [prRepo ? `pr ${prRepo}#${row.sdlc!.pr}` : "", branchRepo ? `branch ${branchRepo} ${row.refs.branch}` : ""]) {
599
+ if (!key) continue;
600
+ const seen = first.get(key);
601
+ if (seen === undefined) first.set(key, row.id); else join(row.id, seen);
602
+ }
603
+ // superseded_by is free text ("task #73", "#73" or "73"); the first task number in it names the replacement.
604
+ const next = Number(/#?(\d+)/.exec(row.cancelled?.supersededBy ?? "")?.[1]);
605
+ if (ids.has(next)) join(row.id, next);
606
+ }
607
+ return new Set(rows.map(row => find(row.id))).size;
608
+ }
561
609
  private proposeRecurringFindings(task: Task, by: PeerId, items: readonly ReviewFinding[], withheld: boolean): void {
562
610
  if (withheld || this.isPii(task) || this.d.privateTurn?.(by) || !items.length || !this.findingTextAllowed(task, items)) return;
563
611
  const rows = this.d.board.list();
@@ -573,7 +621,7 @@ export class Tasks {
573
621
  const finding = last?.reviewFindings?.items.find(value => value.id === item.id);
574
622
  return !last?.withheld && finding && same(finding) && finding.disposition !== "false_positive" && this.findingTextAllowed(row, [finding]);
575
623
  });
576
- if (new Set(holders.map(row => row.id)).size < 2) continue;
624
+ if (this.workUnits(holders) < 2) continue;
577
625
  const key = `Review finding key: ${item.pass}:${item.id}`;
578
626
  if (keys.has(key)) continue;
579
627
  const title = `Review policy amendment for recurring finding ${item.pass}:${item.id}`;
@@ -607,6 +655,8 @@ export class Tasks {
607
655
  ...(h.owner === undefined ? {} : { owner: h.owner }),
608
656
  ...(h.reviewerFamily ? { reviewerFamily: screenHistoryText(h.reviewerFamily, "reviewer family") } : {}),
609
657
  ...(h.reviewerReason ? { reviewerReason: h.reviewerReason } : {}),
658
+ ...(h.unmetCriteria?.length ? { unmetCriteria: h.unmetCriteria } : {}),
659
+ ...(h.deliverables?.length ? { deliverables: publicDeliverables(h.deliverables, this.nameable) } : {}),
610
660
  ...(h.reviewBlocked ? { reviewBlocked: true } : {}),
611
661
  ...(h.reviewerFailure ? { reviewerFailure: { peer: screenHistoryText(h.reviewerFailure.peer, "reviewer"), cause: h.reviewerFailure.cause, ...(h.reviewerFailure.head ? { head: screenHistoryText(h.reviewerFailure.head, "review head") } : {}) } } : {}),
612
662
  ...(h.planning ? { planning: { revision: h.planning.revision, fields: h.planning.fields, ...(h.planning.split ? { children: h.planning.split.children.map(child => child.id) } : {}) } } : {}),
@@ -744,10 +794,19 @@ export class Tasks {
744
794
  }
745
795
 
746
796
  /** What assignment weighs besides states and policy: quota, demotion and the review record. */
797
+ /**
798
+ * #525-3: the settled planner holders, sorted: the one input of the planner walk. It comes from the same settled role map as planner
799
+ * authority (`d.roles`), restricted to rostered peers, so assignment, planner notices and `route explain` agree on who the holders are.
800
+ * Read-only for now: the walk's result is shown and recorded nowhere until 525-2.
801
+ */
802
+ private plannerHolders(): PeerId[] {
803
+ return Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => Array.isArray(roles) && roles.includes("planner") && this.d.bus.isRostered(peer)).map(([peer]) => peer).sort();
804
+ }
805
+
747
806
  private weights(cls: TaskClass, task?: Task) {
748
807
  const now = Date.now();
749
808
  const quota = this.d.quota?.();
750
- return { ...this.health(), ...(task ? { reviewPool: this.reviewPoolOptions(task) } : {}), now, demoted: this.demoted(cls, now), reviews: this.reviewRecord(cls), ...(quota ? { quota } : {}), ...(this.d.review?.adaptive ? { adaptive: { min: this.d.review.min_reviews } } : {}) };
809
+ return { ...this.health(), planners: this.plannerHolders(), ...(task ? { reviewPool: this.reviewPoolOptions(task) } : {}), now, demoted: this.demoted(cls, now), reviews: this.reviewRecord(cls), ...(quota ? { quota } : {}), ...(this.d.review?.adaptive ? { adaptive: { min: this.d.review.min_reviews } } : {}) };
751
810
  }
752
811
 
753
812
  /**
@@ -892,11 +951,11 @@ export class Tasks {
892
951
  }
893
952
  return parts.length ? `no peer can take the planner pull: ${parts.join("; ")}` : "no peer has eligible planner WIP capacity";
894
953
  }
895
- private plannerAssignment(task: Task, reservedOnly = false): Assignment {
954
+ private plannerAssignment(task: Task, reservedOnly = false, routing: Routing = this.d.routing()): Assignment {
896
955
  const states = this.states();
897
956
  const full = this.plannerFull(task);
898
957
  // Preview the To Do transition without routing or offering the persisted backlog row.
899
- return assign({ ...task, state: "proposed" }, states, this.d.routing(), { exclude: this.excluded(task), excludeOwners: full, ...(reservedOnly && task.reserved && !this.excluded(task).includes(task.reserved) ? { candidates: [task.reserved], namedOwner: task.reserved } : {}), waitsFor: this.waitsFor(task), ...this.weights(task.class, task) });
958
+ return assign({ ...task, state: "proposed" }, states, routing, { exclude: this.excluded(task), excludeOwners: full, ...(reservedOnly && task.reserved ? { candidates: [task.reserved], namedOwner: task.reserved } : {}), waitsFor: this.waitsFor(task), ...this.weights(task.class, task) });
900
959
  }
901
960
 
902
961
  async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
@@ -921,14 +980,25 @@ export class Tasks {
921
980
  return !!competing && (competing.rank < order.rank || competing.rank === order.rank && competing.id < order.id);
922
981
  })) throw new PreEffectToolRefusal("a person's P0 backlog order is retained");
923
982
  }
924
- const assignment = this.plannerAssignment(task);
925
- if (!assignment.owner) throw new PreEffectToolRefusal(this.plannerRefusal(task));
926
- if (this.hasPersonReservation(task) && assignment.owner !== task.reserved) throw new PreEffectToolRefusal("a person's reservation is retained");
983
+ if (task.reserved && this.excluded(task).includes(task.reserved)) throw new PreEffectToolRefusal(this.plannerRefusal(task));
984
+ const assignment = this.plannerAssignment(task, !!task.reserved);
985
+ if (!assignment.owner) {
986
+ const refusal = task.reserved ? `reserved peer ${task.reserved} cannot take the planner pull: ${assignment.refusals?.find(row => row.role === "owner" && row.peer === task.reserved)?.reason ?? "not eligible"}; ${this.plannerRefusal(task)}` : this.plannerRefusal(task);
987
+ throw new PreEffectToolRefusal(`${this.hasPersonReservation(task) ? "a person's reservation is retained; " : ""}${refusal}`);
988
+ }
927
989
  return { task, owner: assignment.owner };
928
990
  };
929
991
  const initial = validate(), withheld = await this.withholds("note", input.reason, initial.task);
930
992
  authorized();
931
993
  const current = validate();
994
+ if (current.task.reserved) {
995
+ return this.assignOwner(current.task, by, { candidates: [current.owner], namedOwner: current.owner, planner: true, refuseNamed: "pre",
996
+ plannerPull: { revision: input.revision, note: input.reason.trim(), withheld, guard: () => {
997
+ authorized();
998
+ const checked = validate();
999
+ if (checked.task.history.length !== current.task.history.length || checked.owner !== current.owner) throw new PreEffectToolRefusal("planner pull changed during admission");
1000
+ } } });
1001
+ }
932
1002
  const pulled = this.d.board.pullPlanned(current.task.id, by, current.owner, input.revision, input.reason.trim(), withheld);
933
1003
  this.planningRecorded({ by, task: pulled.id, fields: ["pull", "reserved"], revision: pulled.planningRevision! });
934
1004
  return pulled;
@@ -1370,6 +1440,7 @@ export class Tasks {
1370
1440
  }
1371
1441
 
1372
1442
  async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; role?: unknown; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId; after?: unknown; urgent?: unknown; backlog?: unknown; goal?: unknown }, external?: GitHubSource, created?: () => void): Promise<Task> {
1443
+ const requestCheck = this.requestCheck(by);
1373
1444
  external = external ? { ...external } : undefined;
1374
1445
  if (external && (by !== USER || input.backlog !== true)) throw new PreEffectToolRefusal("external intake requires person-owned backlog staging");
1375
1446
  if (input.backlog !== undefined && typeof input.backlog !== "boolean") throw new PreEffectToolRefusal("backlog must be a boolean");
@@ -1414,6 +1485,7 @@ export class Tasks {
1414
1485
  // board, in no envelope and before no other model. A pattern match is PII without a call; no verdict is PII too.
1415
1486
  const screening = this.screenOn();
1416
1487
  const screened = screening && !signals.includes("pii") ? await this.screenCall(screenText({ ...text, detail: screenDetail, plan })) : undefined;
1488
+ requestCheck();
1417
1489
  if (screened && screened.label !== "clear") signals.push("pii");
1418
1490
  let cls = baseGiven;
1419
1491
  let triaged = false;
@@ -1421,8 +1493,11 @@ export class Tasks {
1421
1493
  if (!cls && this.d.triage) {
1422
1494
  // Signals first: a PII task's text may only go to a model that is reached without leaving the campus network.
1423
1495
  const pii = this.isPii({ signals });
1424
- if (!pii || (await this.d.triage.onCampus().catch(() => false))) cls = await this.d.triage.classify(text.title, text.detail).catch(() => undefined);
1496
+ const reached = !pii || await this.d.triage.onCampus().catch(() => false);
1497
+ requestCheck();
1498
+ if (reached) cls = await this.d.triage.classify(text.title, text.detail).catch(() => undefined);
1425
1499
  else fenced = true;
1500
+ requestCheck();
1426
1501
  triaged = !!cls;
1427
1502
  }
1428
1503
  // A claim is work the caller will do itself: without a class and a model to name one, it is implementation (#6).
@@ -1438,11 +1513,13 @@ export class Tasks {
1438
1513
  if (reserved) this.requireCapabilities({ ...draft, signals, ...(external ? { external } : {}) }, reserved);
1439
1514
  this.warmRepository({ refs: draft.refs, plan });
1440
1515
  const repositoryMismatch = !this.isPii({ signals }) && this.repositories.mismatch(draft.refs, plan);
1516
+ requestCheck();
1441
1517
  let task = this.d.board.propose(by, { ...draft, plan, ...(external ? { external } : {}), ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals, ...(backlog ? { backlog: true } : {}) });
1442
1518
  if (repositoryMismatch) this.releaseNotice(`task #${task.id}: refs and plan name different repositories; check refs.repo before editing`);
1443
1519
  if (this.d.coordination) this.coordinationModes.set(task.id, this.d.coordination());
1444
1520
  created?.();
1445
1521
  if (screening) task = this.screenedTask(task, screened);
1522
+ task = this.linkOnWrite(task); // #723
1446
1523
  if (goal) this.linkNative(task, goal, by);
1447
1524
  if (triaged) task = this.d.board.update(task.id, "hub", "triaged", {}, `class ${cls} named by the hub's model`);
1448
1525
  if (defaulted) task = this.d.board.update(task.id, "hub", "class defaulted", {}, "class implement for a claim without one");
@@ -1855,7 +1932,9 @@ export class Tasks {
1855
1932
  const task = this.d.board.get(target);
1856
1933
  if (!task) throw new Error(`no task #${target}`);
1857
1934
  if (isClosedTaskState(task.state)) return [...workflow, `task ${this.publicTitle(task)} (${task.state}): closed; not routed`];
1858
- const a = assign(task, this.states(), routing, { exclude: this.excluded(task), excludeOwners: this.plannerPullRecord(task)?.pull ? this.plannerFull(task) : [], waitsFor: this.waitsFor(task), ...this.weights(task.class, task) });
1935
+ const planned = task.state === "backlog" && !!task.reserved || this.plannerPullRecord(task)?.pull === true;
1936
+ const a = planned ? this.plannerAssignment(task, true, routing)
1937
+ : assign(task, this.states(), routing, { exclude: this.excluded(task), waitsFor: this.waitsFor(task), ...this.weights(task.class, task) });
1859
1938
  // The split trace is for the pair the record is about: the task's owner when it has one (issue #109).
1860
1939
  return [...workflow, `task ${this.publicTitle(task)} (${task.state}, owner ${task.owner ?? "none"}${task.reserved ? `, reserved for ${task.reserved}` : ""})`, ...(task.profileClass ? [`profile class ${profileTaskLabel(task)}`] : []), ...(task.role ? [`task role ${task.role} (guidance only)`] : []), "if it were assigned now:", ...a.trace, ...(this.splitShadow(task, task.owner ?? a.owner)?.trace ?? [])];
1861
1940
  }
@@ -1943,14 +2022,17 @@ export class Tasks {
1943
2022
  }
1944
2023
 
1945
2024
  /** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
1946
- private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; namedOwner?: PeerId; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean; roster?: boolean; planner?: boolean; expectedFlowRevision?: number; refuseNamed?: "pre" | "plain" } = {}): Promise<Task> {
1947
- if (task.state === "backlog") throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
2025
+ private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; namedOwner?: PeerId; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean; roster?: boolean; planner?: boolean; expectedFlowRevision?: number; refuseNamed?: "pre" | "plain"; plannerPull?: { revision: number; note: string; withheld: boolean; guard: () => void } } = {}): Promise<Task> {
2026
+ if (task.state === "backlog" && !opts.plannerPull) throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
1948
2027
  const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
1949
2028
  let stagedClaimGap: string | undefined;
1950
2029
  if (mode !== "off" && this.isBuildTask(task)) {
1951
2030
  const gap = task.sdlc?.planReview ? await this.planReviewGap(task) : "plan not reviewed since proposal";
1952
2031
  const current = this.d.board.get(task.id);
1953
- if (!current || !this.ownerAssignmentCurrent(task, current)) return current ?? task;
2032
+ if (!current || !this.ownerAssignmentCurrent(task, current)) {
2033
+ if (opts.plannerPull) throw new PreEffectToolRefusal("planner pull changed during admission");
2034
+ return current ?? task;
2035
+ }
1954
2036
  task = current; // Reviewer-only and quiet bookkeeping updates do not supersede ownership authority.
1955
2037
  if (gap) {
1956
2038
  this.releaseNotice(`task ${this.publicTitle(current)}: ${gap}; planner: call hub_planner_groom then hub_plan_review`);
@@ -1967,9 +2049,10 @@ export class Tasks {
1967
2049
  const waits = this.waitsFor(task);
1968
2050
  const plannedMove = opts.planner || this.plannerPullRecord(task)?.pull === true && (by === HUB || opts.roster) && (opts.reason !== "manual" || opts.roster);
1969
2051
  const plannerFull = plannedMove ? this.plannerFull(task) : [];
1970
- const a = assign(task, this.states(), this.d.routing(), { exclude: [...(opts.override ? [] : this.excluded(task)), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], excludeOwners: plannerFull, ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class, task), ...(opts.namedOwner ? { namedOwner: opts.namedOwner } : {}) });
2052
+ const a = assign(opts.plannerPull ? { ...task, state: "proposed" } : task, this.states(), this.d.routing(), { exclude: [...(opts.override ? [] : this.excluded(task)), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], excludeOwners: plannerFull, ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class, task), ...(opts.namedOwner ? { namedOwner: opts.namedOwner } : {}) });
1971
2053
 
1972
2054
  if (waits.length) {
2055
+ if (opts.plannerPull) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
1973
2056
  this.releaseNotice(`task ${this.publicTitle(task)} waits for ${waits.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
1974
2057
  return task;
1975
2058
  }
@@ -1995,7 +2078,13 @@ export class Tasks {
1995
2078
  // A person's explicit assign replaces an agent's reservation (#207). Only a refusal keeps the owner it moves away from
1996
2079
  // out for good: a reviewer's rejections or an escalation by hand, never the hub's own move after a failed delivery.
1997
2080
  const lasting = opts.event === "escalated" && task.owner && (opts.reason === "rejections" || opts.reason === "manual");
1998
- const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, this.assignmentNote(`${opts.note ?? `to ${a.owner}`}${passed}`, a), { ...this.reviewMetadata(a), ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) }, opts.expectedFlowRevision);
2081
+ opts.plannerPull?.guard();
2082
+ const assignmentNote = this.assignmentNote(`${opts.note ?? `to ${a.owner}`}${passed}`, a);
2083
+ const assignmentExtra = { ...this.reviewMetadata(a), ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) };
2084
+ const next = opts.plannerPull
2085
+ ? this.d.board.pullAssigned(task.id, by, a.owner, opts.plannerPull.revision, opts.plannerPull.note, opts.plannerPull.withheld, a.reviewer ?? null, assignmentNote, assignmentExtra, task.history.length)
2086
+ : this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, assignmentNote, assignmentExtra, opts.expectedFlowRevision);
2087
+ if (opts.plannerPull) this.planningRecorded({ by, task: next.id, fields: ["pull", "reserved"], revision: next.planningRevision! });
1999
2088
  // What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
2000
2089
  // escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
2001
2090
  // yet. For the record only. Work routed back to its proposer is left out, as its observations are (by === owner).
@@ -2009,6 +2098,11 @@ export class Tasks {
2009
2098
  // The cohort record formed just below reads it, and so does `route explain` while that turn lasts.
2010
2099
  if (opts.claim && a.owner === by && this.d.bus.peers.get(by)?.state === "busy") this.sent.set(handOver(next, by), by);
2011
2100
  try {
2101
+ if (opts.plannerPull && task.offer) {
2102
+ const prior = { ...task, owner: task.offer.owner };
2103
+ this.withdrawRoleRequest(prior, next, "owner");
2104
+ this.releaseNotice(`task #${next.id}: stale offer withdrawal failed`, () => this.d.bus.withdraw(task.offer!.envelopeId));
2105
+ }
2012
2106
  if (by === USER && opts.event === "reassigned") this.withdrawRoleRequest(task, next, "owner");
2013
2107
  const hits = this.overlapHits(next);
2014
2108
  this.formCohort(next, hits);
@@ -2379,6 +2473,25 @@ export class Tasks {
2379
2473
  if (by !== USER && task[role] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: only its ${role} (${task[role] ?? "none"}) or the console user can do that`);
2380
2474
  }
2381
2475
 
2476
+ /**
2477
+ * #723: link the issue a task names the moment it is proposed or accepted, in advise and enforce, so the lifecycle never
2478
+ * waits for a groom. It is a local parse with no GitHub read, and it never refuses or fails the write it follows.
2479
+ */
2480
+ private linkOnWrite(task: Task): Task {
2481
+ try { return (this.d.sdlc?.config().mode ?? "off") === "off" ? task : this.linkIssue(task); }
2482
+ catch { return task; }
2483
+ }
2484
+
2485
+ /**
2486
+ * #723: a title that qualifies another repository (`agent-mem#13`, `owner/repo#N`) while `refs.repo` is absent cannot link
2487
+ * (the identity needs the repository), so the proposer is told to set it. Advice only; a PII task gets none.
2488
+ */
2489
+ refsRepoHint(task: Task): string | undefined {
2490
+ if (this.isPii(task) || task.refs.repo || task.sdlc?.issue || (this.d.sdlc?.config().mode ?? "off") === "off") return undefined;
2491
+ const names = qualifiedTitleRepos(task.title);
2492
+ return names.length ? `task #${task.id}: the title names ${names.join(", ")}; set refs.repo to that repository (owner/name) so its issue links and the lifecycle shows it` : undefined;
2493
+ }
2494
+
2382
2495
  /** #694: write the one issue the task itself names (the plan-review source) into its lifecycle receipt, once; never relinks. */
2383
2496
  private linkIssue(task: Task): Task {
2384
2497
  if (this.isPii(task) || task.sdlc?.issue) return task;
@@ -2428,12 +2541,16 @@ export class Tasks {
2428
2541
  let issues: number[];
2429
2542
  try { issues = specRefs(task, repository); }
2430
2543
  catch { throw new PreEffectToolRefusal("plan review issue identity unavailable"); }
2544
+ // #723: a title that qualifies the sibling repository `refs.repo` names (`agent-mem#13`) is a local parse, never a read.
2545
+ // A sibling shorthand next to a bare #N is two identities, refused below, never the bare one alone.
2546
+ issues = [...new Set([...issues, ...siblingTitleRefs(task.title, task.refs.repo)])];
2431
2547
  if (issues.length > 1) throw new PreEffectToolRefusal("plan review needs one explicit issue identity");
2432
2548
  return issues[0] ? { repo: repository, issue: issues[0] } : undefined;
2433
2549
  }
2434
2550
 
2435
2551
  async groomingDigest(by: PeerId): Promise<Record<string, unknown>> {
2436
2552
  this.planningAuthority(by);
2553
+ this.backfillIssueLinks(by); // #723: first, bounded, before the slow reads
2437
2554
  const included = (task: Task) => !isClosedTaskState(task.state) || task.state === "cancelled" && task.history.some(entry => !!entry.supersededBy);
2438
2555
  const snapshot = this.d.board.list().filter(included), issues = new Map<string, PlanIssueMetadata>(), attempted = new Set<string>();
2439
2556
  const deadline = performance.now() + 4_000;
@@ -2473,17 +2590,114 @@ export class Tasks {
2473
2590
  const digest = plannerGrooming(visible, { repository: this.d.repository ?? `path:${realPath(this.d.cwd)}`,
2474
2591
  limits: Object.fromEntries(peers.map(peer => [peer, this.wipLimit(peer)])), wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])),
2475
2592
  states, issues, reviewedIssues, acceptedIssues, blockers });
2476
- // #694: backfill lifecycle issue links after every authority check, with their own bound (not the metadata-read
2477
- // budget): each call links up to 32 open tasks that still lack one, so later tasks are reached on later calls.
2478
- if ((this.d.sdlc?.config().mode ?? "off") !== "off") {
2479
- let linked = 0;
2480
- for (const task of this.d.board.list()) {
2481
- if (linked >= 32) break;
2482
- if (isClosedTaskState(task.state) || task.sdlc?.issue || this.isPii(task)) continue;
2483
- if (this.linkIssue(task) !== task) linked++;
2593
+ return { ...digest, truncated: digest.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}) };
2594
+ }
2595
+
2596
+ /**
2597
+ * #694, #723: backfill lifecycle issue links with their own bounds, run first and apart from the slow metadata reads and
2598
+ * the digest, so a groom that later times out has still linked. Each call links up to 32 open tasks that still lack one
2599
+ * within a one-second budget, so later tasks are reached on later calls. A local parse; a failure skips that task only.
2600
+ */
2601
+ private backfillCursor = 0;
2602
+ private backfillIssueLinks(by: PeerId): number {
2603
+ if ((this.d.sdlc?.config().mode ?? "off") === "off") return 0;
2604
+ // ponytail: 32 links and 1 s per call; raise both only with a measured groom time on a 400-task board.
2605
+ const deadline = performance.now() + 1_000;
2606
+ let linked = 0, i = 0;
2607
+ const list = this.d.board.list(), n = list.length;
2608
+ // The scan resumes where the last call stopped, so rows that never link cannot hold later ones back.
2609
+ for (; i < n; i++) {
2610
+ if (linked >= 32 || performance.now() >= deadline) break;
2611
+ const task = list[(this.backfillCursor + i) % n]!;
2612
+ if (isClosedTaskState(task.state) || task.sdlc?.issue || this.isPii(task)) continue;
2613
+ this.planningAuthority(by);
2614
+ try { if (this.linkIssue(task) !== task) linked++; } catch { /* one task never stops the rest */ }
2615
+ }
2616
+ this.backfillCursor = n ? (this.backfillCursor + i) % n : 0;
2617
+ return linked;
2618
+ }
2619
+
2620
+ /**
2621
+ * #481 S7: the SDLC metrics from board history, read-only. Private tasks are counted as withheld and read for nothing else; PR
2622
+ * readbacks are not made here (the cost report owns them), so merge time and same-head reruns read `unknown` unless given.
2623
+ * Detection never calls a model and the report writes nothing.
2624
+ */
2625
+ sdlcMetrics(by: PeerId, since?: unknown, merged?: { mergedAt?: Record<number, number>; ciReruns?: Record<number, number | null> }): MetricsReport {
2626
+ this.planningAuthority(by);
2627
+ if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("metrics unavailable during private work");
2628
+ let cutoff: number | undefined;
2629
+ if (since !== undefined && since !== null && since !== "") {
2630
+ cutoff = typeof since === "number" ? since : /^\d+$/.test(String(since)) ? Number(since) : Date.parse(String(since));
2631
+ if (!Number.isFinite(cutoff) || cutoff < 0) throw new PreEffectToolRefusal("since must be a time (YYYY-MM-DD or an ISO timestamp) or milliseconds");
2632
+ }
2633
+ const rows = this.d.board.list();
2634
+ const tasks: MetricTask[] = rows.map(task => ({ id: task.id, private: this.isPii(task), history: task.history.map(entry => ({ at: entry.at, event: entry.event,
2635
+ ...(entry.reviewCost?.head ? { requestHead: entry.reviewCost.head } : {}), ...(entry.reviewFindings?.head ? { verdictHead: entry.reviewFindings.head } : {}) })) }));
2636
+ const visibleIds = new Set(rows.filter(task => !this.isPii(task)).map(task => task.id));
2637
+ const byKind: Record<string, number> = {};
2638
+ for (const stall of this.stalled()) if (visibleIds.has(stall.task)) byKind[stall.cause.kind] = (byKind[stall.cause.kind] ?? 0) + 1;
2639
+ const stalls = { open: Object.values(byKind).reduce((sum, n) => sum + n, 0), byKind, doneWithoutWrites: rows.filter(task => visibleIds.has(task.id) && task.history.findLast(entry => entry.event === "done")?.noWrites).length };
2640
+ return sdlcMetrics(tasks, { ...(cutoff !== undefined ? { since: cutoff } : {}), ...(this.d.sdlc?.config().bands ? { bands: this.d.sdlc.config().bands } : {}), stalls, ...(merged ?? {}) });
2641
+ }
2642
+
2643
+ /**
2644
+ * #481 S7: act on the band tiers, deterministically and once per breach episode. An episode is a run of non-ok values for one metric
2645
+ * (`BandResult.episode`, derived from history), so a sustained shift is one episode however many tasks finish inside it. `log` is one
2646
+ * hub notice, `diagnose` one read-only `triage` backlog task and `propose` one `plan` backlog card naming the metric and its evidence.
2647
+ * A metric with an open band card (a backlog or open task whose detail carries its key) writes nothing more, so a value that grows
2648
+ * from diagnose to propose inside one episode does not add a second card. Held while the hub is stopping, recovering or has a
2649
+ * storage error, like every host writer. The text is fixed words, numbers and task ids, never task text, and no GitHub issue is
2650
+ * created. Returns the task ids created.
2651
+ */
2652
+ /**
2653
+ * Whether `proposeFromBands` would run now, and a signature of what it would read. The daemon timer records a board revision only for a
2654
+ * pass that ran, and re-runs when the signature changes (a reload that enables bands or edits them on an idle board).
2655
+ */
2656
+ bandsState(): { ready: boolean; signature: string } {
2657
+ const config = this.d.sdlc?.config();
2658
+ const configured = !!config && config.mode !== "off" && !!config.bands && Object.keys(config.bands).length > 0;
2659
+ return { ready: configured && !this.d.sweepHeld?.() && !this.d.bus.isRecoveryHeld && !this.d.bus.storageError, signature: configured ? JSON.stringify([config!.mode, config!.bands]) : "" };
2660
+ }
2661
+
2662
+ proposeFromBands(by: PeerId = HUB): number[] {
2663
+ const config = this.d.sdlc?.config();
2664
+ if (!config || config.mode === "off" || !config.bands || !Object.keys(config.bands).length) return [];
2665
+ if (this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) return [];
2666
+ if (by !== HUB && by !== USER) this.planningAuthority(by);
2667
+ const report = this.sdlcMetrics(by === HUB ? USER : by);
2668
+ if (report.bands === "off") return [];
2669
+ const rows = this.d.board.list();
2670
+ const bandRows = rows.filter(row => row.history[0]?.by === HUB && row.detail?.startsWith("Band breach key: "));
2671
+ const keyOf = (row: Task) => row.detail!.split("\n")[0]!.slice("Band breach key: ".length);
2672
+ const seen = new Set(bandRows.map(keyOf));
2673
+ // A card counts as open while its task is not closed: the planner has not yet decided on it.
2674
+ const open = new Set(bandRows.filter(row => !isClosedTaskState(row.state)).map(row => keyOf(row).split(":")[0]!));
2675
+ const created: number[] = [];
2676
+ for (const band of report.bands as BandResult[]) {
2677
+ if (band.tier === "ok" || band.lastTask === null || band.episode === null) continue;
2678
+ const key = `${band.metric}:#${band.episode}`;
2679
+ if (band.tier === "log") {
2680
+ const marker = `${key}:log`;
2681
+ if (!open.has(band.metric) && this.d.board.recordBandNotice(marker)) this.releaseNotice(`sdlc band: ${band.metric} is ${band.reason}; last value ${band.last} on task #${band.lastTask}`);
2682
+ continue;
2484
2683
  }
2684
+ if (seen.has(`${key}:propose`) || seen.has(`${key}:diagnose`) || open.has(band.metric)) continue;
2685
+ const tier = band.tier === "propose" ? "propose" : "diagnose";
2686
+ const full = `${key}:${tier}`;
2687
+ const evidence = `metric ${band.metric}; newest value ${band.last} on task #${band.lastTask}; episode began at task #${band.episode}; baseline mean ${band.mean}, deviation ${band.sd} over ${band.baseline} tasks; ${band.reason}`;
2688
+ const diagnose = tier === "diagnose";
2689
+ const title = diagnose ? `Diagnose SDLC metric ${band.metric}: read-only triage` : `Review SDLC metric ${band.metric}: control band breach`;
2690
+ const detail = `Band breach key: ${full}\n${evidence}\n${diagnose
2691
+ ? "Read-only triage for the planner: inspect the recent task histories behind this metric and report what changed. Do not edit files, branches or the board."
2692
+ : "Backlog card for the planner: decide whether this breach needs a process change. It names the metric and its evidence; it opens no GitHub issue."}`;
2693
+ const draft = { title, detail, refs: {} };
2694
+ const signals = this.signalsFor(draft);
2695
+ if (this.isPii({ signals }) || !this.candidateTextAllowed(`${title}\n${detail}`)) continue;
2696
+ const proposal = this.d.board.propose(HUB, { ...draft, class: diagnose ? "triage" : "plan", backlog: true, signals });
2697
+ this.incomingBacklog.add(proposal.id); seen.add(full); open.add(band.metric); created.push(proposal.id);
2485
2698
  }
2486
- return { ...digest, truncated: digest.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}) };
2699
+ for (const id of created) this.releaseNotice(`task #${id}: SDLC control band breach proposed in the backlog`);
2700
+ return created;
2487
2701
  }
2488
2702
 
2489
2703
  /** S10 read-only planner report; S7 owns rolling bands and automatic board proposals. */
@@ -2591,7 +2805,23 @@ export class Tasks {
2591
2805
  }
2592
2806
 
2593
2807
  /** Artifact links and claimed local gates stay on the board; no outside text is accepted. */
2594
- recordLifecycle(by: PeerId, id: unknown, input: unknown): Task {
2808
+ /**
2809
+ * `hub_sdlc_record` (#509a): the receipt may also name `deliverables`. They are split off here, claims vetted and the files hashed by the hub,
2810
+ * and only then is the rest recorded as before. A receipt with deliverables and nothing else records just the deliverables.
2811
+ */
2812
+ async recordSdlc(by: PeerId, id: unknown, input: unknown): Promise<Task> {
2813
+ if (!input || typeof input !== "object" || Array.isArray(input) || !("deliverables" in input)) return this.recordLifecycle(by, id, input);
2814
+ const { deliverables, ...receipt } = input as Record<string, unknown>;
2815
+ const task = this.need(id, false, true);
2816
+ if (by !== USER && by !== task.owner && by !== task.reviewer) throw new PreEffectToolRefusal("only the task owner, reviewer or console user may record lifecycle evidence");
2817
+ const claims = this.deliverableClaims(deliverables, task);
2818
+ const records = await this.hashClaims(claims, task, true);
2819
+ const current = this.need(id, false, true);
2820
+ if (current.history.length !== task.history.length) throw new PreEffectToolRefusal("task changed while its deliverables were hashed; record them again");
2821
+ return this.recordLifecycle(by, id, receipt, records);
2822
+ }
2823
+
2824
+ recordLifecycle(by: PeerId, id: unknown, input: unknown, deliverables?: DeliverableRecord[]): Task {
2595
2825
  const task = this.need(id, false, true);
2596
2826
  if (by !== USER && by !== task.owner && by !== task.reviewer) throw new PreEffectToolRefusal("only the task owner, reviewer or console user may record lifecycle evidence");
2597
2827
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
@@ -2621,7 +2851,7 @@ export class Tasks {
2621
2851
  if (next.reviewedHead !== undefined && (next.reviewedHead !== task.sdlc?.reviewedHead || (suppliedHead && artifactChanged))) this.mine(task, by, "reviewer", true);
2622
2852
  const departure = task.history.findLast(h => h.by === USER && h.planDeparture)?.planDeparture;
2623
2853
  const invalidatesDeparture = !!departure && (artifactChanged || next.localHead !== task.sdlc?.localHead && next.localHead !== undefined && next.localHead !== departure.head || next.reviewedHead !== task.sdlc?.reviewedHead && next.reviewedHead !== undefined && next.reviewedHead !== departure.head);
2624
- return this.d.board.update(task.id, by, "lifecycle", { sdlc: next }, undefined, { ...this.planObservation({ ...task, sdlc: next }), ...(invalidatesDeparture ? { planDepartureInvalidated: true } : {}) });
2854
+ return this.d.board.update(task.id, by, "lifecycle", { sdlc: next }, undefined, { ...this.planObservation({ ...task, sdlc: next }), ...(invalidatesDeparture ? { planDepartureInvalidated: true } : {}), ...(deliverables ? { deliverables } : {}) });
2625
2855
  }
2626
2856
 
2627
2857
  async deliveryQueue(repo: string) {
@@ -2831,7 +3061,9 @@ export class Tasks {
2831
3061
  const task = this.need(id, false, true), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
2832
3062
  if (config.mode === "off" || by === USER || !input || typeof input !== "object" || !("stage" in input)) return;
2833
3063
  let next: ReturnType<typeof sdlcReceipt>;
2834
- try { next = sdlcReceipt(input, task.sdlc, config, VERSION); } catch { return; } // recordLifecycle reports an invalid receipt
3064
+ // #509a: `deliverables` ride in the receipt but are not a lifecycle field: recordSdlc splits them off, so the gate reads the same receipt it will record.
3065
+ const { deliverables: _carried, ...lifecycleInput } = input as Record<string, unknown>;
3066
+ try { next = sdlcReceipt(lifecycleInput, task.sdlc, config, VERSION); } catch { return; } // recordLifecycle reports an invalid receipt
2835
3067
  const definition = stageDefinition(config, next.stage);
2836
3068
  if (next.stage === task.sdlc?.stage || !definition || canonicalStage(definition.base) !== "build") return;
2837
3069
  const accepted = next.acceptance;
@@ -2909,6 +3141,7 @@ export class Tasks {
2909
3141
  return base === "build" || base === "implement";
2910
3142
  }
2911
3143
  async acceptReviewed(by: PeerId, id: unknown, plan?: unknown, refs?: unknown): Promise<Task> {
3144
+ const requestCheck = this.requestCheck(by);
2912
3145
  const metadata = acceptRefs(refs);
2913
3146
  const task = this.need(id, false, true);
2914
3147
  this.mine(task, by, "owner", true);
@@ -2919,6 +3152,7 @@ export class Tasks {
2919
3152
  if ((this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode === "off" || !this.isBuildTask(task)) return this.accept(by, id, plan, metadata);
2920
3153
  await this.specGate(by, id);
2921
3154
  await this.planEntryGate(by, draft);
3155
+ requestCheck();
2922
3156
  const current = this.need(id, false, true);
2923
3157
  this.mine(current, by, "owner", true);
2924
3158
  if (current.history.length !== task.history.length || current.owner !== task.owner) throw new PreEffectToolRefusal("task changed during build admission");
@@ -2997,7 +3231,7 @@ export class Tasks {
2997
3231
  this.warmRepository(draft);
2998
3232
  const repositoryChanged = this.repositories.scope(task.refs).key !== this.repositories.scope(draft.refs).key;
2999
3233
  const before = new Set(this.overlapHits(task).map((h) => h.task.id));
3000
- const next = this.d.board.update(task.id, by, "accepted", { state: "in_progress", ...(cleaned ? { plan: cleaned } : {}), ...(metadata ? { refs: metadata } : {}) });
3234
+ 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
3001
3235
  this.captureOwnerAcceptance(next);
3002
3236
  this.d.notify(`task ${this.publicTitle(next)} accepted by ${by}`);
3003
3237
  if (repositoryChanged) this.cohorts.leave(next.id);
@@ -3075,6 +3309,59 @@ export class Tasks {
3075
3309
  return this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true, ...(capacity === true && task.owner ? { exclude: [task.owner] } : {}), ...(planned ? { planner: true } : {}) });
3076
3310
  }
3077
3311
 
3312
+ /**
3313
+ * #509a: the task's deliverables as they are now against what was delivered and what was approved. `changedSinceReview` is true only for a file
3314
+ * measured to differ from the bytes a reviewer approved (or, before approval, from the bytes delivered); a file the hub cannot read is
3315
+ * `unknown`, never `unchanged`. Ids, hashes and sizes only for a private record. Undefined when the task delivered nothing.
3316
+ */
3317
+ async deliverableStatus(task: Task): Promise<{ state: "none" | "delivered" | "approved"; changedSinceReview: boolean; files: DeliverableStatus[] } | undefined> {
3318
+ const delivered = latestDeliverables(task.history);
3319
+ if (!delivered) return undefined;
3320
+ const approved = task.state === "approved" ? approvedDeliverables(task.history) : undefined;
3321
+ const basis = approved ?? delivered;
3322
+ const checks = await this.checkRecords(basis, task);
3323
+ const shown = publicDeliverables(basis, this.nameable)!;
3324
+ const files = checks.map((check, i): DeliverableStatus => {
3325
+ const { sha256, id, format, version, path, private: hidden } = shown[i]!;
3326
+ return { id, sha256, state: check.state, ...(path ? { path } : {}), format, ...(version ? { version } : {}), ...(hidden ? { private: true as const } : {}) };
3327
+ });
3328
+ return { state: approved ? "approved" : "delivered", changedSinceReview: files.some(file => file.state === "changed"), files };
3329
+ }
3330
+
3331
+ /** Check records against the owner's root; with that root unproven every file is `unknown` (never `unchanged`, never an exception). */
3332
+ private async checkRecords(records: DeliverableRecord[], task: Task) {
3333
+ try { return await checkDeliverables(records, this.deliverablePorts(task)); }
3334
+ catch (error) { return records.map(record => ({ record, state: "unknown" as const, why: error instanceof Error ? error.message : "unreadable" })); }
3335
+ }
3336
+
3337
+ /** What the deliverable reader needs from the host: the project root, the secrets denylist, and which files make a record private (#509a). */
3338
+ private deliverablePorts(task: Task): DeliverablePorts {
3339
+ const taskPrivate = this.isPii(task);
3340
+ // The owner's own root: a declared worktree instance writes under its worktree, so its deliverables are hashed there, never in the canonical
3341
+ // checkout (which holds a different file or none). With the root unproven there is nothing safe to hash: refuse rather than guess.
3342
+ const root = !this.d.executionRoot || !task.owner || task.owner === USER ? this.d.cwd : this.d.executionRoot(task.owner);
3343
+ if (root === undefined) throw new DeliverableRefusal(`deliverables: ${task.owner}'s execution root is not verified right now, so its files cannot be hashed`);
3344
+ return { cwd: root, deny: this.d.deny ?? [], private: path => taskPrivate || matchesPolicy(root, path, this.d.piiPaths ?? [], true) };
3345
+ }
3346
+
3347
+ /**
3348
+ * #509a: parse and vet a call's deliverable claims before any effect. A claimed hash is a refusal (the hub hashes the files itself), and so is a
3349
+ * path outside the project, on the denylist, inside the hub's own state, not a regular file, missing or too large. Nothing is hashed or stored here.
3350
+ */
3351
+ private deliverableClaims(input: unknown, task: Task, preEffect = true) {
3352
+ try {
3353
+ const claims = parseDeliverableClaims(input);
3354
+ vetDeliverables(claims, this.deliverablePorts(task));
3355
+ return claims;
3356
+ } catch (error) { throw new (preEffect ? PreEffectToolRefusal : Error)(error instanceof Error ? error.message : "invalid deliverables"); }
3357
+ }
3358
+
3359
+ /** Hash vetted claims now. A refusal during the read (the file changed while it was read) is still before any write. */
3360
+ private async hashClaims(claims: ReturnType<Tasks["deliverableClaims"]>, task: Task, preEffect = true): Promise<DeliverableRecord[]> {
3361
+ try { return await hashDeliverables(claims, this.deliverablePorts(task)); }
3362
+ catch (error) { throw new (preEffect ? PreEffectToolRefusal : Error)(error instanceof DeliverableRefusal || error instanceof Error ? error.message : "deliverables could not be hashed"); }
3363
+ }
3364
+
3078
3365
  /** Tasks whose check is queued or running, and the owner it was started for. */
3079
3366
  private readonly checking = new Map<number, PeerId | null>();
3080
3367
  private checkQueue: Promise<void> = Promise.resolve();
@@ -3102,11 +3389,17 @@ export class Tasks {
3102
3389
  return task;
3103
3390
  }
3104
3391
 
3105
- async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs): Promise<Task> {
3106
- const valid = () => this.completionCurrent(id, by, true);
3392
+ async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs, deliverables?: unknown): Promise<Task> {
3393
+ const requestCheck = this.requestCheck(by);
3394
+ const valid = () => { requestCheck(); return this.completionCurrent(id, by, true); };
3395
+ // #509a: a bad claim (a claimed hash, an outside or denied path, a missing file) is refused before any effect, with nothing recorded.
3396
+ const claims = deliverables === undefined ? undefined : this.deliverableClaims(deliverables, valid());
3107
3397
  // The PII screen reads the summary of a done that passes these checks (issue #198); they run again after it.
3108
3398
  const withheld = await this.withholds("summary", summary, valid());
3109
3399
  let task = valid();
3400
+ // The hub hashes the files itself, once, here: what the owner delivered is what this read, whatever the file becomes afterwards.
3401
+ const records = claims ? await this.hashClaims(claims, task, true) : undefined;
3402
+ task = valid();
3110
3403
  // #481 S1: done without a separate accept starts build work too: the same live spec check as accept, before any write.
3111
3404
  if (task.state === "proposed" || task.state === "changes_requested") {
3112
3405
  const admission = task;
@@ -3152,12 +3445,12 @@ export class Tasks {
3152
3445
  }
3153
3446
  if (task.state === "proposed" || task.state === "changes_requested") task = this.withLink(this.d.board.update(task.id, by, "accepted", { state: "in_progress" }, WITH_DONE), link, refs); // done without a separate accept
3154
3447
  const command = this.d.runCheck ? this.d.check?.(task.class, task) : undefined;
3155
- if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission, link);
3448
+ if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission, link, records, requestCheck);
3156
3449
  // The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
3157
3450
  this.checking.set(task.id, task.owner);
3158
3451
  const checkedRefs = cleanRefs(refs);
3159
3452
  this.warmRepository({ ...task, refs: { ...task.refs, ...checkedRefs } });
3160
- const pending = this.d.board.update(task.id, by, "done (checking)", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...checkedRefs } }), refs: checkedRefs, ...(task.sdlc ? { sdlc: task.sdlc } : {}) }, summary, { withheld: withheld || undefined }, task.history.length, HOST_CODE_SYMBOLS);
3453
+ const pending = this.d.board.update(task.id, by, "done (checking)", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...checkedRefs } }), refs: checkedRefs, ...(task.sdlc ? { sdlc: task.sdlc } : {}) }, summary, { withheld: withheld || undefined, ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
3161
3454
  this.releaseNotice(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
3162
3455
  const seen = { events: pending.history.length, owner: pending.owner };
3163
3456
  this.pendingChecks++;
@@ -3288,11 +3581,15 @@ export class Tasks {
3288
3581
  }
3289
3582
 
3290
3583
  /** `gated`: the transition `done()` already gated with no await since, so it is not read back twice (#382). */
3291
- private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review", admission?: ReviewReadiness, link?: IdentityLink): Promise<Task> {
3584
+ private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review", admission?: ReviewReadiness, link?: IdentityLink, records?: DeliverableRecord[], requestCheck: () => void = () => {}): Promise<Task> {
3585
+ requestCheck();
3586
+ // A check's completion carries the records its `done (checking)` bound: the same bytes the owner delivered, not a later read.
3587
+ records ??= latestPendingDeliverables(task);
3292
3588
  let selection = this.poolReview(task, refs);
3293
3589
  let reviewer = selection?.reviewer ?? task.reviewer, reviewEvidence: PrEvidence | undefined;
3294
3590
  if (this.d.sdlc && this.d.sdlc.config().mode !== "off" && gated !== (reviewer ? "review" : "approval")) {
3295
3591
  reviewEvidence = await this.lifecycleGate(task, reviewer ? "review" : "approval");
3592
+ requestCheck();
3296
3593
  task = this.withLink(this.completionCurrent(task.id, by, false), link, refs);
3297
3594
  selection = this.poolReview(task, refs);
3298
3595
  reviewer = selection?.reviewer ?? task.reviewer;
@@ -3300,6 +3597,7 @@ export class Tasks {
3300
3597
  // A delayed local check needs a new readback; a completion-selected reviewer also needs optional facts in off mode.
3301
3598
  if (!admission && ((reviewer && (this.d.sdlc?.config().mode ?? "off") !== "off") || this.d.prepareReviewBrief)) {
3302
3599
  admission = await this.reviewRequestAdmission(task, refs, false, by, reviewEvidence, undefined, staged => this.withLink(staged, link, refs), !reviewer);
3600
+ requestCheck();
3303
3601
  task = this.withLink(this.completionCurrent(task.id, by, false), link, refs);
3304
3602
  }
3305
3603
  selection = this.poolReview(task, refs);
@@ -3310,7 +3608,7 @@ export class Tasks {
3310
3608
  const completedRefs = cleanRefs(refs);
3311
3609
  this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
3312
3610
  const noWrites = this.noWritesRecorded({ ...task, refs: { ...task.refs, ...completedRefs } });
3313
- const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...completedRefs } }), state: reviewer ? "in_review" : "approved", ...(selection ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { ...this.planObservation(task), withheld: withheld || undefined, ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection), ...(reviewCost ? { reviewCost } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
3611
+ const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...completedRefs } }), state: reviewer ? "in_review" : "approved", ...(selection ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { ...this.planObservation(task), withheld: withheld || undefined, ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection), ...(reviewCost ? { reviewCost } : {}), ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
3314
3612
  this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
3315
3613
  this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
3316
3614
  this.tellCompleted(next, summary, withheld);
@@ -3500,7 +3798,19 @@ export class Tasks {
3500
3798
  const checklistLines = checklistTemplates.map(template =>
3501
3799
  template.replaceAll("{target}", target).replaceAll("{check}", checkResult)
3502
3800
  );
3503
- const checklist = [
3801
+ // #507b: the task's recorded acceptance criteria (the #481 S8 checklist, ids and all). A PII task shows none: its checklist is private.
3802
+ const recorded = this.isPii(task) ? undefined : checklistOf(task.sdlc);
3803
+ const criteriaBlock = recorded?.criteria.length
3804
+ ? ["Acceptance criteria (name an id at the start of an unmet item, such as \"AC1: what is missing\"):",
3805
+ ...recorded.criteria.map(c => `- ${c.id}${c.required ? "" : " (optional)"}: ${c.result}`)].join("\n") + "\n"
3806
+ : "";
3807
+ // #509a: what was delivered, by id, hash and size. A private record (a PII task, or a path under pii.paths) shows no path.
3808
+ const delivered = publicDeliverables(latestDeliverables(task.history), this.nameable);
3809
+ const deliverablesBlock = delivered?.length
3810
+ ? ["Deliverables (the hub hashed these files when the task was done; approval binds these hashes):",
3811
+ ...delivered.map(d => `- ${d.id}: ${d.path ?? "(path withheld)"} [${d.format}${d.version ? ` ${d.version}` : ""}] sha256 ${d.sha256.slice(0, 16)}, ${d.bytes} bytes`)].join("\n") + "\n"
3812
+ : "";
3813
+ const checklist = deliverablesBlock + criteriaBlock + [
3504
3814
  "Checklist:",
3505
3815
  ...this.planGuardItems(task).map(item => `- Check the plan against this live fail before the remaining checks: ${item.line}`),
3506
3816
  ...checklistLines.map(line => `- ${line}`),
@@ -3679,6 +3989,28 @@ export class Tasks {
3679
3989
  const checked = sdlcReceipt({ reviewedHead: head }, task.sdlc, this.d.sdlc?.config() ?? sdlcConfig(undefined));
3680
3990
  reviewedHead = checked.reviewedHead;
3681
3991
  }
3992
+ // #509a: an approval binds the bytes that were delivered. The files are read again now: unchanged, their hashes go on the approval; changed,
3993
+ // missing or unreadable, a reviewer's approval is refused before any write, and the person may approve the files as they are, saying so.
3994
+ let boundDeliverables: DeliverableRecord[] | undefined;
3995
+ if (verdict === "approved") {
3996
+ const delivered = latestDeliverables(task.history);
3997
+ if (delivered) {
3998
+ const identity = JSON.stringify(delivered);
3999
+ const checks = await this.checkRecords(delivered, task);
4000
+ task = valid();
4001
+ if (JSON.stringify(latestDeliverables(task.history)) !== identity) throw new PreEffectToolRefusal("the task's deliverables changed during the verdict; review the current work again");
4002
+ const drift = checks.filter(check => check.state !== "unchanged");
4003
+ if (!drift.length) boundDeliverables = delivered;
4004
+ else {
4005
+ const text = drift.map(check => `${check.record.id} (${check.state})`).join(", ");
4006
+ if (by !== USER) throw new PreEffectToolRefusal(`deliverable changed since it was delivered: ${text}; ask its owner to call hub_task_done again so the current files are reviewed, or a person may approve them as they are`);
4007
+ // The person approves what is on disk now: those bytes are measured and bound, and the verdict says the files had moved.
4008
+ boundDeliverables = await this.hashClaims(delivered.map(record => ({ path: record.path!, format: record.format, ...(record.version !== undefined ? { version: record.version } : {}) })), task, true);
4009
+ task = valid();
4010
+ note = `${note ?? ""}\nApproved with the deliverable files changed since delivery: ${text}`.trim();
4011
+ }
4012
+ }
4013
+ }
3682
4014
  if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "approval", reviewedHead, proposedSets(task)); task = valid(); }
3683
4015
  let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
3684
4016
  if (verdict === "changes_requested" && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
@@ -3697,7 +4029,10 @@ export class Tasks {
3697
4029
  const family = by === USER ? undefined : (this.d.adapterCatalog?.familyOf ?? familyOf)(by, this.d.instances?.());
3698
4030
  const reviewerFamily = poolRequest?.reviewerFamily === family ? family : undefined;
3699
4031
  const reviewerReason = reviewerFamily ? poolRequest?.reviewerReason : undefined;
3700
- const flag = { ...this.planObservation(task), withheld: withheld || undefined, ...(report ? { reviewFindings: report } : {}), ...(reviewContext ? { reviewContext } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
4032
+ // #507b: unmet items that start with a recorded criterion id name that criterion. The ids come from the checklist on the board as it is at this
4033
+ // write (the `task` just rechecked by valid()), never from the caller; a PII task carries none, its checklist being private.
4034
+ const unmetCriteria = pii ? [] : unmetCriteriaIds(items, checklistOf(task.sdlc));
4035
+ const flag = { ...this.planObservation(task), withheld: withheld || undefined, ...(report ? { reviewFindings: report } : {}), ...(unmetCriteria.length ? { unmetCriteria } : {}), ...(boundDeliverables ? { deliverables: boundDeliverables } : {}), ...(reviewContext ? { reviewContext } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
3701
4036
  if (verdict === "approved") {
3702
4037
  const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0, ...(reviewedHead && task.sdlc ? { sdlc: { ...task.sdlc, reviewedHead, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), "merge") } } : {}) }, note, flag);
3703
4038
  if (next.owner) {
@@ -3997,6 +4332,7 @@ export class Tasks {
3997
4332
  }
3998
4333
 
3999
4334
  async remember(by: PeerId, input: { text?: string; title?: string; kind?: string; task?: unknown; horizon?: unknown; scope?: unknown; approach?: unknown; symptom?: unknown; cause?: unknown; evidence?: unknown; retry_when?: unknown }): Promise<string> {
4335
+ const requestCheck = this.requestCheck(by);
4000
4336
  const text = composeRememberText(input);
4001
4337
  const task = input.task !== undefined ? this.d.board.get(Number(input.task)) : undefined;
4002
4338
  if (task && this.isPii(task)) throw new Error("notes about a PII task are not saved: claude-mem processes what it stores with a cloud model");
@@ -4010,6 +4346,7 @@ export class Tasks {
4010
4346
  if (looksSecret(`${title}\n${text}`)) throw new Error("this note looks like it holds a secret (a key, token or password) and is not saved");
4011
4347
  if (this.aboutPrivatePath(title, text, task)) throw new Error("this note is about a path the project keeps private (pii.paths) and is not saved");
4012
4348
  if (await this.withholds("note", `${title}\n${text}`, task)) throw new Error("the PII screen did not clear this note, so it is not saved: claude-mem processes what it stores with a cloud model");
4349
+ requestCheck();
4013
4350
  const kind = (NOTE_KINDS as readonly string[]).includes(String(input.kind)) ? String(input.kind) as Kind : "finding";
4014
4351
  const native = this.saveNative(by, kind, text, title, task, input.horizon === "long" || input.horizon === "short" ? input.horizon : undefined, input.scope === "shared" ? "shared" : "project");
4015
4352
  if (task && this.d.memory) this.markMemoryReference(task, by);