@staix/agent-hub 0.24.0 → 0.26.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 (114) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +35 -8
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +2 -1
  5. package/docs/agent-notes/bus.md +1 -0
  6. package/docs/agent-notes/daemon.md +18 -3
  7. package/docs/agent-notes/tasks.md +12 -4
  8. package/docs/agent-notes/tests.md +3 -1
  9. package/docs/dogfood.md +16 -2
  10. package/docs/memory.md +1 -1
  11. package/docs/operations.md +264 -14
  12. package/docs/profiles.md +2 -0
  13. package/docs/quickstart.md +23 -6
  14. package/docs/security.md +198 -3
  15. package/docs/specs/2026-09-19-agent-hub-design.md +25 -1
  16. package/docs/specs/2026-09-19-multi-project-design.md +12 -1
  17. package/docs/specs/2026-11-01-research-campaigns-design.md +354 -0
  18. package/docs/verification/2026-11-01-0.25.0.md +91 -0
  19. package/docs/verification/2026-11-01-0.26.0.md +54 -0
  20. package/docs/verified.json +45 -17
  21. package/package.json +4 -3
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +5294 -4797
  24. package/src/adapters/channel-peer.ts +4 -2
  25. package/src/adapters/claude-channel.ts +29 -6
  26. package/src/adapters/definitions/builtin-metadata.ts +6 -6
  27. package/src/adapters/definitions/native-cli-doctor.ts +30 -0
  28. package/src/adapters/definitions/qwen.ts +2 -0
  29. package/src/adapters/jcode.ts +3 -0
  30. package/src/adapters/registry-types.ts +2 -0
  31. package/src/adapters/registry.ts +2 -1
  32. package/src/cli/acon-register.ts +29 -0
  33. package/src/cli/conductor-recovery.ts +3 -2
  34. package/src/cli/handoff-args.ts +27 -0
  35. package/src/cli/handoff-output.ts +25 -0
  36. package/src/cli/help.ts +11 -8
  37. package/src/cli/identity.ts +7 -1
  38. package/src/cli/main.ts +138 -30
  39. package/src/cli/output.ts +8 -5
  40. package/src/cli/recovery-package.ts +94 -4
  41. package/src/cli/reset.ts +14 -4
  42. package/src/cli/runtime-version-warning.ts +28 -0
  43. package/src/cli/schedule-output.ts +8 -4
  44. package/src/cli/start-agents-run.ts +125 -0
  45. package/src/cli/study-output.ts +71 -0
  46. package/src/cli/study.ts +58 -0
  47. package/src/cli/task-output.ts +6 -0
  48. package/src/cli/upgrade-interactive.ts +4 -4
  49. package/src/cli/upgrade-runtime.ts +105 -57
  50. package/src/cli/upgrade.ts +90 -28
  51. package/src/hub/autonomy-defaults.ts +32 -7
  52. package/src/hub/board.ts +993 -8
  53. package/src/hub/budget.ts +3 -4
  54. package/src/hub/channel-project.ts +61 -0
  55. package/src/hub/child-process.ts +4 -3
  56. package/src/hub/conductor.ts +3 -4
  57. package/src/hub/config-apply.ts +13 -2
  58. package/src/hub/config-edit-host.ts +1 -0
  59. package/src/hub/config-tier.ts +3 -0
  60. package/src/hub/config-trust.ts +10 -2
  61. package/src/hub/control-client.ts +39 -10
  62. package/src/hub/daemon.ts +326 -25
  63. package/src/hub/db-schema.ts +143 -0
  64. package/src/hub/delivery-journal.ts +3 -4
  65. package/src/hub/envelope.ts +2 -0
  66. package/src/hub/events.ts +4 -1
  67. package/src/hub/execution-budget.ts +15 -4
  68. package/src/hub/folder-trigger.ts +3 -4
  69. package/src/hub/handoff.ts +126 -0
  70. package/src/hub/hub-tools.ts +12 -2
  71. package/src/hub/invocations.ts +3 -4
  72. package/src/hub/lifecycle.ts +87 -29
  73. package/src/hub/manager.ts +110 -87
  74. package/src/hub/no-writes.ts +1 -1
  75. package/src/hub/orca-env.ts +19 -0
  76. package/src/hub/path-policy.ts +12 -8
  77. package/src/hub/project-runtime-state.ts +139 -0
  78. package/src/hub/project-runtime.ts +62 -0
  79. package/src/hub/project.ts +14 -5
  80. package/src/hub/protocol-window.ts +19 -0
  81. package/src/hub/provenance.ts +30 -0
  82. package/src/hub/recovery-store.ts +102 -12
  83. package/src/hub/registry.ts +5 -0
  84. package/src/hub/research-campaign.ts +265 -0
  85. package/src/hub/research-experiments.ts +474 -0
  86. package/src/hub/research-runner.ts +172 -0
  87. package/src/hub/restart.ts +35 -0
  88. package/src/hub/runtime-capture-prune.ts +415 -0
  89. package/src/hub/runtime-prune-health.ts +112 -0
  90. package/src/hub/schedules.ts +213 -13
  91. package/src/hub/sdlc-merge.ts +7 -0
  92. package/src/hub/sdlc-queue.ts +13 -1
  93. package/src/hub/session-identity.ts +49 -0
  94. package/src/hub/settings.ts +4 -0
  95. package/src/hub/snapshots.ts +3 -4
  96. package/src/hub/start-agents.ts +44 -0
  97. package/src/hub/task-approval.ts +23 -0
  98. package/src/hub/tasks.ts +1005 -34
  99. package/src/hub/ui.ts +15 -6
  100. package/src/hub/usage-rollup.ts +3 -4
  101. package/src/memory/capture.ts +2 -2
  102. package/src/pi/tool-receipts.ts +3 -4
  103. package/src/ui/index.html +5 -3
  104. package/src/ui/office-space.ts +118 -0
  105. package/src/ui/plugin.ts +293 -0
  106. package/templates/AGENTS.block.md +1 -0
  107. package/templates/profiles/general.json +1 -1
  108. package/templates/profiles/research.json +4 -4
  109. package/templates/profiles/writing.json +1 -1
  110. package/templates/roles/research/analyst.md +1 -0
  111. package/templates/roles/research/fact-checker.md +1 -0
  112. package/templates/roles/research/research-lead.md +1 -0
  113. package/templates/roles/research/researcher.md +1 -0
  114. package/templates/roles/research/statistician.md +1 -0
package/src/hub/tasks.ts CHANGED
@@ -1,8 +1,18 @@
1
+ import { parseAttemptReceipt, parseResearchRunners, verifyAttemptReceipt } from "./research-runner.ts";
2
+ import { approvalBinding, approvalText } from "./task-approval.ts";
1
3
  import type { InvocationRow } from "./invocations.ts";
2
4
  import type { ContextAdmission } from "./context-window.ts";
3
5
  import { queueBackpressure, queueBackpressurePolicy, type QueueBackpressureCode, type QueueBackpressurePolicy, type QueueBackpressureStatus, type QueuePressure } from "./queue-backpressure.ts";
4
6
  import { canonicalMemorySymbolNode, normalizeMemorySymbolInput, MEMORY_SYMBOL_LIMIT, type MemorySymbolNodes } from "./memory-symbols.ts";
5
- import { createHash } from "node:crypto";
7
+ import { createHash, randomBytes } from "node:crypto";
8
+ import { types } from "node:util";
9
+ import { canonicalJson } from "./packs.ts";
10
+ import { parseExperimentProtocol, parseExperimentAttempt, parseExperimentEvent, protocolHash, checkRunObservation, deliverableObservation, projectExperimentWithObservations, projectRunnerReceipt,
11
+ type ExperimentProtocolContract, type ExperimentProtocolRecord, type ExperimentAttemptRecord, type ExperimentWriteFence, type ExperimentHostEvidence, type ExperimentStatus } from "./research-experiments.ts";
12
+ import { parseComparisonPlan, comparisonPlanHash, comparisonProjection, evaluatorIdentityHash, protocolApprovalBindingMatches, type ComparisonPlanV1 } from "./research-experiments.ts";
13
+ import { parseResearchHandoff, studyProjection, campaignHash, parseResearchCampaign, type ResearchCampaignContract, type ResearchCampaignStatus } from "./research-campaign.ts";
14
+ import type { ResearchCampaignRecord, ResearchUnitBinding, PreparedResearchUnit, HandoffOutboundRow } from "./board.ts";
15
+ import { HANDOFF_EVENTS, HandoffRefusal, followBackLine, handoffEventFor, parseHandoff, type HandoffEvent } from "./handoff.ts";
6
16
  import { taskShellBindingValid, type TaskShellBinding } from "./task-shell-command.ts";
7
17
  import { buildTaskCodeSymbols, publicTaskCodeSymbols, taskCodeSymbolsDisjoint, HOST_CODE_SYMBOLS } from "./task-code-symbols.ts";
8
18
  import { normalizePlanSymbol, symbolsOverlap } from "./symbol-normalizer.ts";
@@ -12,7 +22,7 @@ import { completePlanInventory, planConformance, planConformanceBlockers, planFi
12
22
  import { RELEASE_TRAIN_HISTORY_LIMIT, releaseTrainData, releaseTrainReadiness, releaseMetrics } from "./sdlc-release.ts";
13
23
  import { cleanCostPlan, costMetrics, type CostPlan, type ReviewCost } from "./cost-governance.ts";
14
24
  import { qualifiedTitleRepos, siblingTitleRefs, specRefs } from "./spec-read.ts";
15
- import { folderDetail, MAX_NEW_PER_POLL, parseEvery, SOURCE_ID, scheduledDetail, scheduleKeyOf, type FolderEntry, type FolderRunResult, type FolderScan, type FolderStorePort, type ScannedFile, type ScheduleEntry, type ScheduleRunResult, type ScheduleStore } from "./schedules.ts";
25
+ import { folderDetail, MAX_NEW_PER_POLL, scheduleNext, SOURCE_ID, scheduledDetail, scheduleKeyOf, type FolderEntry, type FolderRunResult, type FolderScan, type FolderStorePort, type ScannedFile, type ScheduleEntry, type ScheduleRunResult, type ScheduleStore } from "./schedules.ts";
16
26
  import { INTAKE_MAX_SOURCES, INTAKE_MAX_TEXT, intakeDetail, intakeSystemPrompt, intakeUserMessage, parseIntakeDetail, parseIntakeSplit, plannerRequestBody } from "./intake.ts";
17
27
  import { DEFAULT_BAND, EPISODE_LOOKBACK, sdlcMetrics, type BandMetric, type BandResult, type MetricTask, type MetricsReport, type SeriesPoint } from "./sdlc-metrics.ts";
18
28
  import { readSecurityScan } from "./security-baseline.ts";
@@ -30,7 +40,9 @@ import { CODE_SCOPE_LINE, evidenceOnlyNotice, type ReviewScope } from "./review-
30
40
  import { DEFAULT_MAX_NITS, parseReviewFindings, findingsForHead, findingHead as resolveFindingHead, findingTally, type ReviewFinding, type ReviewFindingSet } from "./review-findings.ts";
31
41
  import { readReleaseReadiness } from "./sdlc-release.ts";
32
42
  import { loadRoleCatalog, taskRole, taskRoleText, type RoleCatalog } from "./roles.ts";
33
- import { matchesPolicy } from "./path-policy.ts";
43
+ import { matchesPolicy, policyRealPath, insideRoot } from "./path-policy.ts";
44
+ import { isDenied } from "../local/deny.ts";
45
+ import { foldSegment, isHubWritePath } from "./permission-mode.ts";
34
46
  import { correctionInput, resolveVisibleMemory } from "./memory-candidates.ts";
35
47
  import type { MemoryScreen } from "./read-tools.ts";
36
48
  import { withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
@@ -48,7 +60,7 @@ const learnVerdict = (card: Pick<Task, "state" | "history">): "accepted" | "reje
48
60
  };
49
61
  import { approvedDeliverables, latestDeliverables, parseDeliverableClaims, publicDeliverables, type DeliverableRecord, type DeliverableStatus } from "./deliverables.ts";
50
62
  import { checkDeliverables, DeliverableRefusal, hashDeliverables, vetDeliverables, type DeliverablePorts } from "./deliverable-files.ts";
51
- import { isAbsolute, relative } from "node:path";
63
+ import { isAbsolute, relative, resolve } from "node:path";
52
64
  import type { Briefs, NativeItem } from "../memory/brief.ts";
53
65
  import type { MemoryClient } from "../memory/client.ts";
54
66
  import { candidateTargetRevision, type CandidateInput, type CandidateStore, extractNodes, looksSecret, PROMOTED_KINDS, taskKey, type GoalView, type Kind, type MemoryStore, type Scope, type Tier } from "@staix/agent-mem";
@@ -174,6 +186,14 @@ export interface TasksDeps {
174
186
  quota?: () => Record<PeerId, { headroom: number; resetsAt?: number }>;
175
187
  /** Optional persisted task/run execution limits (issue #102). */
176
188
  executionBudget?: ExecutionBudget;
189
+ /** Project-scoped immutable-source metadata, read only before a study transaction. */
190
+ studySource?: (id: string) => { id: string; sha256: string; private: boolean } | undefined;
191
+ /** Host-only fixture clock; never read from model input or project configuration. */
192
+ studyNow?: () => number;
193
+ /** Host-only reader for this project's check-runner receipts; record inputs never supply a receipt. */
194
+ experimentCheck?: (run: string) => Promise<unknown>;
195
+ /** Fresh trusted machine-local runner registrations. A source-trust failure refuses new receipts. */
196
+ researchRunners?: () => unknown;
177
197
  /** Reviewer choice from recorded review outcomes (issue #35); off unless the project config turns it on. */
178
198
  review?: { adaptive: boolean; min_reviews: number; pool?: readonly string[]; rerotate?: boolean; same_family?: "fallback" | "never" };
179
199
  /** Peers whose recent deliveries all failed, with the reason (issue #89); routing skips them. */
@@ -375,6 +395,21 @@ export const GROOM_FYI_WINDOW_MS = 10 * 60_000;
375
395
  * PII handling, envelopes, briefs and memory notes are decided in one place.
376
396
  */
377
397
  /** #694: a PR staged at done with the commit or branch it was read for. */
398
+ export interface ResearchCampaignReceipt { id: string; parentTaskId: number; revision: number; status: ResearchCampaignStatus; unitCount: number; units: ResearchUnitBinding[] }
399
+ interface StudyReferences { parent: Task; adopted: Map<string, Task>; reused: Map<string, Task>; private: boolean; key: string }
400
+ export interface ExperimentReceipt { kind: "protocol" | "attempt" | "event" | "receipt"; protocolHash: string; attemptId?: string; seq?: number; receiptId?: string }
401
+ export interface ComparisonPlanReceipt { kind: "plan"; planHash: string }
402
+ export interface ProtocolApprovalReceipt { protocolHash: string; evaluatorHash: string; approvedBy: string; approvedAt: number }
403
+ export interface ExperimentAttemptView {
404
+ id: string; protocolHash: string; taskId: number; phase: "development" | "final"; status: ExperimentStatus; private: boolean;
405
+ protocol: unknown; events: unknown[]; evidence: Record<string, unknown>; eventsComplete: boolean; nextEventCursor?: number;
406
+ campaignRevision: number; approval: "approved" | "unapproved"; postHoc: "unknown" | "predeclared" | "post-hoc";
407
+ retryOf?: string; reproducesAttemptId?: string; independentlyReproduced: false;
408
+ }
409
+ export interface ExperimentView { attempts: ExperimentAttemptView[]; counts: { all: number; statuses: Record<ExperimentStatus, number> }; nextCursor?: number;
410
+ comparison?: { planHash: string; baselineAttemptId: string; variantAttemptId: string; approved: boolean; private: boolean; projection: ReturnType<typeof comparisonProjection> | "[pii]" } }
411
+ interface ExperimentContext { task: Task; campaign: ResearchCampaignRecord; private: boolean; key: string; fence: ExperimentWriteFence }
412
+ interface CompletionRefresh { revision: number; commit: string; state: "in_review" | "approved" }
378
413
  interface IdentityLink { repo: string; pr: number; basis: { commit?: string; branch?: string } }
379
414
 
380
415
  /** 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. */
@@ -887,6 +922,7 @@ export class Tasks {
887
922
  ...(h.selfReview ? { selfReview: true } : {}),
888
923
  ...(h.reviewerFamily ? { reviewerFamily: screenHistoryText(h.reviewerFamily, "reviewer family") } : {}),
889
924
  ...(h.reviewerReason ? { reviewerReason: h.reviewerReason } : {}),
925
+ ...(h.reviewedHead ? { reviewedHead: h.reviewedHead } : {}),
890
926
  ...(h.reviewScope === "code" ? { reviewScope: "code" as const } : {}),
891
927
  ...(h.evidenceOnly ? { evidenceOnly: true as const } : {}),
892
928
  ...(h.unmetCriteria?.length ? { unmetCriteria: h.unmetCriteria } : {}),
@@ -901,7 +937,8 @@ export class Tasks {
901
937
  const sets = this.reviewFindingSets(task), current = findingsForHead(sets, this.findingHead(task));
902
938
  const symbolRepository = codeSymbols && this.codeSymbolsRepository(task);
903
939
  const publicSymbols = codeSymbols && symbolRepository ? publicTaskCodeSymbols(codeSymbols, this.reviewHead(task), symbolRepository.key, this.publicMetadataText) : undefined;
904
- return { ...rest, ...(publicSymbols ? { codeSymbols: publicSymbols } : {}), ...(current.length ? { findings: findingTally(current) } : {}), ...(cancelled ? { cancelled: { ...cancelled, reason: screenHistoryText(cancelled.reason, "cancellation", cancelled.withheld), ...(cancelled.supersededBy ? { supersededBy: screenHistoryText(cancelled.supersededBy, "supersession", cancelled.withheld) } : {}) } } : {}), ...(external && this.nameable(external.url) ? { external } : {}), actualDates: actualTaskDates(task), ...(((this.d.sdlc?.config().mode ?? "off") !== "off" || this.d.sdlc?.config().workflow === "gsd-lite") ? { lifecycle: sdlcStatus(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc, undefined, this.workflowView(task), undefined, sets, task.refs.commit) } : {}), title, detail, history: publicHistory };
940
+ const handedOff = this.d.board.handoffOrigin(task.id); // #604: where another project's handoff came from; two ids, never text
941
+ return { ...rest, ...(approvalBinding(task) ? { approval: approvalBinding(task) } : {}), ...(handedOff ? { handoffOrigin: { projectId: handedOff.projectId, peer: handedOff.peer, ...(handedOff.originTask === null ? {} : { task: handedOff.originTask }) } } : {}), ...(publicSymbols ? { codeSymbols: publicSymbols } : {}), ...(current.length ? { findings: findingTally(current) } : {}), ...(cancelled ? { cancelled: { ...cancelled, reason: screenHistoryText(cancelled.reason, "cancellation", cancelled.withheld), ...(cancelled.supersededBy ? { supersededBy: screenHistoryText(cancelled.supersededBy, "supersession", cancelled.withheld) } : {}) } } : {}), ...(external && this.nameable(external.url) ? { external } : {}), actualDates: actualTaskDates(task), ...(((this.d.sdlc?.config().mode ?? "off") !== "off" || this.d.sdlc?.config().workflow === "gsd-lite") ? { lifecycle: sdlcStatus(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc, undefined, this.workflowView(task), undefined, sets, task.refs.commit) } : {}), title, detail, history: publicHistory };
905
942
  }
906
943
 
907
944
  /** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
@@ -1226,6 +1263,833 @@ export class Tasks {
1226
1263
  this.planningAuthority(by, task);
1227
1264
  }
1228
1265
 
1266
+ private studiesRunning = 0;
1267
+ private studyStopped = false;
1268
+ private readonly studyDrainWaiters = new Set<() => void>();
1269
+ studiesPending = (): number => this.studiesRunning;
1270
+
1271
+ async stopStudies(): Promise<void> {
1272
+ this.studyStopped = true;
1273
+ if (this.studiesRunning) await new Promise<void>(resolve => this.studyDrainWaiters.add(resolve));
1274
+ }
1275
+
1276
+ private studyHold(): string | undefined {
1277
+ try {
1278
+ if (this.studyStopped || this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError)
1279
+ return "study writes are held by recovery, shutdown or storage failure";
1280
+ } catch { return "study write admission is unavailable"; }
1281
+ return undefined;
1282
+ }
1283
+
1284
+ private async studyWrite<T>(write: () => Promise<T>): Promise<T> {
1285
+ this.studiesRunning++;
1286
+ try { return await write(); } finally {
1287
+ if (--this.studiesRunning === 0) { for (const resolve of this.studyDrainWaiters) resolve(); this.studyDrainWaiters.clear(); }
1288
+ }
1289
+ }
1290
+
1291
+ private studyFields(input: unknown, allowed: readonly string[]): Record<string, unknown> {
1292
+ if (!input || typeof input !== "object" || Array.isArray(input) || types.isProxy(input)
1293
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(input)) || Object.getOwnPropertySymbols(input).length)
1294
+ throw new PreEffectToolRefusal("study request must be a plain data object");
1295
+ const fields = Object.getOwnPropertyDescriptors(input);
1296
+ if (Object.entries(fields).some(([key, descriptor]) => !allowed.includes(key) || !Object.hasOwn(descriptor, "value")))
1297
+ throw new PreEffectToolRefusal("study request has unknown fields or accessors");
1298
+ return Object.fromEntries(Object.entries(fields).map(([key, descriptor]) => [key, descriptor.value]));
1299
+ }
1300
+
1301
+ private studyId(value: unknown): string {
1302
+ if (typeof value !== "string" || !/^rc-[a-f0-9]{12}$/.test(value)) throw new PreEffectToolRefusal("study id is invalid");
1303
+ return value;
1304
+ }
1305
+
1306
+ private studyNumber(value: unknown, field: string): number {
1307
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) throw new PreEffectToolRefusal(`${field} must be a positive integer`);
1308
+ return value;
1309
+ }
1310
+
1311
+ private studyContract(value: unknown): ResearchCampaignContract {
1312
+ try { return parseResearchCampaign(value); }
1313
+ catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "invalid study contract"); }
1314
+ }
1315
+
1316
+ private studyTime(): number {
1317
+ const now = this.d.studyNow?.() ?? Date.now();
1318
+ if (!Number.isFinite(now)) throw new PreEffectToolRefusal("study clock is unavailable");
1319
+ return now;
1320
+ }
1321
+
1322
+ private studyRecord(id: string, revision?: number): ResearchCampaignRecord {
1323
+ let record: ResearchCampaignRecord | undefined;
1324
+ try { record = this.d.board.researchCampaign(id, revision); }
1325
+ catch { throw new PreEffectToolRefusal("study record is unavailable or invalid"); }
1326
+ if (!record || record.project !== this.d.project) throw new PreEffectToolRefusal("study is unavailable in this project");
1327
+ return record;
1328
+ }
1329
+
1330
+ private studyParent(id: number): Task {
1331
+ const parent = this.need(id, false, true, true);
1332
+ if (this.places(parent).repositoryKey !== this.repositories.scope({}).key) throw new PreEffectToolRefusal("study parent is outside this project");
1333
+ return parent;
1334
+ }
1335
+
1336
+ /** Write receipts expose no contract text; only person-only approval returns the unit mapping. */
1337
+ private studyReceipt(record: ResearchCampaignRecord, includeMapping = false): ResearchCampaignReceipt {
1338
+ const units = this.d.board.researchCampaignBindings(record.id, record.revision);
1339
+ return { id: record.id, parentTaskId: record.parentTaskId, revision: record.revision, status: record.status,
1340
+ unitCount: units.length, units: includeMapping ? units : [] };
1341
+ }
1342
+
1343
+ private studySource(id: string): { id: string; sha256: string; private: boolean } | undefined {
1344
+ try {
1345
+ const source = this.d.studySource?.(id);
1346
+ return source && source.id === id && /^[a-f0-9]{64}$/.test(source.sha256) && typeof source.private === "boolean"
1347
+ ? { id: source.id, sha256: source.sha256, private: source.private } : undefined;
1348
+ } catch { return undefined; }
1349
+ }
1350
+
1351
+ private studyLatest(id: string, revision: number): ResearchCampaignRecord {
1352
+ const record = this.studyRecord(id);
1353
+ if (record.revision !== revision || record.status === "closed") throw new PreEffectToolRefusal("study revision is stale or closed");
1354
+ return record;
1355
+ }
1356
+
1357
+ private studyFamily(parent: Task): Set<number> {
1358
+ if (!parent.history.some(entry => entry.event === "draft approved")) throw new PreEffectToolRefusal("adoption requires an approved intake family");
1359
+ const family = new Set<number>(), pending = [...(parent.deps ?? [])];
1360
+ // ponytail: 512 dependency nodes bound intake provenance walks; a larger family needs indexed family identities.
1361
+ while (pending.length) {
1362
+ const id = pending.pop()!;
1363
+ if (family.has(id)) continue;
1364
+ if (family.size >= 512) throw new PreEffectToolRefusal("study intake family exceeds its bounded scope");
1365
+ const task = this.d.board.get(id);
1366
+ if (!task) throw new PreEffectToolRefusal("study intake family is incomplete");
1367
+ family.add(id); pending.push(...(task.deps ?? []));
1368
+ }
1369
+ family.delete(parent.id);
1370
+ return family;
1371
+ }
1372
+
1373
+ private studyReferences(parentId: number, contract: ResearchCampaignContract, campaignId?: string): StudyReferences {
1374
+ const parent = this.studyParent(parentId);
1375
+ const profile = parent.profile ?? "dev", storedProfile = profile === (this.d.profile ?? "dev") ? this.d.profileData ?? profile : profile;
1376
+ try {
1377
+ if (profileClass(parent.profileClass ?? parent.class, storedProfile) !== parent.class) throw new Error("changed class");
1378
+ } catch { throw new PreEffectToolRefusal("study parent profile class is unavailable or changed"); }
1379
+ const sources = contract.sourceIds.map(id => {
1380
+ const source = this.studySource(id);
1381
+ if (!source)
1382
+ throw new PreEffectToolRefusal("study source is unavailable in this project");
1383
+ return source;
1384
+ });
1385
+ if (contract.budget) {
1386
+ const budget = this.d.executionBudget?.status(contract.budget.id);
1387
+ if (!budget || Array.isArray(budget)) throw new PreEffectToolRefusal("study budget is unavailable");
1388
+ }
1389
+ const adopted = new Map<string, Task>();
1390
+ const family = contract.units.some(unit => unit.adoptTaskId !== undefined) ? this.studyFamily(parent) : new Set<number>();
1391
+ for (const unit of contract.units) if (unit.adoptTaskId !== undefined) {
1392
+ const task = this.studyParent(unit.adoptTaskId);
1393
+ if (!family.has(task.id) || task.draft || !task.history.some(entry => entry.event === "draft approved"))
1394
+ throw new PreEffectToolRefusal("study unit must adopt an approved intake child");
1395
+ const binding = this.d.board.researchTaskBinding(task.id);
1396
+ if (binding && (binding.campaignId !== campaignId || binding.unitId !== unit.id)) throw new PreEffectToolRefusal("intake child is already bound to another study unit");
1397
+ adopted.set(unit.id, task);
1398
+ }
1399
+ const reused = new Map<string, Task>();
1400
+ if (campaignId) for (const unit of contract.units) {
1401
+ const previous = this.d.board.researchLatestUnitBinding(campaignId, unit.id);
1402
+ if (previous) reused.set(unit.id, this.studyParent(previous.taskId));
1403
+ }
1404
+ const priv = this.isPii(parent) || sources.some(source => source.private) || [...adopted.values(), ...reused.values()].some(task => this.isPii(task))
1405
+ || !this.nameable(JSON.stringify(contract));
1406
+ const flows = (rows: Map<string, Task>) => [...rows].map(([unit, task]) => [unit, task.id, task.history.length]);
1407
+ return { parent, adopted, reused, private: priv, key: JSON.stringify([parent.history.length, sources, flows(adopted), flows(reused), priv]) };
1408
+ }
1409
+
1410
+ /** Always re-read persisted approval and limits at the dispatch write, including after a metadata await. */
1411
+ private studyDispatchReason(task: Task, owner?: PeerId): string | undefined {
1412
+ try {
1413
+ const bound = this.d.board.researchDispatchBinding(task.id);
1414
+ if (!bound) return undefined;
1415
+ const { campaign, binding } = bound;
1416
+ if (campaign.project !== this.d.project || campaign.status !== "approved" || !binding) return "study revision is not approved or its unit was removed";
1417
+ const stop = campaign.contract.stopPolicy;
1418
+ if (Date.parse(stop.deadline) <= this.studyTime()) return "study deadline has expired";
1419
+ const units = this.d.board.researchCampaignBindings(campaign.id, campaign.revision);
1420
+ if (units.length > stop.maxUnits || binding.round > stop.maxFollowUpRounds) return "study stop limit exceeded";
1421
+ const budget = campaign.contract.budget;
1422
+ if (budget?.hardCap.length) {
1423
+ const ledger = this.d.executionBudget, current = ledger?.status(budget.id);
1424
+ if (!ledger || !current || Array.isArray(current) || !campaign.eligibleOwners?.length ||
1425
+ budget.hardCap.some(resource => resource === "tokens" || current.limits[resource] === undefined || resource === "model_calls" && current.limits.tokens !== undefined)) return "study hard-cap budget is unavailable";
1426
+ if (budget.hardCap.some(resource => !current.units[resource] || current.units[resource]!.remaining <= 0 || current.units[resource]!.reason === "unknown_usage")) return "study hard-cap budget is exhausted or usage is unknown";
1427
+ if (owner && (!campaign.eligibleOwners.includes(owner) || !ledger.covers(budget.id, task.id, owner, budget.hardCap)))
1428
+ return "study owner is not covered by its named hard-cap budget";
1429
+ }
1430
+ return undefined;
1431
+ } catch { return "study dispatch evidence is unavailable"; }
1432
+ }
1433
+
1434
+ private studyDispatchCheck(task: Task, owner?: PeerId): void {
1435
+ const reason = this.studyDispatchReason(task, owner);
1436
+ if (reason) throw new PreEffectToolRefusal(reason);
1437
+ }
1438
+
1439
+ private studyExcludedOwners(task: Task): PeerId[] {
1440
+ return Object.keys(this.states()).filter(peer => this.studyDispatchReason(task, peer) !== undefined);
1441
+ }
1442
+
1443
+ private studyEligibleOwners(contract: ResearchCampaignContract): string[] | undefined {
1444
+ if (!contract.budget?.hardCap.length) return undefined;
1445
+ const ledger = this.d.executionBudget, budget = ledger?.status(contract.budget.id);
1446
+ if (!ledger || !budget || Array.isArray(budget) || contract.budget.hardCap.some(resource => resource === "tokens" || budget.limits[resource] === undefined || resource === "model_calls" && budget.limits.tokens !== undefined))
1447
+ throw new PreEffectToolRefusal("study hard-cap requires an enabled budget with supported resource limits");
1448
+ const eligible = budget.peers.filter(peer => ledger.instrumented(peer)).sort();
1449
+ if (!eligible.length) throw new PreEffectToolRefusal("study hard-cap has no instrumented eligible owner");
1450
+ return eligible;
1451
+ }
1452
+
1453
+ private studyRoundCheck(original: ResearchCampaignRecord, contract: ResearchCampaignContract): void {
1454
+ const added = contract.units.filter(unit => unit.round > 0 && !original.contract.units.some(previous => previous.id === unit.id && previous.round === unit.round));
1455
+ if (!added.length) return;
1456
+ const stored = this.d.board.researchCampaignBindings(original.id, original.revision), handoffs = this.d.board.researchHandoffs(original.id, original.revision);
1457
+ for (const unit of added) {
1458
+ // The revised contract decides which discovery units precede this round: a discovery added in the same revision has no stored binding, so it has no handoff and refuses.
1459
+ const previous = contract.units.filter(value => value.round === unit.round - 1 && value.purpose === "discovery");
1460
+ if (!previous.length || previous.some(value => {
1461
+ const binding = stored.find(bound => bound.unitId === value.id && bound.round === value.round && bound.purpose === "discovery");
1462
+ const handoff = binding && handoffs.find(handoff => handoff.handoff.unitId === value.id);
1463
+ return !handoff || !handoff.handoff.insufficientEvidence && value.sourceIds.some(source => !handoff.handoff.evidence.some(item => item.sourceId === source));
1464
+ })) throw new PreEffectToolRefusal("follow-up rounds require complete stored discovery handoffs or insufficientEvidence");
1465
+ }
1466
+ }
1467
+
1468
+ async studyHandoff(by: PeerId, input: unknown): Promise<{ campaignId: string; unitId: string; seq: number }> {
1469
+ const requestCheck = this.requestCheck(by, () => this.studyHold());
1470
+ let handoff: ReturnType<typeof parseResearchHandoff>;
1471
+ try { handoff = parseResearchHandoff(input); } catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "invalid study handoff"); }
1472
+ return this.studyWrite(async () => {
1473
+ const campaign = this.studyRecord(handoff.campaignId, handoff.revision), task = this.studyParent(handoff.taskId);
1474
+ if (by !== task.owner || task.state !== "in_progress" || this.d.privateTurn?.(by) && !this.isPii(task)) throw new PreEffectToolRefusal("handoff requires the current in-progress unit owner");
1475
+ const sources = campaign.contract.sourceIds.map(id => this.studySource(id));
1476
+ if (sources.some(source => !source)) throw new PreEffectToolRefusal("study handoff source is unavailable");
1477
+ const sourceKey = JSON.stringify(sources);
1478
+ const text = JSON.stringify(handoff);
1479
+ const withheld = !this.nameable(text) || looksSecret(text) || await this.withholds("note", text, task);
1480
+ requestCheck();
1481
+ const current = this.studyParent(task.id);
1482
+ if (current.history.length !== task.history.length || current.owner !== by || current.state !== "in_progress") throw new PreEffectToolRefusal("study handoff task changed during screening");
1483
+ if (JSON.stringify(campaign.contract.sourceIds.map(id => this.studySource(id))) !== sourceKey) throw new PreEffectToolRefusal("study handoff sources changed during screening");
1484
+ if ((withheld || this.studyReplayPrivacy(campaign, this.studyParent(campaign.parentTaskId))) && !this.isPii(current)) throw new PreEffectToolRefusal("study handoff privacy requires private task authority");
1485
+ requestCheck();
1486
+ try {
1487
+ const saved = this.d.board.recordResearchHandoff(handoff, this.d.project, by, this.studyTime(), current.history.length);
1488
+ return { campaignId: saved.handoff.campaignId, unitId: saved.handoff.unitId, seq: saved.seq };
1489
+ } catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "study handoff was not saved"); }
1490
+ });
1491
+ }
1492
+
1493
+ /**
1494
+ * #601: one page of receipts, one per campaign of this project, newest revision, never its question, scope, sources or unit text. At most 50 rows per call (the spec's readback bound), in id
1495
+ * order, strictly after the optional cursor `after`; `next` names the last id of a page that has more behind it. The bound is in the Board query, not in a slice after loading everything.
1496
+ */
1497
+ studyList(by: PeerId, input: unknown): { campaigns: ResearchCampaignReceipt[]; next?: string } {
1498
+ requireLocalTaskPeer(by);
1499
+ const fields = this.studyFields(input, ["after"]);
1500
+ const after = fields.after === undefined ? undefined : this.studyId(fields.after);
1501
+ const page = this.d.board.researchCampaignPage(this.d.project, after, 50);
1502
+ return { campaigns: page.ids.map(id => this.studyReceipt(this.studyRecord(id))), ...(page.next ? { next: page.next } : {}) };
1503
+ }
1504
+
1505
+ studyShow(by: PeerId, input: unknown): ReturnType<typeof studyProjection> {
1506
+ requireLocalTaskPeer(by);
1507
+ const fields = this.studyFields(input, ["id"]), campaign = this.studyRecord(this.studyId(fields.id));
1508
+ const bindings = this.d.board.researchCampaignBindings(campaign.id, campaign.revision);
1509
+ const tasks = bindings.map(unit => this.d.board.get(unit.taskId)).filter((task): task is Task => !!task);
1510
+ const ledger = this.d.executionBudget, configured = campaign.contract.budget, budget = configured && ledger?.status(configured.id);
1511
+ const instrumentedPeers = tasks.flatMap(task => task.owner && ledger?.instrumented(task.owner) ? [task.owner] : []);
1512
+ const projection = studyProjection({ campaign, bindings, tasks, handoffs: this.d.board.researchHandoffs(campaign.id), now: this.studyTime(), instrumentedPeers,
1513
+ ...(budget && !Array.isArray(budget) ? { budget: { ...budget, coveredTaskIds: tasks.filter(task => task.owner && ledger?.covers(budget.id, task.id, task.owner, [])).map(task => task.id) } } : {}) });
1514
+ projection.reasons = [...new Set([...projection.reasons, ...tasks.flatMap(task => { const reason = this.studyDispatchReason(task, task.owner ?? undefined); return reason ? [reason] : []; })])];
1515
+ projection.held = projection.reasons.length > 0;
1516
+ if (this.studyReplayPrivacy(campaign, this.studyParent(campaign.parentTaskId))) {
1517
+ // IDs, counts, states and resource names only. Source references are private view data too.
1518
+ for (const unit of projection.units) { unit.sourceIds = []; unit.duplicateSourceIds = []; unit.handoff.coveredSourceIds = []; }
1519
+ }
1520
+ return projection;
1521
+ }
1522
+
1523
+ private studyReplayPrivacy(record: ResearchCampaignRecord, parent: Task): boolean {
1524
+ return record.private || this.isPii(parent) || record.contract.sourceIds.some(id => {
1525
+ const source = this.studySource(id);
1526
+ return !source || source.private;
1527
+ }) || this.d.board.researchCampaignBindings(record.id, record.revision).some(unit => {
1528
+ const task = this.d.board.get(unit.taskId); return !task || this.isPii(task);
1529
+ });
1530
+ }
1531
+
1532
+ /**
1533
+ * #604 (agent-hub#1030), ORIGIN side. Who may hand work to another project: the person, or a configured planner, or the conductor. The host (the daemon) has already resolved and
1534
+ * checked the target; this is the authority and privacy gate before anything leaves this project, so a refusal here leaves no row in either board.
1535
+ */
1536
+ handoffOutAuthority(by: PeerId, piiTask?: Task): void {
1537
+ requireLocalTaskPeer(by);
1538
+ if (by !== USER && this.d.privateTurn?.(by)) throw new HandoffRefusal("pii_turn", "a handoff is unavailable during a private turn: nothing from it may leave this project");
1539
+ if (piiTask && this.isPii(piiTask)) throw new HandoffRefusal("pii_task", "a [pii] task is not handed off: nothing about it leaves this project");
1540
+ if (by !== USER && !this.d.roles?.[by]?.includes("planner") && this.d.operational?.(by) !== true) throw new HandoffRefusal("no_authority", "a handoff needs the person, a configured planner or the conductor");
1541
+ }
1542
+
1543
+ /** ORIGIN side: remember what was handed over, once the target answered with its task id. */
1544
+ handoffSent(by: PeerId, target: { projectId: string }, remoteTask: number, title: string, localTask?: number): void {
1545
+ this.d.board.recordHandoffOutbound({ remoteProject: target.projectId, remoteTask, originPeer: by, ...(localTask === undefined ? {} : { localTask }), title: title.slice(0, 300), state: "proposed" });
1546
+ }
1547
+
1548
+ /**
1549
+ * TARGET side. The origin peer gets no identity or authority here: the task is an ownerless proposal made by the hub on the origin's behalf, with the origin recorded in its own table.
1550
+ * The request was parsed closed by the origin and is parsed again here, because a control message is data from another process: the body is bounded, control characters are refused and the
1551
+ * text is sanitized before it is stored. A `[pii]` result is refused whole, and nothing is left behind.
1552
+ */
1553
+ async handoffIn(origin: { projectId: unknown; peer: unknown; taskId?: unknown }, input: unknown, fence: () => string | undefined = () => undefined): Promise<{ task: number; state: string; note?: string }> {
1554
+ const request = parseHandoff({ ...(input && typeof input === "object" && !Array.isArray(input) ? input as Record<string, unknown> : { invalid: true }), project: "local" });
1555
+ if (typeof origin.projectId !== "string" || !origin.projectId || origin.projectId.length > 200 || typeof origin.peer !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(origin.peer)
1556
+ || origin.taskId !== undefined && (!Number.isSafeInteger(origin.taskId) || (origin.taskId as number) < 1)) throw new HandoffRefusal("invalid", "handoff origin is malformed");
1557
+ if (origin.projectId === this.d.project) throw new HandoffRefusal("same_project", "that is this project: a task for this project goes through hub_task_propose");
1558
+ // The fence is asked right before the board write and after every await: a recovery that began while the PII screen was pending must find nothing written (agent-hub#1030, review).
1559
+ const held = (): void => { const reason = fence(); if (reason) throw new HandoffRefusal("invalid", reason); };
1560
+ held();
1561
+ // The spec issue the caller named is kept in the task itself, as its own line: the target's planner reads it from the detail, and `ahub task show` shows it there.
1562
+ const issueLine = request.issue === undefined ? "" : `spec issue: #${request.issue} in this repository`;
1563
+ const detail = sanitize([request.detail, issueLine, `[handed off from project ${origin.projectId} by ${origin.peer}${origin.taskId === undefined ? "" : ` (their task #${origin.taskId})`}]`].filter(Boolean).join("\n\n"));
1564
+ const refs: TaskRefs = { ...(request.paths.length ? { paths: request.paths } : {}) };
1565
+ const signals = this.signalsFor({ title: request.title, detail, refs, plan: {} });
1566
+ if (signals.includes("pii")) throw new HandoffRefusal("pii_task", "the handed-off text matches a PII pattern; it is not stored here");
1567
+ const screening = this.screenOn();
1568
+ const screened = screening ? await this.screenCall(screenText({ title: request.title, detail: [detail, ...request.paths].join("\n"), plan: {} })) : undefined;
1569
+ held();
1570
+ if (screening && (!screened || screened.label !== "clear")) throw new HandoffRefusal("pii_task", "the handed-off text could not be cleared by the PII screen; it is not stored here");
1571
+ let task = this.d.board.propose(HUB, { title: request.title, detail, class: request.class, refs, plan: {}, signals });
1572
+ this.d.board.setHandoffOrigin(task.id, { projectId: origin.projectId, peer: origin.peer, ...(origin.taskId === undefined ? {} : { originTask: origin.taskId as number }) });
1573
+ // From here the task and its origin are saved, so nothing below may fail the handoff: the origin must learn the task id, or the target keeps work nobody can track.
1574
+ try { task = this.linkOnWrite(task); } catch { /* the lifecycle link is advice */ }
1575
+ try { this.d.notify(`task ${this.publicTitle(task)} handed off from project ${origin.projectId} by ${origin.peer}`); } catch { /* never throw after a board write */ }
1576
+ let note: string | undefined;
1577
+ try { task = await this.assignOwner(task, HUB, {}); }
1578
+ catch (error) {
1579
+ if (error instanceof AssignmentUndeliveredError) { task = error.task; note = "the assignment is saved but its delivery is not confirmed yet; this hub retries it"; }
1580
+ else note = "the task is saved but was not offered to an owner yet; this hub's routing offers it"; // a routing refusal leaves an ordinary proposal for the planner
1581
+ }
1582
+ return { task: task.id, state: task.state, ...(note ? { note } : {}) };
1583
+ }
1584
+
1585
+ /** TARGET side: the origin recorded for a task, for `ahub task show` and the board. */
1586
+ handoffOriginOf(taskId: number): { projectId: string; peer: string; originTask: number | null } | undefined {
1587
+ const origin = this.d.board.handoffOrigin(taskId);
1588
+ return origin ? { projectId: origin.projectId, peer: origin.peer, originTask: origin.originTask } : undefined;
1589
+ }
1590
+
1591
+ /** ORIGIN side: the target reported a state change. Records it and tells the origin peer with one `kind: task` envelope. Idempotent: a state already recorded sends nothing. */
1592
+ handoffEvent(input: unknown): { recorded: boolean } {
1593
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new HandoffRefusal("invalid", "handoff event must be a data object");
1594
+ const e = input as Record<string, unknown>;
1595
+ if (typeof e.projectId !== "string" || !e.projectId || e.projectId.length > 200 || !Number.isSafeInteger(e.remoteTask) || (e.remoteTask as number) < 1
1596
+ || typeof e.state !== "string" || !(HANDOFF_EVENTS as readonly string[]).includes(e.state)) throw new HandoffRefusal("invalid", "handoff event is malformed");
1597
+ const row = this.d.board.handoffOutboundFor(e.projectId, e.remoteTask as number);
1598
+ if (!row) throw new HandoffRefusal("invalid", "no handoff of ours is recorded for that task");
1599
+ const pr = Number.isSafeInteger(e.pr) && (e.pr as number) > 0 ? e.pr as number : undefined, head = typeof e.head === "string" && /^[0-9a-f]{40}$/.test(e.head) ? e.head : undefined;
1600
+ if (row.state === e.state && (pr === undefined || pr === row.pr) && (head === undefined || head === row.head)) return { recorded: false };
1601
+ this.d.board.recordHandoffOutbound({ remoteProject: row.remoteProject, remoteTask: row.remoteTask, originPeer: row.originPeer, ...(row.localTask === null ? {} : { localTask: row.localTask }), title: row.title, state: e.state, ...(pr === undefined ? {} : { pr }), ...(head === undefined ? {} : { head }) });
1602
+ const body = followBackLine(row.remoteTask, e.state as HandoffEvent, { pr, head });
1603
+ if (row.originPeer !== USER && row.originPeer !== HUB) {
1604
+ try { this.d.bus.publish(newEnvelope(HUB, body, { to: [row.originPeer], kind: "task", priority: "important", refs: { task: String(row.localTask ?? row.remoteTask) } })); }
1605
+ catch { /* never throw after a board write */ }
1606
+ }
1607
+ return { recorded: true };
1608
+ }
1609
+
1610
+ /**
1611
+ * TARGET side, catch-up (#1030 design 4): the origin asks for the current state of the tasks it handed us, by id, after it was down. Answers only for tasks whose recorded origin IS the asking
1612
+ * project, and only with the same fields a live event carries (the event name, a PR number, a full head), so an origin learns nothing it could not have been told live.
1613
+ */
1614
+ handoffStatesFor(projectId: unknown, taskIds: unknown): { task: number; state: HandoffEvent | "proposed"; pr?: number; head?: string }[] {
1615
+ if (typeof projectId !== "string" || !projectId || projectId.length > 200 || !Array.isArray(taskIds) || taskIds.length > 200 || !taskIds.every(id => Number.isSafeInteger(id) && id > 0)) throw new HandoffRefusal("invalid", "handoff state request is malformed");
1616
+ const out: { task: number; state: HandoffEvent | "proposed"; pr?: number; head?: string }[] = [];
1617
+ for (const id of taskIds as number[]) {
1618
+ const origin = this.d.board.handoffOrigin(id), task = origin?.projectId === projectId ? this.d.board.get(id) : undefined;
1619
+ if (!task) continue;
1620
+ const entry = task.history.findLast(h => handoffEventFor(h.event, task.state) !== undefined);
1621
+ const state = entry ? handoffEventFor(entry.event, task.state) ?? "proposed" : "proposed";
1622
+ const pr = typeof task.sdlc?.pr === "number" && Number.isSafeInteger(task.sdlc.pr) && task.sdlc.pr > 0 ? task.sdlc.pr : undefined;
1623
+ const rawHead = task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead, head = typeof rawHead === "string" && /^[0-9a-f]{40}$/.test(rawHead) ? rawHead : undefined; // the owner's commit, else the receipt's head; only a full SHA crosses
1624
+ out.push({ task: id, state, ...(pr === undefined ? {} : { pr }), ...(head === undefined ? {} : { head }) });
1625
+ }
1626
+ return out;
1627
+ }
1628
+
1629
+ /** ORIGIN side: record what a target answered to the catch-up, and tell the origin peer about a change this project missed while it was down. */
1630
+ handoffCaughtUp(projectId: string, rows: { task: number; state: string; pr?: number; head?: string }[]): number {
1631
+ let changed = 0;
1632
+ for (const row of rows) {
1633
+ if (!(HANDOFF_EVENTS as readonly string[]).includes(row.state)) continue;
1634
+ try { if (this.handoffEvent({ projectId, remoteTask: row.task, state: row.state, ...(row.pr === undefined ? {} : { pr: row.pr }), ...(row.head === undefined ? {} : { head: row.head }) }).recorded) changed++; }
1635
+ catch { /* a row we hold no handoff for is not ours */ }
1636
+ }
1637
+ return changed;
1638
+ }
1639
+
1640
+ /** ORIGIN side, readback: what this project handed over, with the last state each target reported. */
1641
+ handoffList(): HandoffOutboundRow[] { return this.d.board.handoffOutbound(); }
1642
+
1643
+ async studyDraft(by: PeerId, input: unknown): Promise<ResearchCampaignReceipt> {
1644
+ const requestCheck = this.requestCheck(by, () => this.studyHold()); this.plannerWriteAuthority(by);
1645
+ const fields = this.studyFields(input, ["parentTaskId", "contract"]), parentId = this.studyNumber(fields.parentTaskId, "parentTaskId");
1646
+ const contract = this.studyContract(fields.contract);
1647
+ if (contract.units.some(unit => unit.round !== 0)) throw new PreEffectToolRefusal("an initial study contains only round zero");
1648
+ return this.studyWrite(async () => {
1649
+ this.plannerWriteAuthority(by, this.studyParent(parentId));
1650
+ const before = this.studyReferences(parentId, contract);
1651
+ const withheld = await this.withholds("note", JSON.stringify(contract), before.parent);
1652
+ requestCheck();
1653
+ const current = this.studyReferences(parentId, contract); this.plannerWriteAuthority(by, current.parent);
1654
+ if (current.key !== before.key) throw new PreEffectToolRefusal("study references changed during draft screening");
1655
+ const record: ResearchCampaignRecord = { id: `rc-${randomBytes(6).toString("hex")}`, project: this.d.project, parentTaskId: parentId,
1656
+ revision: 1, status: "draft", contract, hash: campaignHash(contract), private: current.private || withheld, createdBy: by, createdAt: this.studyTime() };
1657
+ let saved: ResearchCampaignRecord;
1658
+ requestCheck(); this.plannerWriteAuthority(by, current.parent);
1659
+ try { saved = this.d.board.saveResearchCampaignDraft(record, undefined, current.parent.history.length); }
1660
+ catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "study draft was not saved"); }
1661
+ this.releaseNotice(`study ${saved.id} revision ${saved.revision} drafted`);
1662
+ return this.studyReceipt(saved);
1663
+ });
1664
+ }
1665
+
1666
+ async studyRevise(by: PeerId, input: unknown): Promise<ResearchCampaignReceipt> {
1667
+ const requestCheck = this.requestCheck(by, () => this.studyHold()); this.plannerWriteAuthority(by);
1668
+ const fields = this.studyFields(input, ["id", "revision", "contract"]), id = this.studyId(fields.id), revision = this.studyNumber(fields.revision, "revision");
1669
+ const contract = this.studyContract(fields.contract);
1670
+ return this.studyWrite(async () => {
1671
+ const original = this.studyLatest(id, revision);
1672
+ this.plannerWriteAuthority(by, this.studyParent(original.parentTaskId));
1673
+ const before = this.studyReferences(original.parentTaskId, contract, id);
1674
+ this.studyRoundCheck(original, contract);
1675
+ const withheld = await this.withholds("note", JSON.stringify(contract), before.parent);
1676
+ requestCheck();
1677
+ const latest = this.studyLatest(id, revision), current = this.studyReferences(latest.parentTaskId, contract, id);
1678
+ this.plannerWriteAuthority(by, current.parent);
1679
+ if (latest.hash !== original.hash || current.key !== before.key) throw new PreEffectToolRefusal("study changed during revision screening");
1680
+ this.studyRoundCheck(latest, contract);
1681
+ const next: ResearchCampaignRecord = { id, project: this.d.project, parentTaskId: latest.parentTaskId, revision: revision + 1, status: "draft",
1682
+ contract, hash: campaignHash(contract), private: latest.private || current.private || withheld, createdBy: by, createdAt: this.studyTime() };
1683
+ let saved: ResearchCampaignRecord;
1684
+ requestCheck(); this.plannerWriteAuthority(by, current.parent);
1685
+ try { saved = this.d.board.saveResearchCampaignDraft(next, revision, current.parent.history.length); }
1686
+ catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "study revision was not saved"); }
1687
+ this.releaseNotice(`study ${id} revision ${saved.revision} drafted`);
1688
+ return this.studyReceipt(saved);
1689
+ });
1690
+ }
1691
+
1692
+ async studyApprove(by: PeerId, input: unknown): Promise<ResearchCampaignReceipt> {
1693
+ const requestCheck = this.requestCheck(by, () => this.studyHold());
1694
+ if (by !== USER) throw new PreEffectToolRefusal("study approval requires the person");
1695
+ const fields = this.studyFields(input, ["id", "revision"]), id = this.studyId(fields.id), revision = this.studyNumber(fields.revision, "revision");
1696
+ return this.studyWrite(async () => {
1697
+ const original = this.studyRecord(id, revision), parent = this.studyParent(original.parentTaskId);
1698
+ if (original.status === "approved") {
1699
+ const privacy = this.studyReplayPrivacy(original, parent); requestCheck();
1700
+ const replay = this.d.board.activateCampaign(id, revision, this.d.project, by, this.studyTime(), [], parent.history.length, privacy);
1701
+ return this.studyReceipt(replay.campaign, true);
1702
+ }
1703
+ this.studyLatest(id, revision);
1704
+ if (original.status !== "draft" || parent.draft) throw new PreEffectToolRefusal("study approval requires a draft and an approved parent input");
1705
+ this.studyEligibleOwners(original.contract);
1706
+ const before = this.studyReferences(parent.id, original.contract, id);
1707
+ let priv = original.private || before.private || await this.withholds("note", JSON.stringify(original.contract), parent);
1708
+ const prepared: PreparedResearchUnit[] = [];
1709
+ for (const unit of original.contract.units) {
1710
+ requestCheck();
1711
+ const reused = before.reused.get(unit.id);
1712
+ if (reused) { prepared.push({ unitId: unit.id, reuse: { taskId: reused.id, flowRevision: reused.history.length } }); continue; }
1713
+ const adopted = before.adopted.get(unit.id);
1714
+ if (adopted) { prepared.push({ unitId: unit.id, adopt: { taskId: adopted.id, flowRevision: adopted.history.length } }); continue; }
1715
+ const title = `Study unit ${unit.id}`, detail = `Study: ${id} unit ${unit.id}\nQuestion: ${unit.question}\nPerspective: ${unit.perspective}\nRequired output: ${unit.requiredOutput}\nSources: ${unit.sourceIds.join(", ")}`;
1716
+ priv ||= await this.withholds("task", detail, parent);
1717
+ const refs = cleanRefs(parent.refs), signals = [...new Set([...this.signalsFor({ title, detail, refs }), ...parent.signals.filter(signal => ["pii", "pii_path", "urgent"].includes(signal))])];
1718
+ prepared.push({ unitId: unit.id, create: { title, detail, class: parent.class, refs, signals,
1719
+ ...(parent.profile ? { profile: parent.profile } : {}), ...(parent.profileClass ? { profileClass: parent.profileClass } : {}),
1720
+ } });
1721
+ }
1722
+ requestCheck();
1723
+ const latest = this.studyLatest(id, revision), current = this.studyReferences(parent.id, original.contract, id);
1724
+ if (latest.hash !== original.hash || current.key !== before.key) throw new PreEffectToolRefusal("study changed during approval screening");
1725
+ const now = this.studyTime(), deadline = Date.parse(original.contract.stopPolicy.deadline);
1726
+ if (deadline <= now || deadline - now > 90 * 86_400_000) throw new PreEffectToolRefusal("study deadline must be in the next ninety days at approval");
1727
+ priv ||= latest.private || current.private;
1728
+ if (priv && [...current.adopted.values(), ...current.reused.values()].some(task => !this.isPii(task)))
1729
+ throw new PreEffectToolRefusal("a private study cannot retain a public task without changing its authority");
1730
+ for (const unit of prepared) if (unit.create) {
1731
+ if (priv && !unit.create.signals?.includes("pii")) unit.create.signals = [...(unit.create.signals ?? []), "pii"];
1732
+ const planner = this.proposalPlanner({ ...parent, ...unit.create, signals: unit.create.signals ?? [], refs: unit.create.refs ?? {}, draft: undefined, state: "backlog", owner: null, reviewer: null });
1733
+ unit.create.planner = planner.planner; unit.create.plannerReason = planner.plannerReason;
1734
+ this.warmRepository({ refs: unit.create.refs ?? {}, plan: unit.create.plan });
1735
+ }
1736
+ const mode = this.d.coordination?.();
1737
+ let result: ReturnType<Board["activateCampaign"]>;
1738
+ requestCheck();
1739
+ try { result = this.d.board.activateCampaign(id, revision, this.d.project, by, now, prepared, current.parent.history.length, priv, { eligibleOwners: this.studyEligibleOwners(original.contract) }); }
1740
+ catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "study approval was not saved"); }
1741
+ for (const child of result.children) {
1742
+ this.incomingBacklog.add(child.id);
1743
+ if (mode !== undefined) this.coordinationModes.set(child.id, mode);
1744
+ }
1745
+ this.releaseNotice(`study ${id} revision ${revision} approved; ${result.bindings.length} units`);
1746
+ await this.studyReoffer(result.bindings.map(unit => unit.taskId));
1747
+ return this.studyReceipt(result.campaign, true);
1748
+ });
1749
+ }
1750
+
1751
+ /**
1752
+ * A unit held only because a newer draft existed lost its queued offer at delivery. Once a revision is approved, each retained unit task whose offer was dropped, that still has
1753
+ * the same owner and has not been accepted, gets a fresh offer through the one offer path (`sendTask`), which re-runs `studyDispatchCheck`. The approval is already saved: a
1754
+ * refusal here is a notice, never a failure of the approval.
1755
+ */
1756
+ private async studyReoffer(taskIds: readonly number[]): Promise<void> {
1757
+ for (const taskId of taskIds) {
1758
+ let owner: PeerId | undefined;
1759
+ try { owner = this.d.board.researchDroppedOffer(taskId); } catch (e) { this.releaseNotice(`task #${taskId}: study dropped-offer record is unreadable: ${e instanceof Error ? e.message : String(e)}`); continue; }
1760
+ if (owner === undefined) continue;
1761
+ const task = this.d.board.get(taskId);
1762
+ // The record names an owner that no longer holds the unproposed task: it is stale, not a pending offer.
1763
+ if (!task || task.owner !== owner || task.state !== "proposed") { this.d.board.clearResearchDroppedOffer(taskId); continue; }
1764
+ try {
1765
+ await this.sendTask(task, { owner, ...(task.reviewer ? { reviewer: task.reviewer } : {}), trace: [] });
1766
+ this.d.board.clearResearchDroppedOffer(taskId); // after the offer is published: a refusal keeps the record for the next approval
1767
+ } catch (e) { this.releaseNotice(`task ${this.publicTitle(task)}: could not re-offer after the study was approved: ${e instanceof Error ? e.message : String(e)}`); }
1768
+ }
1769
+ }
1770
+
1771
+ async studyClose(by: PeerId, input: unknown): Promise<ResearchCampaignReceipt> {
1772
+ const requestCheck = this.requestCheck(by, () => this.studyHold()); this.plannerWriteAuthority(by);
1773
+ const fields = this.studyFields(input, ["id", "revision", "reason"]), id = this.studyId(fields.id), revision = this.studyNumber(fields.revision, "revision");
1774
+ if (typeof fields.reason !== "string" || !fields.reason.trim() || Buffer.byteLength(fields.reason, "utf8") > 512) throw new PreEffectToolRefusal("study close needs a bounded reason");
1775
+ const reason = fields.reason;
1776
+ return this.studyWrite(async () => {
1777
+ const original = this.studyLatest(id, revision), parent = this.studyParent(original.parentTaskId);
1778
+ this.plannerWriteAuthority(by, parent);
1779
+ const withheld = await this.withholds("note", reason, parent);
1780
+ requestCheck();
1781
+ const latest = this.studyLatest(id, revision), currentParent = this.studyParent(latest.parentTaskId);
1782
+ this.plannerWriteAuthority(by, currentParent);
1783
+ if (latest.hash !== original.hash || currentParent.history.length !== parent.history.length) throw new PreEffectToolRefusal("study changed during close screening");
1784
+ const priv = this.studyReplayPrivacy(latest, currentParent) || withheld || !this.nameable(reason);
1785
+ let closed: ResearchCampaignRecord;
1786
+ requestCheck(); this.plannerWriteAuthority(by, currentParent);
1787
+ try { closed = this.d.board.closeCampaign(id, revision, this.d.project, by, this.studyTime(), reason, currentParent.history.length, priv); }
1788
+ catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "study close was not saved"); }
1789
+ this.releaseNotice(`study ${id} revision ${revision} closed`);
1790
+ return this.studyReceipt(closed);
1791
+ });
1792
+ }
1793
+
1794
+ private experimentHash(input: unknown): string { return createHash("sha256").update(canonicalJson(input)).digest("hex"); }
1795
+
1796
+ private experimentId(input: unknown): string {
1797
+ if (typeof input !== "string" || !/^ra-[a-f0-9]{12}$/.test(input)) throw new PreEffectToolRefusal("experiment identity is invalid");
1798
+ return input;
1799
+ }
1800
+
1801
+ private experimentProtocol(hash: string): ExperimentProtocolRecord {
1802
+ try {
1803
+ const record = this.d.board.experimentProtocol(hash);
1804
+ if (record?.project === this.d.project) return record;
1805
+ } catch { /* damaged records are never returned as evidence */ }
1806
+ throw new PreEffectToolRefusal("experiment protocol is unavailable in this project");
1807
+ }
1808
+
1809
+ private experimentAttempt(id: string): ExperimentAttemptRecord {
1810
+ try {
1811
+ const record = this.d.board.experimentAttempt(id);
1812
+ if (record?.project === this.d.project) return record;
1813
+ } catch { /* fail closed without including any stored body */ }
1814
+ throw new PreEffectToolRefusal("experiment attempt is unavailable in this project");
1815
+ }
1816
+
1817
+ private experimentContext(by: PeerId, taskId: number, protocol: ExperimentProtocolContract, recordedForTaskId = taskId): ExperimentContext {
1818
+ const task = this.studyParent(taskId), bound = this.d.board.researchDispatchBinding(taskId);
1819
+ if (task.owner !== by || task.state !== "in_progress") throw new PreEffectToolRefusal("experiment records require the current in-progress task owner");
1820
+ if (this.d.privateTurn?.(by) && !this.isPii(task)) throw new PreEffectToolRefusal("a private turn cannot write experiment records on a public task");
1821
+ const campaign = bound?.campaign;
1822
+ // ponytail: new attempts against an older revision's protocol are refused; upgrade: explicit membership+source fence.
1823
+ if (!bound?.binding || !campaign || campaign.project !== this.d.project || campaign.id !== protocol.campaignId || campaign.revision !== protocol.campaignRevision || campaign.status !== "approved")
1824
+ throw new PreEffectToolRefusal("experiment task must be a unit of the protocol's current approved campaign revision");
1825
+ const recording = this.d.board.researchDispatchBinding(recordedForTaskId), origin = this.studyParent(recordedForTaskId);
1826
+ if (!recording?.binding || recording.campaign.id !== campaign.id || recording.campaign.revision !== campaign.revision || recording.campaign.status !== "approved")
1827
+ throw new PreEffectToolRefusal("experiment recording task is no longer a unit of this campaign");
1828
+ const sources = protocol.inputs.map(input => {
1829
+ const source = this.studySource(input.sourceId);
1830
+ if (!campaign.contract.sourceIds.includes(input.sourceId) || !source || source.sha256 !== input.sha256) throw new PreEffectToolRefusal("experiment input source identity or hash is unavailable in this campaign");
1831
+ return source;
1832
+ });
1833
+ let artifactPrivate = false;
1834
+ if ("deliverable" in protocol.code) {
1835
+ const reference = protocol.code.deliverable, artifact = latestDeliverables(origin.history)?.find(value => value.id === reference.id && value.sha256 === reference.sha256);
1836
+ if (!artifact) throw new PreEffectToolRefusal("experiment code deliverable identity is not recorded for its task");
1837
+ artifactPrivate = artifact.private === true;
1838
+ }
1839
+ const privateRecord = this.studyReplayPrivacy(campaign, this.studyParent(campaign.parentTaskId)) || this.isPii(task) || this.isPii(origin) || artifactPrivate || sources.some(source => source.private);
1840
+ const fence: ExperimentWriteFence = { project: this.d.project, taskId, taskFlowRevision: task.history.length, owner: by,
1841
+ campaignId: campaign.id, campaignRevision: campaign.revision, campaignHash: campaign.hash };
1842
+ return { task, campaign, private: privateRecord, fence, key: JSON.stringify([fence, sources, privateRecord, origin.history.length, recording.binding]) };
1843
+ }
1844
+
1845
+ private experimentFresh(before: ExperimentContext, by: PeerId, protocol: ExperimentProtocolContract, recordedForTaskId?: number): ExperimentContext {
1846
+ const current = this.experimentContext(by, before.task.id, protocol, recordedForTaskId);
1847
+ if (current.key !== before.key) throw new PreEffectToolRefusal("experiment authority, source or campaign changed during admission");
1848
+ return current;
1849
+ }
1850
+
1851
+ private async experimentScreen(text: string, task: Task): Promise<void> {
1852
+ if (!this.isPii(task) && (!this.nameable(text) || looksSecret(text) || await this.withholds("note", text, task)))
1853
+ throw new PreEffectToolRefusal("experiment text was not cleared; nothing was recorded");
1854
+ }
1855
+
1856
+ private experimentPlanContext(by: PeerId, taskId: number, plan: ComparisonPlanV1): ExperimentContext {
1857
+ const task = this.studyParent(taskId), bound = this.d.board.researchDispatchBinding(taskId), campaign = bound?.campaign;
1858
+ if (task.owner !== by || task.state !== "in_progress" || !bound?.binding || !campaign || campaign.project !== this.d.project
1859
+ || campaign.id !== plan.campaignId || campaign.status !== "approved")
1860
+ throw new PreEffectToolRefusal("comparison plans require the current in-progress campaign unit owner");
1861
+ if (this.d.privateTurn?.(by) && !this.isPii(task)) throw new PreEffectToolRefusal("a private turn cannot write comparison plans on a public task");
1862
+ const sources = plan.fixed.inputs.map(input => {
1863
+ const source = this.studySource(input.sourceId);
1864
+ if (!campaign.contract.sourceIds.includes(input.sourceId) || !source || source.sha256 !== input.sha256)
1865
+ throw new PreEffectToolRefusal("comparison input source identity or hash is unavailable in this campaign");
1866
+ return source;
1867
+ });
1868
+ const privateRecord = this.studyReplayPrivacy(campaign, this.studyParent(campaign.parentTaskId)) || this.isPii(task) || sources.some(source => source.private);
1869
+ const fence: ExperimentWriteFence = { project: this.d.project, taskId, taskFlowRevision: task.history.length, owner: by,
1870
+ campaignId: campaign.id, campaignRevision: campaign.revision, campaignHash: campaign.hash };
1871
+ return { task, campaign, private: privateRecord, fence, key: canonicalJson([fence, privateRecord, sources, bound.binding]) };
1872
+ }
1873
+
1874
+ private experimentApproved(record: ExperimentProtocolRecord): boolean {
1875
+ const approval = this.d.board.experimentProtocolApproval(record.hash);
1876
+ return protocolApprovalBindingMatches(record.protocol, approval && { protocolHash: approval.protocolHash, evaluatorHash: approval.evaluatorHash });
1877
+ }
1878
+
1879
+ /** Approval is an explicit person's decision about immutable bytes, including historical protocols. */
1880
+ async experimentApproveProtocol(by: PeerId, input: unknown): Promise<ProtocolApprovalReceipt> {
1881
+ const requestCheck = this.requestCheck(by, () => this.studyHold());
1882
+ if (by !== USER) throw new PreEffectToolRefusal("protocol approval is person only");
1883
+ const fields = this.studyFields(input, ["hash"]);
1884
+ if (typeof fields.hash !== "string" || fields.hash.length !== 64 || !/^[a-f0-9]{64}$/.test(fields.hash)) throw new PreEffectToolRefusal("protocol approval needs an exact protocol hash");
1885
+ const hash = fields.hash;
1886
+ return this.studyWrite(async () => {
1887
+ requestCheck();
1888
+ const record = this.experimentProtocol(hash), evaluatorHash = evaluatorIdentityHash(record.protocol);
1889
+ requestCheck();
1890
+ let saved;
1891
+ try { saved = this.d.board.approveExperimentProtocol(this.d.project, hash, evaluatorHash, by, this.studyTime()); }
1892
+ catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "protocol approval was not saved"); }
1893
+ return { protocolHash: saved.protocolHash, evaluatorHash: saved.evaluatorHash, approvedBy: saved.approvedBy, approvedAt: saved.at };
1894
+ });
1895
+ }
1896
+
1897
+ /** Record evidence only: this path never runs a check, a shell command or a runner. */
1898
+ async experimentRecord(by: PeerId, input: { kind: "plan"; [key: string]: unknown }): Promise<ComparisonPlanReceipt>;
1899
+ async experimentRecord(by: PeerId, input: { kind: "protocol" | "attempt" | "event" | "receipt"; [key: string]: unknown }): Promise<ExperimentReceipt>;
1900
+ async experimentRecord(by: PeerId, input: unknown): Promise<ExperimentReceipt | ComparisonPlanReceipt>;
1901
+ async experimentRecord(by: PeerId, input: unknown): Promise<ExperimentReceipt | ComparisonPlanReceipt> {
1902
+ const requestCheck = this.requestCheck(by, () => this.studyHold());
1903
+ const envelope = this.studyFields(input, ["kind", "taskId", "protocol", "attempt", "attemptId", "idempotencyKey", "event", "plan", "receipt", "signature"]);
1904
+ return this.studyWrite(async () => {
1905
+ try {
1906
+ if (envelope.kind === "receipt") {
1907
+ const fields = this.studyFields(input, ["kind", "receipt", "signature"]), receipt = parseAttemptReceipt(fields.receipt);
1908
+ const attempt = this.experimentAttempt(receipt.attemptId), record = this.experimentProtocol(attempt.attempt.protocolHash);
1909
+ const relayOwner = by === USER ? this.studyParent(attempt.attempt.taskId).owner : by;
1910
+ if (!relayOwner) throw new PreEffectToolRefusal("receipt relay requires a current task owner");
1911
+ const before = this.experimentContext(relayOwner, attempt.attempt.taskId, record.protocol, record.recordedForTaskId);
1912
+ requestCheck();
1913
+ const proof = (stored: NonNullable<ReturnType<Board["runnerReceipt"]>>): ExperimentReceipt => ({ kind: "receipt", protocolHash: record.hash,
1914
+ attemptId: attempt.attemptId, seq: stored.seq, receiptId: receipt.receiptId });
1915
+ const known = this.d.board.runnerReceipt(receipt.receiptId);
1916
+ if (known) {
1917
+ if (canonicalJson(known.receipt) !== canonicalJson(receipt) || known.signature !== fields.signature)
1918
+ throw new PreEffectToolRefusal("receiptId already records different signed bytes");
1919
+ this.experimentFresh(before, relayOwner, record.protocol, record.recordedForTaskId); requestCheck();
1920
+ return proof(known); // immutable stored proof: do not re-verify against a revoked/replaced registration or write on replay
1921
+ }
1922
+ const registrations = parseResearchRunners(this.d.researchRunners?.() ?? {}), registrationKey = canonicalJson(registrations);
1923
+ const context = { projectId: this.d.project, attempt: { id: attempt.attemptId, projectId: attempt.project, protocolHash: attempt.attempt.protocolHash,
1924
+ ...("commit" in record.protocol.code ? { codeCommit: record.protocol.code.commit } : {}) } };
1925
+ verifyAttemptReceipt(receipt, fields.signature, { ...context, runners: registrations });
1926
+ const lastSeq = this.d.board.experimentLastSeq(attempt.attemptId);
1927
+ await this.experimentScreen(canonicalJson(receipt), before.task); requestCheck();
1928
+ const current = this.experimentFresh(before, relayOwner, record.protocol, record.recordedForTaskId);
1929
+ const latest = parseResearchRunners(this.d.researchRunners?.() ?? {});
1930
+ if (canonicalJson(latest) !== registrationKey) throw new PreEffectToolRefusal("runner registration changed during receipt admission");
1931
+ verifyAttemptReceipt(receipt, fields.signature, { ...context, runners: latest }); requestCheck();
1932
+ const saved = this.d.board.appendRunnerReceipt({ receipt, signature: fields.signature as string, publicKey: latest[receipt.runner]!.publicKey,
1933
+ by, at: this.studyTime(), private: current.private || record.private || attempt.private }, lastSeq, current.fence);
1934
+ return proof(saved);
1935
+ }
1936
+ if (envelope.kind === "plan") {
1937
+ const fields = this.studyFields(input, ["kind", "taskId", "plan"]), taskId = this.studyNumber(fields.taskId, "taskId"), plan = parseComparisonPlan(fields.plan);
1938
+ const before = this.experimentPlanContext(by, taskId, plan);
1939
+ await this.experimentScreen(canonicalJson(plan), before.task); requestCheck();
1940
+ const current = this.experimentPlanContext(by, taskId, plan);
1941
+ if (current.key !== before.key) throw new PreEffectToolRefusal("comparison authority, source or campaign changed during admission");
1942
+ requestCheck();
1943
+ const saved = this.d.board.saveExperimentComparisonPlan({ hash: comparisonPlanHash(plan), project: this.d.project, recordedForTaskId: taskId,
1944
+ plan, by, at: this.studyTime(), private: current.private }, current.fence);
1945
+ return { kind: "plan", planHash: saved.hash };
1946
+ }
1947
+ if (envelope.kind === "protocol") {
1948
+ const fields = this.studyFields(input, ["kind", "taskId", "protocol"]), taskId = this.studyNumber(fields.taskId, "taskId"), protocol = parseExperimentProtocol(fields.protocol);
1949
+ const before = this.experimentContext(by, taskId, protocol);
1950
+ for (const reference of [protocol.variantOf, protocol.supersedes]) if (reference) {
1951
+ const previous = this.experimentProtocol(reference);
1952
+ if (previous.protocol.campaignId !== protocol.campaignId || reference === protocol.supersedes && previous.protocol.lineage !== protocol.lineage)
1953
+ throw new PreEffectToolRefusal("experiment protocol reference belongs to another campaign or lineage");
1954
+ }
1955
+ await this.experimentScreen(canonicalJson(protocol), before.task); requestCheck();
1956
+ const current = this.experimentFresh(before, by, protocol); requestCheck();
1957
+ const saved = this.d.board.saveExperimentProtocol({ hash: protocolHash(protocol), project: this.d.project, recordedForTaskId: taskId, protocol, by, at: this.studyTime(), private: current.private }, current.fence);
1958
+ return { kind: "protocol", protocolHash: saved.hash };
1959
+ }
1960
+ if (envelope.kind === "attempt") {
1961
+ const fields = this.studyFields(input, ["kind", "attempt"]), attempt = parseExperimentAttempt(fields.attempt), record = this.experimentProtocol(attempt.protocolHash);
1962
+ const before = this.experimentContext(by, attempt.taskId, record.protocol, record.recordedForTaskId);
1963
+ for (const reference of [attempt.retryOf, attempt.reproducesAttemptId]) if (reference) {
1964
+ const previous = this.experimentAttempt(reference), protocol = this.experimentProtocol(previous.attempt.protocolHash);
1965
+ if (protocol.protocol.campaignId !== record.protocol.campaignId) throw new PreEffectToolRefusal("attempt reference belongs to another campaign");
1966
+ }
1967
+ await this.experimentScreen(canonicalJson(attempt), before.task); requestCheck();
1968
+ const current = this.experimentFresh(before, by, record.protocol, record.recordedForTaskId); requestCheck();
1969
+ const saved = this.d.board.createExperimentAttempt({ attemptId: `ra-${randomBytes(6).toString("hex")}`, project: this.d.project, attempt, bodyHash: this.experimentHash(attempt), by, at: this.studyTime(), private: current.private || record.private }, current.fence);
1970
+ return { kind: "attempt", protocolHash: saved.attempt.protocolHash, attemptId: saved.attemptId };
1971
+ }
1972
+ if (envelope.kind === "event") {
1973
+ const fields = this.studyFields(input, ["kind", "attemptId", "idempotencyKey", "event"]), id = this.experimentId(fields.attemptId), key = fields.idempotencyKey;
1974
+ if (typeof key !== "string" || !key.trim() || key.length > 64 || Buffer.byteLength(key, "utf8") > 64 || Buffer.from(key, "utf8").toString("utf8") !== key) throw new PreEffectToolRefusal("event idempotencyKey needs at most 64 UTF-8 bytes");
1975
+ const attempt = this.experimentAttempt(id), record = this.experimentProtocol(attempt.attempt.protocolHash), event = parseExperimentEvent(fields.event, record.protocol);
1976
+ const before = this.experimentContext(by, attempt.attempt.taskId, record.protocol, record.recordedForTaskId), lastSeq = this.d.board.experimentLastSeq(id);
1977
+ await this.experimentScreen(canonicalJson([key, event]), before.task); requestCheck();
1978
+ this.experimentFresh(before, by, record.protocol, record.recordedForTaskId);
1979
+ const observations: ExperimentHostEvidence[] = [];
1980
+ let outputPrivate = event.kind === "output" && "path" in event.body ? this.experimentOutputPrivate(before.task, event.body.path) : false;
1981
+ if (event.kind === "end" && event.body.run && this.d.experimentCheck) {
1982
+ let receipt: unknown;
1983
+ try { receipt = await this.d.experimentCheck(event.body.run); } catch { /* unavailable is unknown */ }
1984
+ requestCheck(); this.experimentFresh(before, by, record.protocol, record.recordedForTaskId);
1985
+ const observation = checkRunObservation(receipt, event.body.run, before.task.id, record.protocol);
1986
+ if (observation) observations.push(observation);
1987
+ } else if (event.kind === "output" && "path" in event.body) {
1988
+ try {
1989
+ const measured = await hashDeliverables([event.body], this.deliverablePorts(before.task));
1990
+ const output = measured[0];
1991
+ if (output) { observations.push(deliverableObservation(output)); outputPrivate ||= output.private === true; }
1992
+ } catch { /* #509 refusal, missing or unreadable means unknown; never use a declared fallback hash */ }
1993
+ requestCheck(); this.experimentFresh(before, by, record.protocol, record.recordedForTaskId);
1994
+ }
1995
+ const current = this.experimentFresh(before, by, record.protocol, record.recordedForTaskId); requestCheck();
1996
+ if (event.kind === "output" && "path" in event.body) outputPrivate ||= this.experimentOutputPrivate(current.task, event.body.path);
1997
+ const saved = this.d.board.appendExperimentEvent(id, key, event, observations, by, this.studyTime(), current.private || record.private || attempt.private || outputPrivate, lastSeq, current.fence);
1998
+ return { kind: "event", protocolHash: record.hash, attemptId: id, seq: saved.event.seq };
1999
+ }
2000
+ throw new PreEffectToolRefusal("unsupported experiment record kind");
2001
+ } catch (error) {
2002
+ if (error instanceof PreEffectToolRefusal) throw error;
2003
+ throw new PreEffectToolRefusal(error instanceof Error ? error.message : "experiment record was not saved");
2004
+ }
2005
+ });
2006
+ }
2007
+
2008
+ private runnerKeyStatus(stored: NonNullable<ReturnType<Board["runnerReceipt"]>>): "active" | "revoked" | "replaced" | "unavailable" {
2009
+ try {
2010
+ const runners = parseResearchRunners(this.d.researchRunners?.() ?? {});
2011
+ if (!Object.hasOwn(runners, stored.receipt.runner)) return "unavailable";
2012
+ const current = runners[stored.receipt.runner]!;
2013
+ if (current.publicKey !== stored.publicKey) return "replaced";
2014
+ if (current.revoked) return "revoked";
2015
+ return current.projects.includes(stored.receipt.projectId) ? "active" : "unavailable";
2016
+ } catch { return "unavailable"; }
2017
+ }
2018
+
2019
+ private experimentPrivate(record: ExperimentProtocolRecord, attempt: ExperimentAttemptRecord): boolean {
2020
+ try {
2021
+ const campaign = this.d.board.researchCampaign(record.protocol.campaignId, record.protocol.campaignRevision), task = this.d.board.get(attempt.attempt.taskId);
2022
+ return record.private || attempt.private || !campaign || this.studyReplayPrivacy(campaign, this.studyParent(campaign.parentTaskId)) || !task || this.isPii(task)
2023
+ || record.protocol.inputs.some(input => { const source = this.studySource(input.sourceId); return !source || source.private; });
2024
+ } catch { return true; }
2025
+ }
2026
+
2027
+ async experimentShow(by: PeerId, input: unknown): Promise<ExperimentView> {
2028
+ return this.experimentRead(by, input);
2029
+ }
2030
+
2031
+ private experimentRead(by: PeerId, input: unknown): ExperimentView {
2032
+ requireLocalTaskPeer(by);
2033
+ const fields = this.studyFields(input, ["attemptId", "campaignId", "after", "eventAfter", "limit", "planHash", "baselineAttemptId", "variantAttemptId"]);
2034
+ const cursor = (value: unknown, name: string) => value === undefined ? 0 : typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : (() => { throw new PreEffectToolRefusal(`${name} must be a nonnegative integer`); })();
2035
+ const after = cursor(fields.after, "after"), eventAfter = cursor(fields.eventAfter, "eventAfter"), limit = fields.limit === undefined ? 50 : this.studyNumber(fields.limit, "limit");
2036
+ if (limit > 50) throw new PreEffectToolRefusal("experiment readback is limited to 50 rows");
2037
+ if (fields.planHash !== undefined || fields.baselineAttemptId !== undefined || fields.variantAttemptId !== undefined) {
2038
+ if (fields.attemptId !== undefined || fields.campaignId !== undefined || fields.after !== undefined
2039
+ || typeof fields.planHash !== "string" || fields.planHash.length !== 64 || !/^[a-f0-9]{64}$/.test(fields.planHash))
2040
+ throw new PreEffectToolRefusal("comparison readback needs one plan hash and two attempt ids");
2041
+ const plan = this.d.board.experimentComparisonPlan(fields.planHash);
2042
+ if (!plan || plan.project !== this.d.project) throw new PreEffectToolRefusal("comparison plan is unavailable in this project");
2043
+ const baseline = this.experimentAttempt(this.experimentId(fields.baselineAttemptId)), variant = this.experimentAttempt(this.experimentId(fields.variantAttemptId));
2044
+ const first = this.experimentProtocol(baseline.attempt.protocolHash), second = this.experimentProtocol(variant.attempt.protocolHash);
2045
+ if (first.protocol.campaignId !== plan.plan.campaignId || second.protocol.campaignId !== plan.plan.campaignId)
2046
+ throw new PreEffectToolRefusal("comparison attempts must belong to the plan's campaign");
2047
+ const left = this.experimentRead(by, { attemptId: baseline.attemptId, eventAfter, limit });
2048
+ const right = this.experimentRead(by, { attemptId: variant.attemptId, eventAfter, limit });
2049
+ let planPrivate = true;
2050
+ try {
2051
+ const campaign = this.studyRecord(plan.plan.campaignId);
2052
+ planPrivate = plan.private || this.studyReplayPrivacy(campaign, this.studyParent(campaign.parentTaskId))
2053
+ || plan.plan.fixed.inputs.some(input => { const source = this.studySource(input.sourceId); return !source || source.private; });
2054
+ } catch { /* an unavailable privacy classification withholds the comparison */ }
2055
+ const privateRecord = planPrivate || left.attempts.some(row => row.private) || right.attempts.some(row => row.private);
2056
+ return { attempts: [...left.attempts, ...right.attempts], counts: left.counts,
2057
+ comparison: { planHash: plan.hash, baselineAttemptId: baseline.attemptId, variantAttemptId: variant.attemptId,
2058
+ approved: this.experimentApproved(first) && this.experimentApproved(second), private: privateRecord,
2059
+ projection: privateRecord ? "[pii]" : comparisonProjection(plan.plan, first.protocol, second.protocol) } };
2060
+ }
2061
+ let campaignId: string, attempts: ExperimentAttemptRecord[], nextCursor: number | undefined;
2062
+ if (fields.attemptId !== undefined) {
2063
+ if (fields.campaignId !== undefined || fields.after !== undefined) throw new PreEffectToolRefusal("choose one attempt or campaign page");
2064
+ const attempt = this.experimentAttempt(this.experimentId(fields.attemptId));
2065
+ campaignId = this.experimentProtocol(attempt.attempt.protocolHash).protocol.campaignId; attempts = [attempt];
2066
+ } else {
2067
+ campaignId = this.studyId(fields.campaignId); this.studyRecord(campaignId);
2068
+ const page = this.d.board.experimentAttempts(this.d.project, campaignId, after, limit); attempts = page.rows;
2069
+ if (!page.done) nextCursor = page.afterRow;
2070
+ }
2071
+ const summary = this.d.board.experimentSummary(this.d.project, campaignId), views: ExperimentAttemptView[] = [];
2072
+ for (const attempt of attempts) {
2073
+ const record = this.experimentProtocol(attempt.attempt.protocolHash), page = this.d.board.experimentEvents(attempt.attemptId, eventAfter, limit);
2074
+ const runnerReceipts = this.d.board.runnerReceipts(attempt.attemptId);
2075
+ const privateRecord = this.experimentPrivate(record, attempt) || page.rows.some(row => row.private)
2076
+ || runnerReceipts.some(row => row.private || !this.nameable(canonicalJson(row.receipt)) || looksSecret(canonicalJson(row.receipt)));
2077
+ const status = this.d.board.experimentAttemptStatus(attempt.attemptId);
2078
+ const observations = page.rows.flatMap(row => row.observations), events = page.rows.map(row => row.event);
2079
+ const evidence = { ...projectExperimentWithObservations(record.protocol, events, observations),
2080
+ ...(runnerReceipts.length ? { runnerAttested: runnerReceipts.map(row => ({ ...projectRunnerReceipt(record.protocol, row.receipt), key: { hash: row.keyHash, status: this.runnerKeyStatus(row) } })) } : {}) };
2081
+ views.push({ id: attempt.attemptId, protocolHash: record.hash, taskId: attempt.attempt.taskId, phase: attempt.attempt.phase, status, private: privateRecord,
2082
+ campaignRevision: record.protocol.campaignRevision, approval: this.experimentApproved(record) ? "approved" : "unapproved",
2083
+ postHoc: this.d.board.experimentProtocolContext(record.hash).state,
2084
+ ...(attempt.attempt.retryOf ? { retryOf: attempt.attempt.retryOf } : {}), ...(attempt.attempt.reproducesAttemptId ? { reproducesAttemptId: attempt.attempt.reproducesAttemptId } : {}), independentlyReproduced: false,
2085
+ protocol: { value: privateRecord ? "[pii]" : record.protocol, provenance: "declared" },
2086
+ events: page.rows.map(row => privateRecord ? { seq: row.event.seq, kind: row.event.kind, at: row.event.at, provenance: row.event.provenance, body: "[pii]" } : row.event),
2087
+ evidence: privateRecord ? { declared: "[pii]", measured: evidence.measured.map(row => ({ eventSeq: row.eventSeq, source: row.source, provenance: "host-observed", values: "[pii]" })), ...(runnerReceipts.length ? { runnerAttested: runnerReceipts.map(() => ({ provenance: "runner-attested", values: "[pii]" })) } : {}) } : evidence,
2088
+ eventsComplete: page.done, ...(!page.done ? { nextEventCursor: page.afterSeq } : {}) });
2089
+ }
2090
+ return { attempts: views, counts: { all: summary.total, statuses: summary.statuses }, ...(nextCursor === undefined ? {} : { nextCursor }) };
2091
+ }
2092
+
1229
2093
  /** Every new host proposal, including split children, consumes the same pure walk before its first notification. */
1230
2094
  private proposalPlanner(task: Task): PlannerBinding {
1231
2095
  const choice = assign({ ...task, state: "proposed" }, this.states(), this.d.routing(), { ...this.weights(task.class, task), candidates: [] });
@@ -1449,7 +2313,7 @@ export class Tasks {
1449
2313
  const states = this.states();
1450
2314
  const full = this.plannerFull(task);
1451
2315
  // Preview the To Do transition without routing or offering the persisted backlog row.
1452
- 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, projectedHealth), ...(queueOverride ? { queueOverride, contextOverride: true } : {}) });
2316
+ return assign({ ...task, state: "proposed" }, states, routing, { exclude: this.excluded(task), excludeOwners: [...full, ...this.studyExcludedOwners(task)], ...(reservedOnly && task.reserved ? { candidates: [task.reserved], namedOwner: task.reserved } : {}), waitsFor: this.waitsFor(task), ...this.weights(task.class, task, projectedHealth), ...(queueOverride ? { queueOverride, contextOverride: true } : {}) });
1453
2317
  }
1454
2318
 
1455
2319
  async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }, opts: { onBehalfOf?: PeerId; guard?: () => void } = {}): Promise<Task> {
@@ -1491,6 +2355,7 @@ export class Tasks {
1491
2355
  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);
1492
2356
  throw new PreEffectToolRefusal(`${this.hasPersonReservation(task) ? "a person's reservation is retained; " : ""}${refusal}`);
1493
2357
  }
2358
+ this.studyDispatchCheck(task, assignment.owner);
1494
2359
  return { task, owner: assignment.owner, assignment };
1495
2360
  };
1496
2361
  const initial = validate(), withheld = await this.withholds("note", input.reason, initial.task);
@@ -1505,6 +2370,7 @@ export class Tasks {
1505
2370
  } } });
1506
2371
  }
1507
2372
  opts.guard?.();
2373
+ this.studyDispatchCheck(current.task, current.owner);
1508
2374
  const pulled = this.d.board.pullPlanned(current.task.id, by, current.owner, input.revision, input.reason.trim(), withheld, this.plannerBinding(current.task, current.assignment, true));
1509
2375
  this.planningRecorded({ by, task: pulled.id, fields: ["pull", "reserved"], revision: pulled.planningRevision! });
1510
2376
  return pulled;
@@ -2489,6 +3355,12 @@ export class Tasks {
2489
3355
  }
2490
3356
  if (env.from === HUB && env.refs?.task) {
2491
3357
  const task = this.d.board.get(Number(env.refs.task));
3358
+ if (env.kind === "task" && env.refs.taskOffer !== undefined && task?.owner === peer && this.studyDispatchReason(task, peer)) {
3359
+ // The bus discards a false verdict for good and cannot hold the envelope. Remember the owner it was for (on the board, so it survives a restart), so approving the study again offers the retained task afresh.
3360
+ try { this.d.board.recordResearchDroppedOffer(task.id, peer); }
3361
+ catch (e) { this.releaseNotice(`task ${this.publicTitle(task)}: could not record the dropped study offer: ${e instanceof Error ? e.message : String(e)}`); }
3362
+ return false;
3363
+ }
2492
3364
  if (task?.state === "cancelled") return env.kind === "task" && typeof env.refs.taskCancelled === "number" && env.refs.taskCancelled === task.cancelled?.flowRevision;
2493
3365
  if (env.refs.taskRecipient === "planner") return !!task && this.taskPlanner(task).includes(peer);
2494
3366
  if (env.refs.planReview !== undefined) return !!task && !isClosedTaskState(task.state) && (task.planningRevision ?? 0) === env.refs.planReview
@@ -2728,6 +3600,12 @@ export class Tasks {
2728
3600
  private async assignOwner(task: Task, by: PeerId, opts: { current?: () => void; conductorManual?: true; queueOverride?: "owner"; 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> {
2729
3601
  opts.current?.();
2730
3602
  if (task.state === "backlog" && !opts.plannerPull) throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
3603
+ const studyReason = this.studyDispatchReason(task, opts.namedOwner);
3604
+ if (studyReason) {
3605
+ if (opts.refuseNamed || opts.plannerPull || opts.claim) throw new PreEffectToolRefusal(studyReason);
3606
+ if (task.state === "proposed" && !task.owner) this.holdReady(task, "study dispatch", studyReason);
3607
+ return task;
3608
+ }
2731
3609
  const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
2732
3610
  let stagedClaimGap: string | undefined;
2733
3611
  if (mode !== "off" && this.isBuildTask(task)) {
@@ -2755,7 +3633,7 @@ export class Tasks {
2755
3633
  const waits = this.waitsFor(task);
2756
3634
  const plannedMove = opts.planner || this.plannerPullRecord(task)?.pull === true && (by === HUB || opts.roster) && (opts.reason !== "manual" || opts.roster);
2757
3635
  const plannerFull = plannedMove ? this.plannerFull(task) : [];
2758
- 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 } : {}), // A self-claim hands over no envelope; reviewer admission remains automatic.
3636
+ 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, ...this.studyExcludedOwners(task)], ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class, task), ...(opts.namedOwner ? { namedOwner: opts.namedOwner } : {}), // A self-claim hands over no envelope; reviewer admission remains automatic.
2759
3637
  contextOverride: !!(opts.queueOverride || opts.claim),
2760
3638
  ...(opts.queueOverride || opts.claim ? { queueOverride: opts.queueOverride ?? "owner" as const } : {}) });
2761
3639
 
@@ -2795,6 +3673,7 @@ export class Tasks {
2795
3673
  const binding = this.plannerBinding(task, a, !!opts.plannerPull);
2796
3674
  const assignmentExtra = { ...this.reviewMetadata(a, task), ...(binding ? { plannerReason: binding.plannerReason } : {}), ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}), ...(opts.conductorManual ? { conductorManual: true as const } : {}) };
2797
3675
  opts.current?.();
3676
+ this.studyDispatchCheck(this.d.board.get(task.id) ?? task, a.owner);
2798
3677
  const next = opts.plannerPull
2799
3678
  ? 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, binding)
2800
3679
  : this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(binding ? { planner: binding.planner } : {}), ...(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);
@@ -2857,6 +3736,7 @@ export class Tasks {
2857
3736
  this.releaseNotice(this.specText(next, "missing", "advice"));
2858
3737
  }
2859
3738
  if (stagedClaimGap) return next; // An explicit claim may reserve its owner, but no offer or acceptance bypasses review.
3739
+ this.studyDispatchCheck(next, by);
2860
3740
  const claimed = this.d.board.update(next.id, by, "accepted", { state: "in_progress" });
2861
3741
  this.captureOwnerAcceptance(claimed);
2862
3742
  this.releaseNotice(`task ${this.publicTitle(claimed)} claimed by ${by}`);
@@ -2917,6 +3797,7 @@ export class Tasks {
2917
3797
  currentAuthority?.();
2918
3798
  const current = this.d.board.get(task.id);
2919
3799
  if (!current || !this.ownerAssignmentCurrent(task, current)) throw new AssignmentSupersededError(current ?? task);
3800
+ this.studyDispatchCheck(current, current.owner ?? undefined);
2920
3801
  task = current;
2921
3802
  const pii = this.isPii(task), delivery = { ...a, reviewer: task.reviewer ?? undefined };
2922
3803
  const rejected = task.history.filter((h) => h.event === "changes_requested").map((h) => `- ${h.by}: ${this.screen(task, h.note ?? "", "review note", delivery.owner, h.withheld)}`);
@@ -2945,8 +3826,10 @@ export class Tasks {
2945
3826
  const idle = this.d.bus.stateOf(owner) === "idle";
2946
3827
  const env = newEnvelope(HUB, body, { to: [owner], kind: "task", priority: "important", refs: { ...task.refs, task: String(task.id), taskRecipient: "owner", taskOffer: task.history.length }, ...(pii ? { private: true } : {}) });
2947
3828
  currentAuthority?.();
3829
+ this.studyDispatchCheck(task, owner);
2948
3830
  this.d.board.recordTaskOffer(task.id, { envelopeId: env.id, owner, assignmentFlowRevision: task.history.length, ...(this.d.offerPullOnly ? { pullOnly: this.d.offerPullOnly(owner) } : {}) }, task.history.length);
2949
3831
  currentAuthority?.();
3832
+ this.studyDispatchCheck(task, owner);
2950
3833
  this.d.bus.publish(env);
2951
3834
  if (idle && this.d.bus.stateOf(owner) === "busy" && !this.d.bus.queueIds(owner).includes(env.id)) this.sent.set(handOver(task, owner), owner);
2952
3835
  }
@@ -3767,7 +4650,7 @@ export class Tasks {
3767
4650
  for (const entry of entries) {
3768
4651
  const row = store.ensure(entry.id, now);
3769
4652
  if (row.paused) continue; // #335: a person's pause is neither a run nor a skipped tick
3770
- if (now < row.anchor + parseEvery(entry.every)) continue;
4653
+ if (now < scheduleNext(entry, row.anchor)) continue;
3771
4654
  out.push(await this.runSchedule(store, entry, now, extraHold));
3772
4655
  }
3773
4656
  } finally { this.schedulesRunning = false; }
@@ -4028,7 +4911,7 @@ export class Tasks {
4028
4911
 
4029
4912
  /** The board as the merge queue and the guarded merge read it (#786): ids, dependencies, privacy, receipts, findings and plans. */
4030
4913
  private deliveryLinks(config: SdlcConfig): DeliveryTask[] {
4031
- return this.d.board.list().map(t => ({ id: t.id, after: t.deps ?? [], private: this.isPii(t), receipt: t.sdlc, phase: this.isPii(t) ? undefined : this.workflowView(t), findings: this.isPii(t) ? undefined : this.reviewFindingSets(t), plan: config.mode === "off" || this.isPii(t) ? undefined : { paths: this.planPaths(t), revision: t.history.findLast(h => h.planRevision)?.planRevision?.revision ?? 0, fingerprint: planFingerprint(t.plan ?? {}), accepted: this.validPlanDepartures(t) } }));
4914
+ return this.d.board.list().map(t => ({ id: t.id, after: t.deps ?? [], private: this.isPii(t), latestHead: this.reviewHead(t), approval: approvalBinding(t), receipt: t.sdlc, phase: this.isPii(t) ? undefined : this.workflowView(t), findings: this.isPii(t) ? undefined : this.reviewFindingSets(t), plan: config.mode === "off" || this.isPii(t) ? undefined : { paths: this.planPaths(t), revision: t.history.findLast(h => h.planRevision)?.planRevision?.revision ?? 0, fingerprint: planFingerprint(t.plan ?? {}), accepted: this.validPlanDepartures(t) } }));
4032
4915
  }
4033
4916
 
4034
4917
  /**
@@ -4155,6 +5038,8 @@ export class Tasks {
4155
5038
  private mergeRecordCurrent(id:number,by:PeerId,record:MergeRecord,artifactCurrent:()=>boolean):Task|undefined {
4156
5039
  const now=this.d.board.get(id);
4157
5040
  return artifactCurrent() && now && now.sdlc?.repo===record.repo && now.sdlc.pr===record.pr && now.sdlc.reviewedHead===record.head
5041
+ && (!approvalBinding(now) || approvalBinding(now)!.current && approvalBinding(now)!.head === record.head)
5042
+ && (!this.reviewHead(now) || this.reviewHead(now) === record.head)
4158
5043
  && now.state==="approved" && !this.isPii(now) && !this.d.privateTurn?.(by) && this.mergeAuthority(by)
4159
5044
  && !this.d.bus.isRecoveryHeld && !this.d.bus.storageError ? now : undefined;
4160
5045
  }
@@ -4566,6 +5451,7 @@ export class Tasks {
4566
5451
  this.mine(task, by, "owner", true);
4567
5452
  this.ready(task, true);
4568
5453
  if (task.state !== "proposed" && task.state !== "changes_requested" && task.state !== "in_progress") throw new PreEffectToolRefusal(`task #${task.id} is ${task.state}: cannot move to in_progress`);
5454
+ this.studyDispatchCheck(task, task.owner ?? undefined);
4569
5455
  this.acceptanceCapacity(task);
4570
5456
  // S1 spec acceptance and S9 plan review are independent prerequisites; retain S1's missing-spec refusal first.
4571
5457
  if (this.specGated(task) && this.d.sdlc?.config().mode === "enforce" && specState(task.sdlc, undefined) === "missing") throw new PreEffectToolRefusal(this.specText(task, "missing", "blocked"));
@@ -4593,6 +5479,7 @@ export class Tasks {
4593
5479
  this.warmRepository(draft);
4594
5480
  const repositoryChanged = this.repositories.scope(task.refs).key !== this.repositories.scope(draft.refs).key;
4595
5481
  const before = new Set(this.overlapHits(task).map((h) => h.task.id));
5482
+ this.studyDispatchCheck(task, task.owner ?? undefined);
4596
5483
  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
4597
5484
  this.captureOwnerAcceptance(next);
4598
5485
  this.d.notify(`task ${this.publicTitle(next)} accepted by ${by}`);
@@ -4745,13 +5632,23 @@ export class Tasks {
4745
5632
  }
4746
5633
 
4747
5634
  /** What the deliverable reader needs from the host: the project root, the secrets denylist, and which files make a record private (#509a). */
4748
- private deliverablePorts(task: Task): DeliverablePorts {
5635
+ private deliverablePorts(task: Task, policy: { failClosedOnDiscoveryLimit?: boolean } = {}): DeliverablePorts {
4749
5636
  const taskPrivate = this.isPii(task);
4750
5637
  // The owner's own root: a declared worktree instance writes under its worktree, so its deliverables are hashed there, never in the canonical
4751
5638
  // checkout (which holds a different file or none). With the root unproven there is nothing safe to hash: refuse rather than guess.
4752
5639
  const root = !this.d.executionRoot || !task.owner || task.owner === USER ? this.d.cwd : this.d.executionRoot(task.owner);
4753
5640
  if (root === undefined) throw new DeliverableRefusal(`deliverables: ${task.owner}'s execution root is not verified right now, so its files cannot be hashed`);
4754
- return { cwd: root, deny: this.d.deny ?? [], private: path => taskPrivate || matchesPolicy(root, path, this.d.piiPaths ?? [], true) };
5641
+ return { cwd: root, deny: this.d.deny ?? [], private: path => taskPrivate || matchesPolicy(root, path, this.d.piiPaths ?? [], true, policy) };
5642
+ }
5643
+
5644
+ /** Path privacy exists even when measurement cannot open the output. Use the #509 policies on both names. */
5645
+ private experimentOutputPrivate(task: Task, path: string): boolean {
5646
+ try {
5647
+ const ports = this.deliverablePorts(task, { failClosedOnDiscoveryLimit: true }), root = realPath(ports.cwd), deny = [...ports.deny];
5648
+ const typed = relative(root, resolve(root, path)), actual = relative(root, policyRealPath(resolve(root, path)));
5649
+ return !insideRoot(typed) || !insideRoot(actual) || ports.private(path)
5650
+ || [typed, actual].some(name => isHubWritePath(name) || isDenied(name, deny) || isDenied(foldSegment(name), deny.map(foldSegment)));
5651
+ } catch { return true; }
4755
5652
  }
4756
5653
 
4757
5654
  /**
@@ -4782,26 +5679,73 @@ export class Tasks {
4782
5679
  /** Whether a completion check is still running for this task. */
4783
5680
  isChecking = (id: number) => this.checking.has(id);
4784
5681
 
4785
- private completionCurrent(id: unknown, by: PeerId, preEffect: boolean): Task {
5682
+ /** Invalidate stale artifact evidence without changing spec acceptance or open findings. */
5683
+ private completionDraft(task: Task, refresh?: Pick<CompletionRefresh, "commit">): Task {
5684
+ if (!refresh || !task.sdlc) return task;
5685
+ const sdlc = { ...task.sdlc };
5686
+ if (sdlc.head !== refresh.commit) { delete sdlc.head; delete sdlc.observed; }
5687
+ if (sdlc.reviewedHead !== refresh.commit) delete sdlc.reviewedHead;
5688
+ if (sdlc.localHead !== refresh.commit) { delete sdlc.localHead; delete sdlc.localResult; }
5689
+ delete sdlc.merge;
5690
+ return { ...task, sdlc };
5691
+ }
5692
+
5693
+ private completionCurrent(id: unknown, by: PeerId, preEffect: boolean, refresh?: CompletionRefresh): Task {
4786
5694
  const task = this.need(id, false, preEffect);
4787
5695
  this.mine(task, by, "owner", preEffect);
5696
+ if (refresh && (task.state !== refresh.state || task.history.length !== refresh.revision))
5697
+ throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: review changed during re-file; retry on current work`);
4788
5698
  if (preEffect) {
4789
- if (task.state === "in_review" || isClosedTaskState(task.state)) throw new PreEffectToolRefusal(`task #${task.id} is already ${task.state}`);
5699
+ if (task.state === "in_review" && !refresh || isClosedTaskState(task.state) && !(task.state === "approved" && refresh)) throw new PreEffectToolRefusal(`task #${task.id} is already ${task.state}`);
4790
5700
  } else {
4791
- if (task.state === "in_review" || isClosedTaskState(task.state)) throw new Error(`task #${task.id} is already ${task.state}`);
5701
+ if (task.state === "in_review" && !refresh || isClosedTaskState(task.state) && !(task.state === "approved" && refresh)) throw new Error(`task #${task.id} is already ${task.state}`);
4792
5702
  }
5703
+ if (task.sdlc?.merge?.state === "merged") throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: PR is merged`);
4793
5704
  this.ready(task, preEffect);
4794
5705
  if (task.state === "proposed" || task.state === "changes_requested") this.acceptanceCapacity(task);
4795
5706
  if (this.checking.has(task.id)) {
4796
5707
  // The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
4797
5708
  throw new (preEffect ? PreEffectToolRefusal : Error)(this.checking.get(task.id) === task.owner ? `task #${task.id}: its check is still running; its result comes as a task message` : `task #${task.id}: a check from before it changed hands is still running; call hub_task_done again in a few minutes`);
4798
5709
  }
4799
- return task;
5710
+ return this.completionDraft(task, refresh);
5711
+ }
5712
+
5713
+ /** An approval may reopen only while its linked PR is known not to be merged, even with lifecycle gates off. */
5714
+ private async unmergedForReopen(task: Task, valid: () => Task): Promise<void> {
5715
+ valid();
5716
+ if (task.sdlc?.merge?.state === "merged") throw new PreEffectToolRefusal(`task #${task.id}: PR is merged`);
5717
+ const { repo, pr } = task.sdlc ?? {};
5718
+ if (!repo || !pr) return;
5719
+ if (this.isPii(task)) throw new PreEffectToolRefusal(`task #${task.id}: PR merge state unavailable for private work`);
5720
+ const original = this.need(task.id, false, true);
5721
+ const identity = JSON.stringify([original.sdlc, original.refs, original.history.length]);
5722
+ let state: unknown;
5723
+ try {
5724
+ const reply = await (this.d.sdlc?.gh ?? sdlcGh)(["pr", "view", String(pr), "--repo", repo, "--json", "state"]);
5725
+ if (reply.code === 0) state = JSON.parse(reply.stdout).state;
5726
+ } catch { /* unknown merge state refuses before effects */ }
5727
+ valid();
5728
+ const current = this.need(task.id, false, true);
5729
+ if (JSON.stringify([current.sdlc, current.refs, current.history.length]) !== identity) throw new PreEffectToolRefusal(`task #${task.id}: task changed during merge-state read`);
5730
+ if (state === "MERGED") throw new PreEffectToolRefusal(`task #${task.id}: PR is merged`);
5731
+ if (state !== "OPEN" && state !== "CLOSED") throw new PreEffectToolRefusal(`task #${task.id}: PR merge state unavailable; cannot reopen approval`);
4800
5732
  }
4801
5733
 
4802
5734
  async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs, deliverables?: unknown): Promise<Task> {
4803
5735
  const requestCheck = this.requestCheck(by);
4804
- const valid = () => { requestCheck(); return this.completionCurrent(id, by, true); };
5736
+ const initial = this.need(id, false, true);
5737
+ this.mine(initial, by, "owner", true);
5738
+ let refresh: CompletionRefresh | undefined;
5739
+ if (initial.state === "in_review" || initial.state === "approved") {
5740
+ const commit = cleanRefs(refs).commit;
5741
+ if (!commit || !/^[a-f0-9]{40}$/.test(commit) || commit === this.reviewHead(initial))
5742
+ throw new PreEffectToolRefusal(`task #${initial.id} is already ${initial.state}; re-file requires a different full head SHA`);
5743
+ refresh = { revision: initial.history.length, commit, state: initial.state };
5744
+ }
5745
+ const submittedCommit = cleanRefs(refs).commit;
5746
+ const draft = refresh ?? (submittedCommit && /^[a-f0-9]{40}$/.test(submittedCommit) ? { commit: submittedCommit } : undefined);
5747
+ const valid = () => { requestCheck(); return this.completionDraft(this.completionCurrent(id, by, true, refresh), draft); };
5748
+ if (initial.state === "approved") { await this.unmergedForReopen(initial, valid); valid(); }
4805
5749
  // #509a: a bad claim (a claimed hash, an outside or denied path, a missing file) is refused before any effect, with nothing recorded.
4806
5750
  const claims = deliverables === undefined ? undefined : this.deliverableClaims(deliverables, valid());
4807
5751
  // The PII screen reads the summary of a done that passes these checks (issue #198); they run again after it.
@@ -4823,7 +5767,7 @@ export class Tasks {
4823
5767
  task = current();
4824
5768
  let reviewEvidence: PrEvidence | undefined;
4825
5769
  if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { reviewEvidence = await this.lifecycleGate(task, "review"); task = current(); }
4826
- const reviewAdmission = task.reviewer || this.d.prepareReviewBrief ? await this.reviewRequestAdmission(task, refs, true, by, reviewEvidence, undefined, staged => this.withLink(staged, link, refs), !task.reviewer) : undefined;
5770
+ 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;
4827
5771
  task = current();
4828
5772
  // A member of a silent cohort (issue #107): its done is an intent, and the last of them integrates first.
4829
5773
  const cohort = this.cohorts.of(task.id);
@@ -4855,12 +5799,14 @@ export class Tasks {
4855
5799
  }
4856
5800
  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
4857
5801
  const command = this.d.runCheck ? this.d.check?.(task.class, task) : undefined;
4858
- if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission, link, records, requestCheck);
5802
+ if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission, link, records, requestCheck, refresh);
4859
5803
  // The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
5804
+ if (refresh?.state === "approved") { await this.unmergedForReopen(task, valid); task = current(); }
4860
5805
  this.checking.set(task.id, task.owner);
4861
5806
  const checkedRefs = cleanRefs(refs);
4862
5807
  this.warmRepository({ ...task, refs: { ...task.refs, ...checkedRefs } });
4863
- 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);
5808
+ const pending = this.d.board.update(task.id, by, "done (checking)", { ...(refresh ? { state: "in_progress" as const } : {}), ...(refresh?.state === "approved" && !task.reviewer ? { reviewer: USER } : {}), 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);
5809
+ if (refresh) { this.ciWatchers.cancel(pending.id); this.heldReviews.delete(pending.id); }
4864
5810
  this.releaseNotice(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
4865
5811
  const seen = { events: pending.history.length, owner: pending.owner };
4866
5812
  this.pendingChecks++;
@@ -5005,35 +5951,42 @@ export class Tasks {
5005
5951
  }
5006
5952
 
5007
5953
  /** `gated`: the transition `done()` already gated with no await since, so it is not read back twice (#382). */
5008
- 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> {
5954
+ private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review", admission?: ReviewReadiness, link?: IdentityLink, records?: DeliverableRecord[], requestCheck: () => void = () => {}, refresh?: CompletionRefresh): Promise<Task> {
5009
5955
  requestCheck();
5010
5956
  // A check's completion carries the records its `done (checking)` bound: the same bytes the owner delivered, not a later read.
5011
5957
  records ??= latestPendingDeliverables(task);
5958
+ const submittedCommit = cleanRefs(refs).commit;
5959
+ const draft = refresh ?? (submittedCommit && /^[a-f0-9]{40}$/.test(submittedCommit) ? { commit: submittedCommit } : undefined);
5960
+ task = this.completionDraft(task, draft);
5012
5961
  let selection = this.poolReview(task, refs);
5013
- let reviewer = selection?.reviewer ?? task.reviewer, reviewEvidence: PrEvidence | undefined;
5962
+ let reviewer = selection?.reviewer ?? task.reviewer ?? (refresh?.state === "approved" ? USER : undefined), reviewEvidence: PrEvidence | undefined;
5014
5963
  if (this.d.sdlc && this.d.sdlc.config().mode !== "off" && gated !== (reviewer ? "review" : "approval")) {
5015
5964
  reviewEvidence = await this.lifecycleGate(task, reviewer ? "review" : "approval");
5016
5965
  requestCheck();
5017
- task = this.withLink(this.completionCurrent(task.id, by, false), link, refs);
5966
+ task = this.withLink(this.completionDraft(this.completionCurrent(task.id, by, false, refresh), draft), link, refs);
5018
5967
  selection = this.poolReview(task, refs);
5019
- reviewer = selection?.reviewer ?? task.reviewer;
5968
+ reviewer = selection?.reviewer ?? task.reviewer ?? (refresh?.state === "approved" ? USER : undefined);
5020
5969
  }
5021
5970
  // A delayed local check needs a new readback; a completion-selected reviewer also needs optional facts in off mode.
5022
5971
  if (!admission && ((reviewer && (this.d.sdlc?.config().mode ?? "off") !== "off") || this.d.prepareReviewBrief)) {
5023
- admission = await this.reviewRequestAdmission(task, refs, false, by, reviewEvidence, undefined, staged => this.withLink(staged, link, refs), !reviewer);
5972
+ admission = await this.reviewRequestAdmission(task, refs, false, by, reviewEvidence, undefined, staged => this.withLink(this.completionDraft(staged, draft), link, refs), !reviewer);
5024
5973
  requestCheck();
5025
- task = this.withLink(this.completionCurrent(task.id, by, false), link, refs);
5974
+ task = this.withLink(this.completionDraft(this.completionCurrent(task.id, by, false, refresh), draft), link, refs);
5026
5975
  }
5027
5976
  selection = this.poolReview(task, refs);
5028
- reviewer = selection?.reviewer ?? task.reviewer;
5977
+ reviewer = selection?.reviewer ?? task.reviewer ?? (refresh?.state === "approved" ? USER : undefined);
5029
5978
  const reviewCost: ReviewCost | undefined = reviewer && task.plan && (task.plan.review || task.plan.ci)
5030
5979
  ? { ...cleanCostPlan(task.plan), ...(admission?.ready && admission.head ? { head: admission.head } : {}) } : undefined;
5031
5980
  const routedSummary = selection?.reviewSelection?.blocked ? `${summary ?? ""}\nReview routing: ${this.assignmentNote("pool unavailable", selection)}`.trim() : summary;
5981
+ if (refresh?.state === "approved") {
5982
+ await this.unmergedForReopen(task, () => { requestCheck(); return this.completionCurrent(task.id, by, true, refresh); });
5983
+ task = this.withLink(this.completionDraft(this.completionCurrent(task.id, by, true, refresh), draft), link, refs);
5984
+ }
5032
5985
  const completedRefs = cleanRefs(refs);
5033
5986
  this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
5034
5987
  const noWrites = this.noWritesRecorded({ ...task, refs: { ...task.refs, ...completedRefs } });
5035
5988
  const capacity = this.capacityBefore(task);
5036
- 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, { ...task, refs: { ...task.refs, ...completedRefs } }), ...(reviewCost ? { reviewCost } : {}), ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
5989
+ const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...completedRefs } }), state: reviewer ? "in_review" : "approved", ...(selection || refresh?.state === "approved" ? { 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, { ...task, refs: { ...task.refs, ...completedRefs } }), ...(reviewCost ? { reviewCost } : {}), ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
5037
5990
  this.queueReviewWritten(next);
5038
5991
  this.writePlannerNotices(next, capacity, next.state === "approved");
5039
5992
  this.ciWatchers.cancel(next.id); this.heldReviews.delete(next.id); // a newer head or a move ends the old head's watcher (#785 P1b)
@@ -5462,12 +6415,15 @@ export class Tasks {
5462
6415
 
5463
6416
  /** #520: the rendered review brief of a non-PII task's commit, or why there is none; empty without a commit. */
5464
6417
  briefText(task: Task): string {
5465
- if (this.isPii(task) || !task.refs.commit) return "";
6418
+ if (this.isPii(task)) return "";
6419
+ const approval = approvalText(approvalBinding(task));
6420
+ const prefix = (brief: string) => [approval, brief].filter(Boolean).join("\n");
6421
+ if (!task.refs.commit) return approval;
5466
6422
  try {
5467
6423
  const brief = this.d.reviewBrief?.(task);
5468
- if (!brief) return "";
5469
- return "unavailable" in brief ? `Review brief unavailable: ${brief.unavailable}.` : sanitize(renderReviewBrief(brief));
5470
- } catch { return "Review brief unavailable: it could not be built."; }
6424
+ if (!brief) return approval;
6425
+ return prefix("unavailable" in brief ? `Review brief unavailable: ${brief.unavailable}.` : sanitize(renderReviewBrief(brief)));
6426
+ } catch { return prefix("Review brief unavailable: it could not be built."); }
5471
6427
  }
5472
6428
 
5473
6429
  async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown, coverage?: unknown, findings?: unknown): Promise<Task> {
@@ -5478,16 +6434,22 @@ export class Tasks {
5478
6434
  // inherited reviewedHead lives there).
5479
6435
  let covered: { commit: string | undefined; receipt: string } | undefined;
5480
6436
  let conformanceIdentity: string | undefined;
6437
+ let reviewRound: number | undefined;
6438
+ let withdrawalRevision: number | undefined;
6439
+ const withdrawing = this.need(id, false, true).state === "approved" && verdict === "changes_requested";
5481
6440
  const valid = (): Task => {
5482
6441
  requestCheck();
5483
6442
  const refused = authority?.();
5484
6443
  if (refused) throw new PreEffectToolRefusal(refused); // rechecked after every await, right before each write
5485
6444
  const t = this.need(id, false, true);
6445
+ if (withdrawalRevision !== undefined && t.history.length !== withdrawalRevision) throw new PreEffectToolRefusal(`task #${t.id}: approval changed during withdrawal`);
6446
+ if (reviewRound !== undefined && this.reviewRequestRevision(t) !== reviewRound) throw new PreEffectToolRefusal(`task #${t.id}: review request changed during the verdict; review the new head`);
5486
6447
  if (covered && (t.refs.commit !== covered.commit || JSON.stringify(t.sdlc ?? null) !== covered.receipt)) throw new PreEffectToolRefusal(`task #${t.id}'s commit or lifecycle receipt changed during the verdict: review the new head`);
5487
6448
  this.mine(t, by, "reviewer", true);
5488
6449
  if (verdict !== "approved" && verdict !== "changes_requested") throw new PreEffectToolRefusal('verdict must be "approved" or "changes_requested"');
5489
- if (t.state !== "in_review") throw new PreEffectToolRefusal(`task #${t.id} is ${t.state}: cannot move to ${verdict} before its owner calls hub_task_done`);
6450
+ if (t.state !== (withdrawing ? "approved" : "in_review")) throw new PreEffectToolRefusal(`task #${t.id} is ${t.state}: cannot move to ${verdict} before its owner calls hub_task_done`);
5490
6451
  if (conformanceIdentity !== undefined && conformanceIdentity !== this.planIdentity(t)) throw new PreEffectToolRefusal("plan conformance identity changed during verdict");
6452
+ if (t.sdlc?.merge?.state === "merged") throw new PreEffectToolRefusal(`task #${t.id}: PR is merged`);
5491
6453
  findingGuard?.(t);
5492
6454
  return t;
5493
6455
  };
@@ -5498,6 +6460,9 @@ export class Tasks {
5498
6460
  // the gap in the verdict note, off and a person's own verdict skip it.
5499
6461
  const mode = this.d.sdlc?.config().mode ?? "off";
5500
6462
  const initial = valid(), suppliedFindings = findings !== undefined;
6463
+ reviewRound = this.reviewRequestRevision(initial);
6464
+ if (withdrawing) withdrawalRevision = initial.history.length;
6465
+ if (withdrawing) { await this.unmergedForReopen(initial, valid); valid(); }
5501
6466
  let parsed: ReviewFinding[];
5502
6467
  try { parsed = parseReviewFindings(findings, { person: by === USER, maxNits: this.d.sdlc?.config().maxNits ?? DEFAULT_MAX_NITS }); }
5503
6468
  catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "invalid structured findings"); }
@@ -5602,6 +6567,7 @@ export class Tasks {
5602
6567
  task = valid();
5603
6568
  if (cleared && context === JSON.stringify({ title: task.title, detail: task.detail, refs: task.refs, plan: task.plan, signals: task.signals })) candidate = input; // Async screening cannot authorize a verdict under an obsolete role/state.
5604
6569
  }
6570
+ if (withdrawing) { await this.unmergedForReopen(task, valid); task = valid(); }
5605
6571
  const pii = this.isPii(task);
5606
6572
  const reviewContext = by === USER ? undefined : this.d.reviewContext?.(by, task.id);
5607
6573
  const poolRequest = task.history.findLast(entry => ["done", "reviewer changed", "reviewer unavailable"].includes(entry.event));
@@ -5611,7 +6577,7 @@ export class Tasks {
5611
6577
  // #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
5612
6578
  // write (the `task` just rechecked by valid()), never from the caller; a PII task carries none, its checklist being private.
5613
6579
  const unmetCriteria = pii ? [] : unmetCriteriaIds(items, checklistOf(task.sdlc));
5614
- const flag = { ...this.planObservation(task), withheld: withheld || undefined, ...(report ? { reviewFindings: report } : {}), ...(unmetCriteria.length ? { unmetCriteria } : {}), ...(boundDeliverables ? { deliverables: boundDeliverables } : {}), ...(reviewContext ? { reviewContext } : {}), ...(codeScope ? { reviewScope: "code" as const } : {}), ...(evidenceOnly ? { evidenceOnly: true as const } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
6580
+ const flag = { ...(findingHead ? { reviewedHead: findingHead } : {}), ...this.planObservation(task), withheld: withheld || undefined, ...(report ? { reviewFindings: report } : {}), ...(unmetCriteria.length ? { unmetCriteria } : {}), ...(boundDeliverables ? { deliverables: boundDeliverables } : {}), ...(reviewContext ? { reviewContext } : {}), ...(codeScope ? { reviewScope: "code" as const } : {}), ...(evidenceOnly ? { evidenceOnly: true as const } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
5615
6581
  if (verdict === "approved") {
5616
6582
  const capacity = this.capacityBefore(task);
5617
6583
  this.ciWatchers.cancel(task.id); this.heldReviews.delete(task.id);
@@ -5637,7 +6603,7 @@ export class Tasks {
5637
6603
  return next;
5638
6604
  }
5639
6605
  this.ciWatchers.cancel(task.id); this.heldReviews.delete(task.id);
5640
- const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: evidenceOnly ? task.rejections : task.rejections + 1 }, note, flag);
6606
+ const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: evidenceOnly ? task.rejections : task.rejections + 1 }, note, flag, task.history.length);
5641
6607
  void this.mirrorVerdict(rejected, by, "changes_requested", reviewedHead ?? rejected.sdlc?.reviewedHead, note, withheld);
5642
6608
  if (!evidenceOnly) this.releaseNotice(`task #${rejected.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(rejected, by, parsed, withheld));
5643
6609
  if (!evidenceOnly) this.releaseNotice(`task #${rejected.id}: review saved but recurring-criterion proposal unavailable`, () => this.proposeCriterionCandidates(rejected, by, withheld));
@@ -5650,6 +6616,11 @@ export class Tasks {
5650
6616
  if (!evidenceOnly) this.contradict(rejected);
5651
6617
  this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`, withheld);
5652
6618
  if (evidenceOnly) this.tellConductorEvidenceOnly(rejected, by);
6619
+ if (withdrawing) {
6620
+ this.releaseNotice(`task ${this.publicTitle(rejected)}: could not tell its owner the approval was withdrawn`, () =>
6621
+ this.tell(rejected, `Task #${rejected.id}: ${by} withdrew approval and requests changes. Review the findings, then call hub_task_done with the updated head.`, pii));
6622
+ return rejected;
6623
+ }
5653
6624
  if (!evidenceOnly && rejected.rejections >= (this.d.escalateAfter?.() ?? ESCALATE_AFTER)) {
5654
6625
  let moved: Task;
5655
6626
  try {