@staix/agent-hub 0.24.0 → 0.25.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 (70) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +27 -5
  3. package/docs/agent-notes/adapters.md +1 -0
  4. package/docs/agent-notes/bus.md +1 -0
  5. package/docs/agent-notes/daemon.md +15 -2
  6. package/docs/agent-notes/tasks.md +9 -2
  7. package/docs/agent-notes/tests.md +2 -1
  8. package/docs/dogfood.md +12 -2
  9. package/docs/memory.md +1 -1
  10. package/docs/operations.md +137 -4
  11. package/docs/profiles.md +2 -0
  12. package/docs/quickstart.md +10 -3
  13. package/docs/security.md +155 -3
  14. package/docs/specs/2026-09-19-agent-hub-design.md +25 -1
  15. package/docs/specs/2026-09-19-multi-project-design.md +12 -1
  16. package/docs/specs/2026-11-01-research-campaigns-design.md +351 -0
  17. package/docs/verification/2026-11-01-0.25.0.md +91 -0
  18. package/docs/verified.json +31 -15
  19. package/package.json +4 -3
  20. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  21. package/plugins/agent-hub/server.js +5118 -4708
  22. package/src/adapters/claude-channel.ts +29 -6
  23. package/src/adapters/definitions/native-cli-doctor.ts +30 -0
  24. package/src/adapters/jcode.ts +3 -0
  25. package/src/adapters/registry.ts +1 -1
  26. package/src/cli/acon-register.ts +29 -0
  27. package/src/cli/handoff-args.ts +27 -0
  28. package/src/cli/handoff-output.ts +25 -0
  29. package/src/cli/help.ts +6 -3
  30. package/src/cli/identity.ts +7 -1
  31. package/src/cli/main.ts +76 -7
  32. package/src/cli/output.ts +8 -5
  33. package/src/cli/schedule-output.ts +8 -4
  34. package/src/cli/study-output.ts +71 -0
  35. package/src/cli/study.ts +58 -0
  36. package/src/cli/task-output.ts +1 -0
  37. package/src/hub/board.ts +871 -2
  38. package/src/hub/channel-project.ts +61 -0
  39. package/src/hub/control-client.ts +10 -1
  40. package/src/hub/daemon.ts +247 -11
  41. package/src/hub/envelope.ts +2 -0
  42. package/src/hub/events.ts +4 -1
  43. package/src/hub/execution-budget.ts +12 -0
  44. package/src/hub/handoff.ts +126 -0
  45. package/src/hub/hub-tools.ts +12 -2
  46. package/src/hub/manager.ts +64 -57
  47. package/src/hub/no-writes.ts +1 -1
  48. package/src/hub/path-policy.ts +12 -8
  49. package/src/hub/project.ts +14 -5
  50. package/src/hub/provenance.ts +30 -0
  51. package/src/hub/research-campaign.ts +265 -0
  52. package/src/hub/research-experiments.ts +445 -0
  53. package/src/hub/restart.ts +26 -0
  54. package/src/hub/schedules.ts +210 -9
  55. package/src/hub/session-identity.ts +49 -0
  56. package/src/hub/tasks.ts +894 -22
  57. package/src/hub/ui.ts +15 -6
  58. package/src/memory/capture.ts +2 -2
  59. package/src/ui/index.html +5 -3
  60. package/src/ui/office-space.ts +118 -0
  61. package/src/ui/plugin.ts +293 -0
  62. package/templates/AGENTS.block.md +1 -0
  63. package/templates/profiles/general.json +1 -1
  64. package/templates/profiles/research.json +4 -4
  65. package/templates/profiles/writing.json +1 -1
  66. package/templates/roles/research/analyst.md +1 -0
  67. package/templates/roles/research/fact-checker.md +1 -0
  68. package/templates/roles/research/research-lead.md +1 -0
  69. package/templates/roles/research/researcher.md +1 -0
  70. package/templates/roles/research/statistician.md +1 -0
package/src/hub/tasks.ts CHANGED
@@ -2,7 +2,15 @@ import type { InvocationRow } from "./invocations.ts";
2
2
  import type { ContextAdmission } from "./context-window.ts";
3
3
  import { queueBackpressure, queueBackpressurePolicy, type QueueBackpressureCode, type QueueBackpressurePolicy, type QueueBackpressureStatus, type QueuePressure } from "./queue-backpressure.ts";
4
4
  import { canonicalMemorySymbolNode, normalizeMemorySymbolInput, MEMORY_SYMBOL_LIMIT, type MemorySymbolNodes } from "./memory-symbols.ts";
5
- import { createHash } from "node:crypto";
5
+ import { createHash, randomBytes } from "node:crypto";
6
+ import { types } from "node:util";
7
+ import { canonicalJson } from "./packs.ts";
8
+ import { parseExperimentProtocol, parseExperimentAttempt, parseExperimentEvent, protocolHash, checkRunObservation, deliverableObservation, projectExperimentWithObservations,
9
+ type ExperimentProtocolContract, type ExperimentProtocolRecord, type ExperimentAttemptRecord, type ExperimentWriteFence, type ExperimentHostEvidence, type ExperimentStatus } from "./research-experiments.ts";
10
+ import { parseComparisonPlan, comparisonPlanHash, comparisonProjection, evaluatorIdentityHash, protocolApprovalBindingMatches, type ComparisonPlanV1 } from "./research-experiments.ts";
11
+ import { parseResearchHandoff, studyProjection, campaignHash, parseResearchCampaign, type ResearchCampaignContract, type ResearchCampaignStatus } from "./research-campaign.ts";
12
+ import type { ResearchCampaignRecord, ResearchUnitBinding, PreparedResearchUnit, HandoffOutboundRow } from "./board.ts";
13
+ import { HANDOFF_EVENTS, HandoffRefusal, followBackLine, handoffEventFor, parseHandoff, type HandoffEvent } from "./handoff.ts";
6
14
  import { taskShellBindingValid, type TaskShellBinding } from "./task-shell-command.ts";
7
15
  import { buildTaskCodeSymbols, publicTaskCodeSymbols, taskCodeSymbolsDisjoint, HOST_CODE_SYMBOLS } from "./task-code-symbols.ts";
8
16
  import { normalizePlanSymbol, symbolsOverlap } from "./symbol-normalizer.ts";
@@ -12,7 +20,7 @@ import { completePlanInventory, planConformance, planConformanceBlockers, planFi
12
20
  import { RELEASE_TRAIN_HISTORY_LIMIT, releaseTrainData, releaseTrainReadiness, releaseMetrics } from "./sdlc-release.ts";
13
21
  import { cleanCostPlan, costMetrics, type CostPlan, type ReviewCost } from "./cost-governance.ts";
14
22
  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";
23
+ 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
24
  import { INTAKE_MAX_SOURCES, INTAKE_MAX_TEXT, intakeDetail, intakeSystemPrompt, intakeUserMessage, parseIntakeDetail, parseIntakeSplit, plannerRequestBody } from "./intake.ts";
17
25
  import { DEFAULT_BAND, EPISODE_LOOKBACK, sdlcMetrics, type BandMetric, type BandResult, type MetricTask, type MetricsReport, type SeriesPoint } from "./sdlc-metrics.ts";
18
26
  import { readSecurityScan } from "./security-baseline.ts";
@@ -30,7 +38,9 @@ import { CODE_SCOPE_LINE, evidenceOnlyNotice, type ReviewScope } from "./review-
30
38
  import { DEFAULT_MAX_NITS, parseReviewFindings, findingsForHead, findingHead as resolveFindingHead, findingTally, type ReviewFinding, type ReviewFindingSet } from "./review-findings.ts";
31
39
  import { readReleaseReadiness } from "./sdlc-release.ts";
32
40
  import { loadRoleCatalog, taskRole, taskRoleText, type RoleCatalog } from "./roles.ts";
33
- import { matchesPolicy } from "./path-policy.ts";
41
+ import { matchesPolicy, policyRealPath, insideRoot } from "./path-policy.ts";
42
+ import { isDenied } from "../local/deny.ts";
43
+ import { foldSegment, isHubWritePath } from "./permission-mode.ts";
34
44
  import { correctionInput, resolveVisibleMemory } from "./memory-candidates.ts";
35
45
  import type { MemoryScreen } from "./read-tools.ts";
36
46
  import { withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
@@ -48,7 +58,7 @@ const learnVerdict = (card: Pick<Task, "state" | "history">): "accepted" | "reje
48
58
  };
49
59
  import { approvedDeliverables, latestDeliverables, parseDeliverableClaims, publicDeliverables, type DeliverableRecord, type DeliverableStatus } from "./deliverables.ts";
50
60
  import { checkDeliverables, DeliverableRefusal, hashDeliverables, vetDeliverables, type DeliverablePorts } from "./deliverable-files.ts";
51
- import { isAbsolute, relative } from "node:path";
61
+ import { isAbsolute, relative, resolve } from "node:path";
52
62
  import type { Briefs, NativeItem } from "../memory/brief.ts";
53
63
  import type { MemoryClient } from "../memory/client.ts";
54
64
  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 +184,12 @@ export interface TasksDeps {
174
184
  quota?: () => Record<PeerId, { headroom: number; resetsAt?: number }>;
175
185
  /** Optional persisted task/run execution limits (issue #102). */
176
186
  executionBudget?: ExecutionBudget;
187
+ /** Project-scoped immutable-source metadata, read only before a study transaction. */
188
+ studySource?: (id: string) => { id: string; sha256: string; private: boolean } | undefined;
189
+ /** Host-only fixture clock; never read from model input or project configuration. */
190
+ studyNow?: () => number;
191
+ /** Host-only reader for this project's check-runner receipts; record inputs never supply a receipt. */
192
+ experimentCheck?: (run: string) => Promise<unknown>;
177
193
  /** Reviewer choice from recorded review outcomes (issue #35); off unless the project config turns it on. */
178
194
  review?: { adaptive: boolean; min_reviews: number; pool?: readonly string[]; rerotate?: boolean; same_family?: "fallback" | "never" };
179
195
  /** Peers whose recent deliveries all failed, with the reason (issue #89); routing skips them. */
@@ -375,6 +391,21 @@ export const GROOM_FYI_WINDOW_MS = 10 * 60_000;
375
391
  * PII handling, envelopes, briefs and memory notes are decided in one place.
376
392
  */
377
393
  /** #694: a PR staged at done with the commit or branch it was read for. */
394
+ export interface ResearchCampaignReceipt { id: string; parentTaskId: number; revision: number; status: ResearchCampaignStatus; unitCount: number; units: ResearchUnitBinding[] }
395
+ interface StudyReferences { parent: Task; adopted: Map<string, Task>; reused: Map<string, Task>; private: boolean; key: string }
396
+ export interface ExperimentReceipt { kind: "protocol" | "attempt" | "event"; protocolHash: string; attemptId?: string; seq?: number }
397
+ export interface ComparisonPlanReceipt { kind: "plan"; planHash: string }
398
+ export interface ProtocolApprovalReceipt { protocolHash: string; evaluatorHash: string; approvedBy: string; approvedAt: number }
399
+ export interface ExperimentAttemptView {
400
+ id: string; protocolHash: string; taskId: number; phase: "development" | "final"; status: ExperimentStatus; private: boolean;
401
+ protocol: unknown; events: unknown[]; evidence: Record<string, unknown>; eventsComplete: boolean; nextEventCursor?: number;
402
+ campaignRevision: number; approval: "approved" | "unapproved"; postHoc: "unknown" | "predeclared" | "post-hoc";
403
+ retryOf?: string; reproducesAttemptId?: string; independentlyReproduced: false;
404
+ }
405
+ export interface ExperimentView { attempts: ExperimentAttemptView[]; counts: { all: number; statuses: Record<ExperimentStatus, number> }; nextCursor?: number;
406
+ comparison?: { planHash: string; baselineAttemptId: string; variantAttemptId: string; approved: boolean; private: boolean; projection: ReturnType<typeof comparisonProjection> | "[pii]" } }
407
+ interface ExperimentContext { task: Task; campaign: ResearchCampaignRecord; private: boolean; key: string; fence: ExperimentWriteFence }
408
+ interface CompletionRefresh { revision: number; commit: string }
378
409
  interface IdentityLink { repo: string; pr: number; basis: { commit?: string; branch?: string } }
379
410
 
380
411
  /** 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. */
@@ -901,7 +932,8 @@ export class Tasks {
901
932
  const sets = this.reviewFindingSets(task), current = findingsForHead(sets, this.findingHead(task));
902
933
  const symbolRepository = codeSymbols && this.codeSymbolsRepository(task);
903
934
  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 };
935
+ const handedOff = this.d.board.handoffOrigin(task.id); // #604: where another project's handoff came from; two ids, never text
936
+ return { ...rest, ...(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
937
  }
906
938
 
907
939
  /** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
@@ -1226,6 +1258,789 @@ export class Tasks {
1226
1258
  this.planningAuthority(by, task);
1227
1259
  }
1228
1260
 
1261
+ private studiesRunning = 0;
1262
+ private studyStopped = false;
1263
+ private readonly studyDrainWaiters = new Set<() => void>();
1264
+ studiesPending = (): number => this.studiesRunning;
1265
+
1266
+ async stopStudies(): Promise<void> {
1267
+ this.studyStopped = true;
1268
+ if (this.studiesRunning) await new Promise<void>(resolve => this.studyDrainWaiters.add(resolve));
1269
+ }
1270
+
1271
+ private studyHold(): string | undefined {
1272
+ try {
1273
+ if (this.studyStopped || this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError)
1274
+ return "study writes are held by recovery, shutdown or storage failure";
1275
+ } catch { return "study write admission is unavailable"; }
1276
+ return undefined;
1277
+ }
1278
+
1279
+ private async studyWrite<T>(write: () => Promise<T>): Promise<T> {
1280
+ this.studiesRunning++;
1281
+ try { return await write(); } finally {
1282
+ if (--this.studiesRunning === 0) { for (const resolve of this.studyDrainWaiters) resolve(); this.studyDrainWaiters.clear(); }
1283
+ }
1284
+ }
1285
+
1286
+ private studyFields(input: unknown, allowed: readonly string[]): Record<string, unknown> {
1287
+ if (!input || typeof input !== "object" || Array.isArray(input) || types.isProxy(input)
1288
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(input)) || Object.getOwnPropertySymbols(input).length)
1289
+ throw new PreEffectToolRefusal("study request must be a plain data object");
1290
+ const fields = Object.getOwnPropertyDescriptors(input);
1291
+ if (Object.entries(fields).some(([key, descriptor]) => !allowed.includes(key) || !Object.hasOwn(descriptor, "value")))
1292
+ throw new PreEffectToolRefusal("study request has unknown fields or accessors");
1293
+ return Object.fromEntries(Object.entries(fields).map(([key, descriptor]) => [key, descriptor.value]));
1294
+ }
1295
+
1296
+ private studyId(value: unknown): string {
1297
+ if (typeof value !== "string" || !/^rc-[a-f0-9]{12}$/.test(value)) throw new PreEffectToolRefusal("study id is invalid");
1298
+ return value;
1299
+ }
1300
+
1301
+ private studyNumber(value: unknown, field: string): number {
1302
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) throw new PreEffectToolRefusal(`${field} must be a positive integer`);
1303
+ return value;
1304
+ }
1305
+
1306
+ private studyContract(value: unknown): ResearchCampaignContract {
1307
+ try { return parseResearchCampaign(value); }
1308
+ catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "invalid study contract"); }
1309
+ }
1310
+
1311
+ private studyTime(): number {
1312
+ const now = this.d.studyNow?.() ?? Date.now();
1313
+ if (!Number.isFinite(now)) throw new PreEffectToolRefusal("study clock is unavailable");
1314
+ return now;
1315
+ }
1316
+
1317
+ private studyRecord(id: string, revision?: number): ResearchCampaignRecord {
1318
+ let record: ResearchCampaignRecord | undefined;
1319
+ try { record = this.d.board.researchCampaign(id, revision); }
1320
+ catch { throw new PreEffectToolRefusal("study record is unavailable or invalid"); }
1321
+ if (!record || record.project !== this.d.project) throw new PreEffectToolRefusal("study is unavailable in this project");
1322
+ return record;
1323
+ }
1324
+
1325
+ private studyParent(id: number): Task {
1326
+ const parent = this.need(id, false, true, true);
1327
+ if (this.places(parent).repositoryKey !== this.repositories.scope({}).key) throw new PreEffectToolRefusal("study parent is outside this project");
1328
+ return parent;
1329
+ }
1330
+
1331
+ /** Write receipts expose no contract text; only person-only approval returns the unit mapping. */
1332
+ private studyReceipt(record: ResearchCampaignRecord, includeMapping = false): ResearchCampaignReceipt {
1333
+ const units = this.d.board.researchCampaignBindings(record.id, record.revision);
1334
+ return { id: record.id, parentTaskId: record.parentTaskId, revision: record.revision, status: record.status,
1335
+ unitCount: units.length, units: includeMapping ? units : [] };
1336
+ }
1337
+
1338
+ private studySource(id: string): { id: string; sha256: string; private: boolean } | undefined {
1339
+ try {
1340
+ const source = this.d.studySource?.(id);
1341
+ return source && source.id === id && /^[a-f0-9]{64}$/.test(source.sha256) && typeof source.private === "boolean"
1342
+ ? { id: source.id, sha256: source.sha256, private: source.private } : undefined;
1343
+ } catch { return undefined; }
1344
+ }
1345
+
1346
+ private studyLatest(id: string, revision: number): ResearchCampaignRecord {
1347
+ const record = this.studyRecord(id);
1348
+ if (record.revision !== revision || record.status === "closed") throw new PreEffectToolRefusal("study revision is stale or closed");
1349
+ return record;
1350
+ }
1351
+
1352
+ private studyFamily(parent: Task): Set<number> {
1353
+ if (!parent.history.some(entry => entry.event === "draft approved")) throw new PreEffectToolRefusal("adoption requires an approved intake family");
1354
+ const family = new Set<number>(), pending = [...(parent.deps ?? [])];
1355
+ // ponytail: 512 dependency nodes bound intake provenance walks; a larger family needs indexed family identities.
1356
+ while (pending.length) {
1357
+ const id = pending.pop()!;
1358
+ if (family.has(id)) continue;
1359
+ if (family.size >= 512) throw new PreEffectToolRefusal("study intake family exceeds its bounded scope");
1360
+ const task = this.d.board.get(id);
1361
+ if (!task) throw new PreEffectToolRefusal("study intake family is incomplete");
1362
+ family.add(id); pending.push(...(task.deps ?? []));
1363
+ }
1364
+ family.delete(parent.id);
1365
+ return family;
1366
+ }
1367
+
1368
+ private studyReferences(parentId: number, contract: ResearchCampaignContract, campaignId?: string): StudyReferences {
1369
+ const parent = this.studyParent(parentId);
1370
+ const profile = parent.profile ?? "dev", storedProfile = profile === (this.d.profile ?? "dev") ? this.d.profileData ?? profile : profile;
1371
+ try {
1372
+ if (profileClass(parent.profileClass ?? parent.class, storedProfile) !== parent.class) throw new Error("changed class");
1373
+ } catch { throw new PreEffectToolRefusal("study parent profile class is unavailable or changed"); }
1374
+ const sources = contract.sourceIds.map(id => {
1375
+ const source = this.studySource(id);
1376
+ if (!source)
1377
+ throw new PreEffectToolRefusal("study source is unavailable in this project");
1378
+ return source;
1379
+ });
1380
+ if (contract.budget) {
1381
+ const budget = this.d.executionBudget?.status(contract.budget.id);
1382
+ if (!budget || Array.isArray(budget)) throw new PreEffectToolRefusal("study budget is unavailable");
1383
+ }
1384
+ const adopted = new Map<string, Task>();
1385
+ const family = contract.units.some(unit => unit.adoptTaskId !== undefined) ? this.studyFamily(parent) : new Set<number>();
1386
+ for (const unit of contract.units) if (unit.adoptTaskId !== undefined) {
1387
+ const task = this.studyParent(unit.adoptTaskId);
1388
+ if (!family.has(task.id) || task.draft || !task.history.some(entry => entry.event === "draft approved"))
1389
+ throw new PreEffectToolRefusal("study unit must adopt an approved intake child");
1390
+ const binding = this.d.board.researchTaskBinding(task.id);
1391
+ if (binding && (binding.campaignId !== campaignId || binding.unitId !== unit.id)) throw new PreEffectToolRefusal("intake child is already bound to another study unit");
1392
+ adopted.set(unit.id, task);
1393
+ }
1394
+ const reused = new Map<string, Task>();
1395
+ if (campaignId) for (const unit of contract.units) {
1396
+ const previous = this.d.board.researchLatestUnitBinding(campaignId, unit.id);
1397
+ if (previous) reused.set(unit.id, this.studyParent(previous.taskId));
1398
+ }
1399
+ const priv = this.isPii(parent) || sources.some(source => source.private) || [...adopted.values(), ...reused.values()].some(task => this.isPii(task))
1400
+ || !this.nameable(JSON.stringify(contract));
1401
+ const flows = (rows: Map<string, Task>) => [...rows].map(([unit, task]) => [unit, task.id, task.history.length]);
1402
+ return { parent, adopted, reused, private: priv, key: JSON.stringify([parent.history.length, sources, flows(adopted), flows(reused), priv]) };
1403
+ }
1404
+
1405
+ /** Always re-read persisted approval and limits at the dispatch write, including after a metadata await. */
1406
+ private studyDispatchReason(task: Task, owner?: PeerId): string | undefined {
1407
+ try {
1408
+ const bound = this.d.board.researchDispatchBinding(task.id);
1409
+ if (!bound) return undefined;
1410
+ const { campaign, binding } = bound;
1411
+ if (campaign.project !== this.d.project || campaign.status !== "approved" || !binding) return "study revision is not approved or its unit was removed";
1412
+ const stop = campaign.contract.stopPolicy;
1413
+ if (Date.parse(stop.deadline) <= this.studyTime()) return "study deadline has expired";
1414
+ const units = this.d.board.researchCampaignBindings(campaign.id, campaign.revision);
1415
+ if (units.length > stop.maxUnits || binding.round > stop.maxFollowUpRounds) return "study stop limit exceeded";
1416
+ const budget = campaign.contract.budget;
1417
+ if (budget?.hardCap.length) {
1418
+ const ledger = this.d.executionBudget, current = ledger?.status(budget.id);
1419
+ if (!ledger || !current || Array.isArray(current) || !campaign.eligibleOwners?.length ||
1420
+ budget.hardCap.some(resource => resource === "tokens" || current.limits[resource] === undefined || resource === "model_calls" && current.limits.tokens !== undefined)) return "study hard-cap budget is unavailable";
1421
+ 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";
1422
+ if (owner && (!campaign.eligibleOwners.includes(owner) || !ledger.covers(budget.id, task.id, owner, budget.hardCap)))
1423
+ return "study owner is not covered by its named hard-cap budget";
1424
+ }
1425
+ return undefined;
1426
+ } catch { return "study dispatch evidence is unavailable"; }
1427
+ }
1428
+
1429
+ private studyDispatchCheck(task: Task, owner?: PeerId): void {
1430
+ const reason = this.studyDispatchReason(task, owner);
1431
+ if (reason) throw new PreEffectToolRefusal(reason);
1432
+ }
1433
+
1434
+ private studyExcludedOwners(task: Task): PeerId[] {
1435
+ return Object.keys(this.states()).filter(peer => this.studyDispatchReason(task, peer) !== undefined);
1436
+ }
1437
+
1438
+ private studyEligibleOwners(contract: ResearchCampaignContract): string[] | undefined {
1439
+ if (!contract.budget?.hardCap.length) return undefined;
1440
+ const ledger = this.d.executionBudget, budget = ledger?.status(contract.budget.id);
1441
+ if (!ledger || !budget || Array.isArray(budget) || contract.budget.hardCap.some(resource => resource === "tokens" || budget.limits[resource] === undefined || resource === "model_calls" && budget.limits.tokens !== undefined))
1442
+ throw new PreEffectToolRefusal("study hard-cap requires an enabled budget with supported resource limits");
1443
+ const eligible = budget.peers.filter(peer => ledger.instrumented(peer)).sort();
1444
+ if (!eligible.length) throw new PreEffectToolRefusal("study hard-cap has no instrumented eligible owner");
1445
+ return eligible;
1446
+ }
1447
+
1448
+ private studyRoundCheck(original: ResearchCampaignRecord, contract: ResearchCampaignContract): void {
1449
+ const added = contract.units.filter(unit => unit.round > 0 && !original.contract.units.some(previous => previous.id === unit.id && previous.round === unit.round));
1450
+ if (!added.length) return;
1451
+ const stored = this.d.board.researchCampaignBindings(original.id, original.revision), handoffs = this.d.board.researchHandoffs(original.id, original.revision);
1452
+ for (const unit of added) {
1453
+ // 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.
1454
+ const previous = contract.units.filter(value => value.round === unit.round - 1 && value.purpose === "discovery");
1455
+ if (!previous.length || previous.some(value => {
1456
+ const binding = stored.find(bound => bound.unitId === value.id && bound.round === value.round && bound.purpose === "discovery");
1457
+ const handoff = binding && handoffs.find(handoff => handoff.handoff.unitId === value.id);
1458
+ return !handoff || !handoff.handoff.insufficientEvidence && value.sourceIds.some(source => !handoff.handoff.evidence.some(item => item.sourceId === source));
1459
+ })) throw new PreEffectToolRefusal("follow-up rounds require complete stored discovery handoffs or insufficientEvidence");
1460
+ }
1461
+ }
1462
+
1463
+ async studyHandoff(by: PeerId, input: unknown): Promise<{ campaignId: string; unitId: string; seq: number }> {
1464
+ const requestCheck = this.requestCheck(by, () => this.studyHold());
1465
+ let handoff: ReturnType<typeof parseResearchHandoff>;
1466
+ try { handoff = parseResearchHandoff(input); } catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "invalid study handoff"); }
1467
+ return this.studyWrite(async () => {
1468
+ const campaign = this.studyRecord(handoff.campaignId, handoff.revision), task = this.studyParent(handoff.taskId);
1469
+ 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");
1470
+ const sources = campaign.contract.sourceIds.map(id => this.studySource(id));
1471
+ if (sources.some(source => !source)) throw new PreEffectToolRefusal("study handoff source is unavailable");
1472
+ const sourceKey = JSON.stringify(sources);
1473
+ const text = JSON.stringify(handoff);
1474
+ const withheld = !this.nameable(text) || looksSecret(text) || await this.withholds("note", text, task);
1475
+ requestCheck();
1476
+ const current = this.studyParent(task.id);
1477
+ if (current.history.length !== task.history.length || current.owner !== by || current.state !== "in_progress") throw new PreEffectToolRefusal("study handoff task changed during screening");
1478
+ if (JSON.stringify(campaign.contract.sourceIds.map(id => this.studySource(id))) !== sourceKey) throw new PreEffectToolRefusal("study handoff sources changed during screening");
1479
+ if ((withheld || this.studyReplayPrivacy(campaign, this.studyParent(campaign.parentTaskId))) && !this.isPii(current)) throw new PreEffectToolRefusal("study handoff privacy requires private task authority");
1480
+ requestCheck();
1481
+ try {
1482
+ const saved = this.d.board.recordResearchHandoff(handoff, this.d.project, by, this.studyTime(), current.history.length);
1483
+ return { campaignId: saved.handoff.campaignId, unitId: saved.handoff.unitId, seq: saved.seq };
1484
+ } catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "study handoff was not saved"); }
1485
+ });
1486
+ }
1487
+
1488
+ /**
1489
+ * #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
1490
+ * 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.
1491
+ */
1492
+ studyList(by: PeerId, input: unknown): { campaigns: ResearchCampaignReceipt[]; next?: string } {
1493
+ requireLocalTaskPeer(by);
1494
+ const fields = this.studyFields(input, ["after"]);
1495
+ const after = fields.after === undefined ? undefined : this.studyId(fields.after);
1496
+ const page = this.d.board.researchCampaignPage(this.d.project, after, 50);
1497
+ return { campaigns: page.ids.map(id => this.studyReceipt(this.studyRecord(id))), ...(page.next ? { next: page.next } : {}) };
1498
+ }
1499
+
1500
+ studyShow(by: PeerId, input: unknown): ReturnType<typeof studyProjection> {
1501
+ requireLocalTaskPeer(by);
1502
+ const fields = this.studyFields(input, ["id"]), campaign = this.studyRecord(this.studyId(fields.id));
1503
+ const bindings = this.d.board.researchCampaignBindings(campaign.id, campaign.revision);
1504
+ const tasks = bindings.map(unit => this.d.board.get(unit.taskId)).filter((task): task is Task => !!task);
1505
+ const ledger = this.d.executionBudget, configured = campaign.contract.budget, budget = configured && ledger?.status(configured.id);
1506
+ const instrumentedPeers = tasks.flatMap(task => task.owner && ledger?.instrumented(task.owner) ? [task.owner] : []);
1507
+ const projection = studyProjection({ campaign, bindings, tasks, handoffs: this.d.board.researchHandoffs(campaign.id), now: this.studyTime(), instrumentedPeers,
1508
+ ...(budget && !Array.isArray(budget) ? { budget: { ...budget, coveredTaskIds: tasks.filter(task => task.owner && ledger?.covers(budget.id, task.id, task.owner, [])).map(task => task.id) } } : {}) });
1509
+ projection.reasons = [...new Set([...projection.reasons, ...tasks.flatMap(task => { const reason = this.studyDispatchReason(task, task.owner ?? undefined); return reason ? [reason] : []; })])];
1510
+ projection.held = projection.reasons.length > 0;
1511
+ if (this.studyReplayPrivacy(campaign, this.studyParent(campaign.parentTaskId))) {
1512
+ // IDs, counts, states and resource names only. Source references are private view data too.
1513
+ for (const unit of projection.units) { unit.sourceIds = []; unit.duplicateSourceIds = []; unit.handoff.coveredSourceIds = []; }
1514
+ }
1515
+ return projection;
1516
+ }
1517
+
1518
+ private studyReplayPrivacy(record: ResearchCampaignRecord, parent: Task): boolean {
1519
+ return record.private || this.isPii(parent) || record.contract.sourceIds.some(id => {
1520
+ const source = this.studySource(id);
1521
+ return !source || source.private;
1522
+ }) || this.d.board.researchCampaignBindings(record.id, record.revision).some(unit => {
1523
+ const task = this.d.board.get(unit.taskId); return !task || this.isPii(task);
1524
+ });
1525
+ }
1526
+
1527
+ /**
1528
+ * #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
1529
+ * 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.
1530
+ */
1531
+ handoffOutAuthority(by: PeerId, piiTask?: Task): void {
1532
+ requireLocalTaskPeer(by);
1533
+ 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");
1534
+ if (piiTask && this.isPii(piiTask)) throw new HandoffRefusal("pii_task", "a [pii] task is not handed off: nothing about it leaves this project");
1535
+ 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");
1536
+ }
1537
+
1538
+ /** ORIGIN side: remember what was handed over, once the target answered with its task id. */
1539
+ handoffSent(by: PeerId, target: { projectId: string }, remoteTask: number, title: string, localTask?: number): void {
1540
+ this.d.board.recordHandoffOutbound({ remoteProject: target.projectId, remoteTask, originPeer: by, ...(localTask === undefined ? {} : { localTask }), title: title.slice(0, 300), state: "proposed" });
1541
+ }
1542
+
1543
+ /**
1544
+ * 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.
1545
+ * 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
1546
+ * text is sanitized before it is stored. A `[pii]` result is refused whole, and nothing is left behind.
1547
+ */
1548
+ async handoffIn(origin: { projectId: unknown; peer: unknown; taskId?: unknown }, input: unknown, fence: () => string | undefined = () => undefined): Promise<{ task: number; state: string; note?: string }> {
1549
+ const request = parseHandoff({ ...(input && typeof input === "object" && !Array.isArray(input) ? input as Record<string, unknown> : { invalid: true }), project: "local" });
1550
+ 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)
1551
+ || origin.taskId !== undefined && (!Number.isSafeInteger(origin.taskId) || (origin.taskId as number) < 1)) throw new HandoffRefusal("invalid", "handoff origin is malformed");
1552
+ 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");
1553
+ // 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).
1554
+ const held = (): void => { const reason = fence(); if (reason) throw new HandoffRefusal("invalid", reason); };
1555
+ held();
1556
+ // 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.
1557
+ const issueLine = request.issue === undefined ? "" : `spec issue: #${request.issue} in this repository`;
1558
+ 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"));
1559
+ const refs: TaskRefs = { ...(request.paths.length ? { paths: request.paths } : {}) };
1560
+ const signals = this.signalsFor({ title: request.title, detail, refs, plan: {} });
1561
+ if (signals.includes("pii")) throw new HandoffRefusal("pii_task", "the handed-off text matches a PII pattern; it is not stored here");
1562
+ const screening = this.screenOn();
1563
+ const screened = screening ? await this.screenCall(screenText({ title: request.title, detail: [detail, ...request.paths].join("\n"), plan: {} })) : undefined;
1564
+ held();
1565
+ 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");
1566
+ let task = this.d.board.propose(HUB, { title: request.title, detail, class: request.class, refs, plan: {}, signals });
1567
+ this.d.board.setHandoffOrigin(task.id, { projectId: origin.projectId, peer: origin.peer, ...(origin.taskId === undefined ? {} : { originTask: origin.taskId as number }) });
1568
+ // 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.
1569
+ try { task = this.linkOnWrite(task); } catch { /* the lifecycle link is advice */ }
1570
+ try { this.d.notify(`task ${this.publicTitle(task)} handed off from project ${origin.projectId} by ${origin.peer}`); } catch { /* never throw after a board write */ }
1571
+ let note: string | undefined;
1572
+ try { task = await this.assignOwner(task, HUB, {}); }
1573
+ catch (error) {
1574
+ if (error instanceof AssignmentUndeliveredError) { task = error.task; note = "the assignment is saved but its delivery is not confirmed yet; this hub retries it"; }
1575
+ 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
1576
+ }
1577
+ return { task: task.id, state: task.state, ...(note ? { note } : {}) };
1578
+ }
1579
+
1580
+ /** TARGET side: the origin recorded for a task, for `ahub task show` and the board. */
1581
+ handoffOriginOf(taskId: number): { projectId: string; peer: string; originTask: number | null } | undefined {
1582
+ const origin = this.d.board.handoffOrigin(taskId);
1583
+ return origin ? { projectId: origin.projectId, peer: origin.peer, originTask: origin.originTask } : undefined;
1584
+ }
1585
+
1586
+ /** 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. */
1587
+ handoffEvent(input: unknown): { recorded: boolean } {
1588
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new HandoffRefusal("invalid", "handoff event must be a data object");
1589
+ const e = input as Record<string, unknown>;
1590
+ if (typeof e.projectId !== "string" || !e.projectId || e.projectId.length > 200 || !Number.isSafeInteger(e.remoteTask) || (e.remoteTask as number) < 1
1591
+ || typeof e.state !== "string" || !(HANDOFF_EVENTS as readonly string[]).includes(e.state)) throw new HandoffRefusal("invalid", "handoff event is malformed");
1592
+ const row = this.d.board.handoffOutboundFor(e.projectId, e.remoteTask as number);
1593
+ if (!row) throw new HandoffRefusal("invalid", "no handoff of ours is recorded for that task");
1594
+ 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;
1595
+ if (row.state === e.state && (pr === undefined || pr === row.pr) && (head === undefined || head === row.head)) return { recorded: false };
1596
+ 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 }) });
1597
+ const body = followBackLine(row.remoteTask, e.state as HandoffEvent, { pr, head });
1598
+ if (row.originPeer !== USER && row.originPeer !== HUB) {
1599
+ try { this.d.bus.publish(newEnvelope(HUB, body, { to: [row.originPeer], kind: "task", priority: "important", refs: { task: String(row.localTask ?? row.remoteTask) } })); }
1600
+ catch { /* never throw after a board write */ }
1601
+ }
1602
+ return { recorded: true };
1603
+ }
1604
+
1605
+ /**
1606
+ * 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
1607
+ * 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.
1608
+ */
1609
+ handoffStatesFor(projectId: unknown, taskIds: unknown): { task: number; state: HandoffEvent | "proposed"; pr?: number; head?: string }[] {
1610
+ 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");
1611
+ const out: { task: number; state: HandoffEvent | "proposed"; pr?: number; head?: string }[] = [];
1612
+ for (const id of taskIds as number[]) {
1613
+ const origin = this.d.board.handoffOrigin(id), task = origin?.projectId === projectId ? this.d.board.get(id) : undefined;
1614
+ if (!task) continue;
1615
+ const entry = task.history.findLast(h => handoffEventFor(h.event, task.state) !== undefined);
1616
+ const state = entry ? handoffEventFor(entry.event, task.state) ?? "proposed" : "proposed";
1617
+ const pr = typeof task.sdlc?.pr === "number" && Number.isSafeInteger(task.sdlc.pr) && task.sdlc.pr > 0 ? task.sdlc.pr : undefined;
1618
+ 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
1619
+ out.push({ task: id, state, ...(pr === undefined ? {} : { pr }), ...(head === undefined ? {} : { head }) });
1620
+ }
1621
+ return out;
1622
+ }
1623
+
1624
+ /** 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. */
1625
+ handoffCaughtUp(projectId: string, rows: { task: number; state: string; pr?: number; head?: string }[]): number {
1626
+ let changed = 0;
1627
+ for (const row of rows) {
1628
+ if (!(HANDOFF_EVENTS as readonly string[]).includes(row.state)) continue;
1629
+ 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++; }
1630
+ catch { /* a row we hold no handoff for is not ours */ }
1631
+ }
1632
+ return changed;
1633
+ }
1634
+
1635
+ /** ORIGIN side, readback: what this project handed over, with the last state each target reported. */
1636
+ handoffList(): HandoffOutboundRow[] { return this.d.board.handoffOutbound(); }
1637
+
1638
+ async studyDraft(by: PeerId, input: unknown): Promise<ResearchCampaignReceipt> {
1639
+ const requestCheck = this.requestCheck(by, () => this.studyHold()); this.plannerWriteAuthority(by);
1640
+ const fields = this.studyFields(input, ["parentTaskId", "contract"]), parentId = this.studyNumber(fields.parentTaskId, "parentTaskId");
1641
+ const contract = this.studyContract(fields.contract);
1642
+ if (contract.units.some(unit => unit.round !== 0)) throw new PreEffectToolRefusal("an initial study contains only round zero");
1643
+ return this.studyWrite(async () => {
1644
+ this.plannerWriteAuthority(by, this.studyParent(parentId));
1645
+ const before = this.studyReferences(parentId, contract);
1646
+ const withheld = await this.withholds("note", JSON.stringify(contract), before.parent);
1647
+ requestCheck();
1648
+ const current = this.studyReferences(parentId, contract); this.plannerWriteAuthority(by, current.parent);
1649
+ if (current.key !== before.key) throw new PreEffectToolRefusal("study references changed during draft screening");
1650
+ const record: ResearchCampaignRecord = { id: `rc-${randomBytes(6).toString("hex")}`, project: this.d.project, parentTaskId: parentId,
1651
+ revision: 1, status: "draft", contract, hash: campaignHash(contract), private: current.private || withheld, createdBy: by, createdAt: this.studyTime() };
1652
+ let saved: ResearchCampaignRecord;
1653
+ requestCheck(); this.plannerWriteAuthority(by, current.parent);
1654
+ try { saved = this.d.board.saveResearchCampaignDraft(record, undefined, current.parent.history.length); }
1655
+ catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "study draft was not saved"); }
1656
+ this.releaseNotice(`study ${saved.id} revision ${saved.revision} drafted`);
1657
+ return this.studyReceipt(saved);
1658
+ });
1659
+ }
1660
+
1661
+ async studyRevise(by: PeerId, input: unknown): Promise<ResearchCampaignReceipt> {
1662
+ const requestCheck = this.requestCheck(by, () => this.studyHold()); this.plannerWriteAuthority(by);
1663
+ const fields = this.studyFields(input, ["id", "revision", "contract"]), id = this.studyId(fields.id), revision = this.studyNumber(fields.revision, "revision");
1664
+ const contract = this.studyContract(fields.contract);
1665
+ return this.studyWrite(async () => {
1666
+ const original = this.studyLatest(id, revision);
1667
+ this.plannerWriteAuthority(by, this.studyParent(original.parentTaskId));
1668
+ const before = this.studyReferences(original.parentTaskId, contract, id);
1669
+ this.studyRoundCheck(original, contract);
1670
+ const withheld = await this.withholds("note", JSON.stringify(contract), before.parent);
1671
+ requestCheck();
1672
+ const latest = this.studyLatest(id, revision), current = this.studyReferences(latest.parentTaskId, contract, id);
1673
+ this.plannerWriteAuthority(by, current.parent);
1674
+ if (latest.hash !== original.hash || current.key !== before.key) throw new PreEffectToolRefusal("study changed during revision screening");
1675
+ this.studyRoundCheck(latest, contract);
1676
+ const next: ResearchCampaignRecord = { id, project: this.d.project, parentTaskId: latest.parentTaskId, revision: revision + 1, status: "draft",
1677
+ contract, hash: campaignHash(contract), private: latest.private || current.private || withheld, createdBy: by, createdAt: this.studyTime() };
1678
+ let saved: ResearchCampaignRecord;
1679
+ requestCheck(); this.plannerWriteAuthority(by, current.parent);
1680
+ try { saved = this.d.board.saveResearchCampaignDraft(next, revision, current.parent.history.length); }
1681
+ catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "study revision was not saved"); }
1682
+ this.releaseNotice(`study ${id} revision ${saved.revision} drafted`);
1683
+ return this.studyReceipt(saved);
1684
+ });
1685
+ }
1686
+
1687
+ async studyApprove(by: PeerId, input: unknown): Promise<ResearchCampaignReceipt> {
1688
+ const requestCheck = this.requestCheck(by, () => this.studyHold());
1689
+ if (by !== USER) throw new PreEffectToolRefusal("study approval requires the person");
1690
+ const fields = this.studyFields(input, ["id", "revision"]), id = this.studyId(fields.id), revision = this.studyNumber(fields.revision, "revision");
1691
+ return this.studyWrite(async () => {
1692
+ const original = this.studyRecord(id, revision), parent = this.studyParent(original.parentTaskId);
1693
+ if (original.status === "approved") {
1694
+ const privacy = this.studyReplayPrivacy(original, parent); requestCheck();
1695
+ const replay = this.d.board.activateCampaign(id, revision, this.d.project, by, this.studyTime(), [], parent.history.length, privacy);
1696
+ return this.studyReceipt(replay.campaign, true);
1697
+ }
1698
+ this.studyLatest(id, revision);
1699
+ if (original.status !== "draft" || parent.draft) throw new PreEffectToolRefusal("study approval requires a draft and an approved parent input");
1700
+ this.studyEligibleOwners(original.contract);
1701
+ const before = this.studyReferences(parent.id, original.contract, id);
1702
+ let priv = original.private || before.private || await this.withholds("note", JSON.stringify(original.contract), parent);
1703
+ const prepared: PreparedResearchUnit[] = [];
1704
+ for (const unit of original.contract.units) {
1705
+ requestCheck();
1706
+ const reused = before.reused.get(unit.id);
1707
+ if (reused) { prepared.push({ unitId: unit.id, reuse: { taskId: reused.id, flowRevision: reused.history.length } }); continue; }
1708
+ const adopted = before.adopted.get(unit.id);
1709
+ if (adopted) { prepared.push({ unitId: unit.id, adopt: { taskId: adopted.id, flowRevision: adopted.history.length } }); continue; }
1710
+ 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(", ")}`;
1711
+ priv ||= await this.withholds("task", detail, parent);
1712
+ const refs = cleanRefs(parent.refs), signals = [...new Set([...this.signalsFor({ title, detail, refs }), ...parent.signals.filter(signal => ["pii", "pii_path", "urgent"].includes(signal))])];
1713
+ prepared.push({ unitId: unit.id, create: { title, detail, class: parent.class, refs, signals,
1714
+ ...(parent.profile ? { profile: parent.profile } : {}), ...(parent.profileClass ? { profileClass: parent.profileClass } : {}),
1715
+ } });
1716
+ }
1717
+ requestCheck();
1718
+ const latest = this.studyLatest(id, revision), current = this.studyReferences(parent.id, original.contract, id);
1719
+ if (latest.hash !== original.hash || current.key !== before.key) throw new PreEffectToolRefusal("study changed during approval screening");
1720
+ const now = this.studyTime(), deadline = Date.parse(original.contract.stopPolicy.deadline);
1721
+ if (deadline <= now || deadline - now > 90 * 86_400_000) throw new PreEffectToolRefusal("study deadline must be in the next ninety days at approval");
1722
+ priv ||= latest.private || current.private;
1723
+ if (priv && [...current.adopted.values(), ...current.reused.values()].some(task => !this.isPii(task)))
1724
+ throw new PreEffectToolRefusal("a private study cannot retain a public task without changing its authority");
1725
+ for (const unit of prepared) if (unit.create) {
1726
+ if (priv && !unit.create.signals?.includes("pii")) unit.create.signals = [...(unit.create.signals ?? []), "pii"];
1727
+ const planner = this.proposalPlanner({ ...parent, ...unit.create, signals: unit.create.signals ?? [], refs: unit.create.refs ?? {}, draft: undefined, state: "backlog", owner: null, reviewer: null });
1728
+ unit.create.planner = planner.planner; unit.create.plannerReason = planner.plannerReason;
1729
+ this.warmRepository({ refs: unit.create.refs ?? {}, plan: unit.create.plan });
1730
+ }
1731
+ const mode = this.d.coordination?.();
1732
+ let result: ReturnType<Board["activateCampaign"]>;
1733
+ requestCheck();
1734
+ try { result = this.d.board.activateCampaign(id, revision, this.d.project, by, now, prepared, current.parent.history.length, priv, { eligibleOwners: this.studyEligibleOwners(original.contract) }); }
1735
+ catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "study approval was not saved"); }
1736
+ for (const child of result.children) {
1737
+ this.incomingBacklog.add(child.id);
1738
+ if (mode !== undefined) this.coordinationModes.set(child.id, mode);
1739
+ }
1740
+ this.releaseNotice(`study ${id} revision ${revision} approved; ${result.bindings.length} units`);
1741
+ await this.studyReoffer(result.bindings.map(unit => unit.taskId));
1742
+ return this.studyReceipt(result.campaign, true);
1743
+ });
1744
+ }
1745
+
1746
+ /**
1747
+ * 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
1748
+ * 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
1749
+ * refusal here is a notice, never a failure of the approval.
1750
+ */
1751
+ private async studyReoffer(taskIds: readonly number[]): Promise<void> {
1752
+ for (const taskId of taskIds) {
1753
+ let owner: PeerId | undefined;
1754
+ 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; }
1755
+ if (owner === undefined) continue;
1756
+ const task = this.d.board.get(taskId);
1757
+ // The record names an owner that no longer holds the unproposed task: it is stale, not a pending offer.
1758
+ if (!task || task.owner !== owner || task.state !== "proposed") { this.d.board.clearResearchDroppedOffer(taskId); continue; }
1759
+ try {
1760
+ await this.sendTask(task, { owner, ...(task.reviewer ? { reviewer: task.reviewer } : {}), trace: [] });
1761
+ this.d.board.clearResearchDroppedOffer(taskId); // after the offer is published: a refusal keeps the record for the next approval
1762
+ } catch (e) { this.releaseNotice(`task ${this.publicTitle(task)}: could not re-offer after the study was approved: ${e instanceof Error ? e.message : String(e)}`); }
1763
+ }
1764
+ }
1765
+
1766
+ async studyClose(by: PeerId, input: unknown): Promise<ResearchCampaignReceipt> {
1767
+ const requestCheck = this.requestCheck(by, () => this.studyHold()); this.plannerWriteAuthority(by);
1768
+ const fields = this.studyFields(input, ["id", "revision", "reason"]), id = this.studyId(fields.id), revision = this.studyNumber(fields.revision, "revision");
1769
+ if (typeof fields.reason !== "string" || !fields.reason.trim() || Buffer.byteLength(fields.reason, "utf8") > 512) throw new PreEffectToolRefusal("study close needs a bounded reason");
1770
+ const reason = fields.reason;
1771
+ return this.studyWrite(async () => {
1772
+ const original = this.studyLatest(id, revision), parent = this.studyParent(original.parentTaskId);
1773
+ this.plannerWriteAuthority(by, parent);
1774
+ const withheld = await this.withholds("note", reason, parent);
1775
+ requestCheck();
1776
+ const latest = this.studyLatest(id, revision), currentParent = this.studyParent(latest.parentTaskId);
1777
+ this.plannerWriteAuthority(by, currentParent);
1778
+ if (latest.hash !== original.hash || currentParent.history.length !== parent.history.length) throw new PreEffectToolRefusal("study changed during close screening");
1779
+ const priv = this.studyReplayPrivacy(latest, currentParent) || withheld || !this.nameable(reason);
1780
+ let closed: ResearchCampaignRecord;
1781
+ requestCheck(); this.plannerWriteAuthority(by, currentParent);
1782
+ try { closed = this.d.board.closeCampaign(id, revision, this.d.project, by, this.studyTime(), reason, currentParent.history.length, priv); }
1783
+ catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "study close was not saved"); }
1784
+ this.releaseNotice(`study ${id} revision ${revision} closed`);
1785
+ return this.studyReceipt(closed);
1786
+ });
1787
+ }
1788
+
1789
+ private experimentHash(input: unknown): string { return createHash("sha256").update(canonicalJson(input)).digest("hex"); }
1790
+
1791
+ private experimentId(input: unknown): string {
1792
+ if (typeof input !== "string" || !/^ra-[a-f0-9]{12}$/.test(input)) throw new PreEffectToolRefusal("experiment identity is invalid");
1793
+ return input;
1794
+ }
1795
+
1796
+ private experimentProtocol(hash: string): ExperimentProtocolRecord {
1797
+ try {
1798
+ const record = this.d.board.experimentProtocol(hash);
1799
+ if (record?.project === this.d.project) return record;
1800
+ } catch { /* damaged records are never returned as evidence */ }
1801
+ throw new PreEffectToolRefusal("experiment protocol is unavailable in this project");
1802
+ }
1803
+
1804
+ private experimentAttempt(id: string): ExperimentAttemptRecord {
1805
+ try {
1806
+ const record = this.d.board.experimentAttempt(id);
1807
+ if (record?.project === this.d.project) return record;
1808
+ } catch { /* fail closed without including any stored body */ }
1809
+ throw new PreEffectToolRefusal("experiment attempt is unavailable in this project");
1810
+ }
1811
+
1812
+ private experimentContext(by: PeerId, taskId: number, protocol: ExperimentProtocolContract, recordedForTaskId = taskId): ExperimentContext {
1813
+ const task = this.studyParent(taskId), bound = this.d.board.researchDispatchBinding(taskId);
1814
+ if (task.owner !== by || task.state !== "in_progress") throw new PreEffectToolRefusal("experiment records require the current in-progress task owner");
1815
+ if (this.d.privateTurn?.(by) && !this.isPii(task)) throw new PreEffectToolRefusal("a private turn cannot write experiment records on a public task");
1816
+ const campaign = bound?.campaign;
1817
+ // ponytail: new attempts against an older revision's protocol are refused; upgrade: explicit membership+source fence.
1818
+ if (!bound?.binding || !campaign || campaign.project !== this.d.project || campaign.id !== protocol.campaignId || campaign.revision !== protocol.campaignRevision || campaign.status !== "approved")
1819
+ throw new PreEffectToolRefusal("experiment task must be a unit of the protocol's current approved campaign revision");
1820
+ const recording = this.d.board.researchDispatchBinding(recordedForTaskId), origin = this.studyParent(recordedForTaskId);
1821
+ if (!recording?.binding || recording.campaign.id !== campaign.id || recording.campaign.revision !== campaign.revision || recording.campaign.status !== "approved")
1822
+ throw new PreEffectToolRefusal("experiment recording task is no longer a unit of this campaign");
1823
+ const sources = protocol.inputs.map(input => {
1824
+ const source = this.studySource(input.sourceId);
1825
+ 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");
1826
+ return source;
1827
+ });
1828
+ let artifactPrivate = false;
1829
+ if ("deliverable" in protocol.code) {
1830
+ const reference = protocol.code.deliverable, artifact = latestDeliverables(origin.history)?.find(value => value.id === reference.id && value.sha256 === reference.sha256);
1831
+ if (!artifact) throw new PreEffectToolRefusal("experiment code deliverable identity is not recorded for its task");
1832
+ artifactPrivate = artifact.private === true;
1833
+ }
1834
+ const privateRecord = this.studyReplayPrivacy(campaign, this.studyParent(campaign.parentTaskId)) || this.isPii(task) || this.isPii(origin) || artifactPrivate || sources.some(source => source.private);
1835
+ const fence: ExperimentWriteFence = { project: this.d.project, taskId, taskFlowRevision: task.history.length, owner: by,
1836
+ campaignId: campaign.id, campaignRevision: campaign.revision, campaignHash: campaign.hash };
1837
+ return { task, campaign, private: privateRecord, fence, key: JSON.stringify([fence, sources, privateRecord, origin.history.length, recording.binding]) };
1838
+ }
1839
+
1840
+ private experimentFresh(before: ExperimentContext, by: PeerId, protocol: ExperimentProtocolContract, recordedForTaskId?: number): ExperimentContext {
1841
+ const current = this.experimentContext(by, before.task.id, protocol, recordedForTaskId);
1842
+ if (current.key !== before.key) throw new PreEffectToolRefusal("experiment authority, source or campaign changed during admission");
1843
+ return current;
1844
+ }
1845
+
1846
+ private async experimentScreen(text: string, task: Task): Promise<void> {
1847
+ if (!this.isPii(task) && (!this.nameable(text) || looksSecret(text) || await this.withholds("note", text, task)))
1848
+ throw new PreEffectToolRefusal("experiment text was not cleared; nothing was recorded");
1849
+ }
1850
+
1851
+ private experimentPlanContext(by: PeerId, taskId: number, plan: ComparisonPlanV1): ExperimentContext {
1852
+ const task = this.studyParent(taskId), bound = this.d.board.researchDispatchBinding(taskId), campaign = bound?.campaign;
1853
+ if (task.owner !== by || task.state !== "in_progress" || !bound?.binding || !campaign || campaign.project !== this.d.project
1854
+ || campaign.id !== plan.campaignId || campaign.status !== "approved")
1855
+ throw new PreEffectToolRefusal("comparison plans require the current in-progress campaign unit owner");
1856
+ if (this.d.privateTurn?.(by) && !this.isPii(task)) throw new PreEffectToolRefusal("a private turn cannot write comparison plans on a public task");
1857
+ const sources = plan.fixed.inputs.map(input => {
1858
+ const source = this.studySource(input.sourceId);
1859
+ if (!campaign.contract.sourceIds.includes(input.sourceId) || !source || source.sha256 !== input.sha256)
1860
+ throw new PreEffectToolRefusal("comparison input source identity or hash is unavailable in this campaign");
1861
+ return source;
1862
+ });
1863
+ const privateRecord = this.studyReplayPrivacy(campaign, this.studyParent(campaign.parentTaskId)) || this.isPii(task) || sources.some(source => source.private);
1864
+ const fence: ExperimentWriteFence = { project: this.d.project, taskId, taskFlowRevision: task.history.length, owner: by,
1865
+ campaignId: campaign.id, campaignRevision: campaign.revision, campaignHash: campaign.hash };
1866
+ return { task, campaign, private: privateRecord, fence, key: canonicalJson([fence, privateRecord, sources, bound.binding]) };
1867
+ }
1868
+
1869
+ private experimentApproved(record: ExperimentProtocolRecord): boolean {
1870
+ const approval = this.d.board.experimentProtocolApproval(record.hash);
1871
+ return protocolApprovalBindingMatches(record.protocol, approval && { protocolHash: approval.protocolHash, evaluatorHash: approval.evaluatorHash });
1872
+ }
1873
+
1874
+ /** Approval is an explicit person's decision about immutable bytes, including historical protocols. */
1875
+ async experimentApproveProtocol(by: PeerId, input: unknown): Promise<ProtocolApprovalReceipt> {
1876
+ const requestCheck = this.requestCheck(by, () => this.studyHold());
1877
+ if (by !== USER) throw new PreEffectToolRefusal("protocol approval is person only");
1878
+ const fields = this.studyFields(input, ["hash"]);
1879
+ 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");
1880
+ const hash = fields.hash;
1881
+ return this.studyWrite(async () => {
1882
+ requestCheck();
1883
+ const record = this.experimentProtocol(hash), evaluatorHash = evaluatorIdentityHash(record.protocol);
1884
+ requestCheck();
1885
+ let saved;
1886
+ try { saved = this.d.board.approveExperimentProtocol(this.d.project, hash, evaluatorHash, by, this.studyTime()); }
1887
+ catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "protocol approval was not saved"); }
1888
+ return { protocolHash: saved.protocolHash, evaluatorHash: saved.evaluatorHash, approvedBy: saved.approvedBy, approvedAt: saved.at };
1889
+ });
1890
+ }
1891
+
1892
+ /** Record evidence only: this path never runs a check, a shell command or a runner. */
1893
+ async experimentRecord(by: PeerId, input: { kind: "plan"; [key: string]: unknown }): Promise<ComparisonPlanReceipt>;
1894
+ async experimentRecord(by: PeerId, input: { kind: "protocol" | "attempt" | "event"; [key: string]: unknown }): Promise<ExperimentReceipt>;
1895
+ async experimentRecord(by: PeerId, input: unknown): Promise<ExperimentReceipt | ComparisonPlanReceipt>;
1896
+ async experimentRecord(by: PeerId, input: unknown): Promise<ExperimentReceipt | ComparisonPlanReceipt> {
1897
+ const requestCheck = this.requestCheck(by, () => this.studyHold());
1898
+ const envelope = this.studyFields(input, ["kind", "taskId", "protocol", "attempt", "attemptId", "idempotencyKey", "event", "plan"]);
1899
+ return this.studyWrite(async () => {
1900
+ try {
1901
+ if (envelope.kind === "plan") {
1902
+ const fields = this.studyFields(input, ["kind", "taskId", "plan"]), taskId = this.studyNumber(fields.taskId, "taskId"), plan = parseComparisonPlan(fields.plan);
1903
+ const before = this.experimentPlanContext(by, taskId, plan);
1904
+ await this.experimentScreen(canonicalJson(plan), before.task); requestCheck();
1905
+ const current = this.experimentPlanContext(by, taskId, plan);
1906
+ if (current.key !== before.key) throw new PreEffectToolRefusal("comparison authority, source or campaign changed during admission");
1907
+ requestCheck();
1908
+ const saved = this.d.board.saveExperimentComparisonPlan({ hash: comparisonPlanHash(plan), project: this.d.project, recordedForTaskId: taskId,
1909
+ plan, by, at: this.studyTime(), private: current.private }, current.fence);
1910
+ return { kind: "plan", planHash: saved.hash };
1911
+ }
1912
+ if (envelope.kind === "protocol") {
1913
+ const fields = this.studyFields(input, ["kind", "taskId", "protocol"]), taskId = this.studyNumber(fields.taskId, "taskId"), protocol = parseExperimentProtocol(fields.protocol);
1914
+ const before = this.experimentContext(by, taskId, protocol);
1915
+ for (const reference of [protocol.variantOf, protocol.supersedes]) if (reference) {
1916
+ const previous = this.experimentProtocol(reference);
1917
+ if (previous.protocol.campaignId !== protocol.campaignId || reference === protocol.supersedes && previous.protocol.lineage !== protocol.lineage)
1918
+ throw new PreEffectToolRefusal("experiment protocol reference belongs to another campaign or lineage");
1919
+ }
1920
+ await this.experimentScreen(canonicalJson(protocol), before.task); requestCheck();
1921
+ const current = this.experimentFresh(before, by, protocol); requestCheck();
1922
+ 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);
1923
+ return { kind: "protocol", protocolHash: saved.hash };
1924
+ }
1925
+ if (envelope.kind === "attempt") {
1926
+ const fields = this.studyFields(input, ["kind", "attempt"]), attempt = parseExperimentAttempt(fields.attempt), record = this.experimentProtocol(attempt.protocolHash);
1927
+ const before = this.experimentContext(by, attempt.taskId, record.protocol, record.recordedForTaskId);
1928
+ for (const reference of [attempt.retryOf, attempt.reproducesAttemptId]) if (reference) {
1929
+ const previous = this.experimentAttempt(reference), protocol = this.experimentProtocol(previous.attempt.protocolHash);
1930
+ if (protocol.protocol.campaignId !== record.protocol.campaignId) throw new PreEffectToolRefusal("attempt reference belongs to another campaign");
1931
+ }
1932
+ await this.experimentScreen(canonicalJson(attempt), before.task); requestCheck();
1933
+ const current = this.experimentFresh(before, by, record.protocol, record.recordedForTaskId); requestCheck();
1934
+ 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);
1935
+ return { kind: "attempt", protocolHash: saved.attempt.protocolHash, attemptId: saved.attemptId };
1936
+ }
1937
+ if (envelope.kind === "event") {
1938
+ const fields = this.studyFields(input, ["kind", "attemptId", "idempotencyKey", "event"]), id = this.experimentId(fields.attemptId), key = fields.idempotencyKey;
1939
+ 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");
1940
+ const attempt = this.experimentAttempt(id), record = this.experimentProtocol(attempt.attempt.protocolHash), event = parseExperimentEvent(fields.event, record.protocol);
1941
+ const before = this.experimentContext(by, attempt.attempt.taskId, record.protocol, record.recordedForTaskId), lastSeq = this.d.board.experimentLastSeq(id);
1942
+ await this.experimentScreen(canonicalJson([key, event]), before.task); requestCheck();
1943
+ this.experimentFresh(before, by, record.protocol, record.recordedForTaskId);
1944
+ const observations: ExperimentHostEvidence[] = [];
1945
+ let outputPrivate = event.kind === "output" && "path" in event.body ? this.experimentOutputPrivate(before.task, event.body.path) : false;
1946
+ if (event.kind === "end" && event.body.run && this.d.experimentCheck) {
1947
+ let receipt: unknown;
1948
+ try { receipt = await this.d.experimentCheck(event.body.run); } catch { /* unavailable is unknown */ }
1949
+ requestCheck(); this.experimentFresh(before, by, record.protocol, record.recordedForTaskId);
1950
+ const observation = checkRunObservation(receipt, event.body.run, before.task.id, record.protocol);
1951
+ if (observation) observations.push(observation);
1952
+ } else if (event.kind === "output" && "path" in event.body) {
1953
+ try {
1954
+ const measured = await hashDeliverables([event.body], this.deliverablePorts(before.task));
1955
+ const output = measured[0];
1956
+ if (output) { observations.push(deliverableObservation(output)); outputPrivate ||= output.private === true; }
1957
+ } catch { /* #509 refusal, missing or unreadable means unknown; never use a declared fallback hash */ }
1958
+ requestCheck(); this.experimentFresh(before, by, record.protocol, record.recordedForTaskId);
1959
+ }
1960
+ const current = this.experimentFresh(before, by, record.protocol, record.recordedForTaskId); requestCheck();
1961
+ if (event.kind === "output" && "path" in event.body) outputPrivate ||= this.experimentOutputPrivate(current.task, event.body.path);
1962
+ const saved = this.d.board.appendExperimentEvent(id, key, event, observations, by, this.studyTime(), current.private || record.private || attempt.private || outputPrivate, lastSeq, current.fence);
1963
+ return { kind: "event", protocolHash: record.hash, attemptId: id, seq: saved.event.seq };
1964
+ }
1965
+ throw new PreEffectToolRefusal("unsupported experiment record kind");
1966
+ } catch (error) {
1967
+ if (error instanceof PreEffectToolRefusal) throw error;
1968
+ throw new PreEffectToolRefusal(error instanceof Error ? error.message : "experiment record was not saved");
1969
+ }
1970
+ });
1971
+ }
1972
+
1973
+ private experimentPrivate(record: ExperimentProtocolRecord, attempt: ExperimentAttemptRecord): boolean {
1974
+ try {
1975
+ const campaign = this.d.board.researchCampaign(record.protocol.campaignId, record.protocol.campaignRevision), task = this.d.board.get(attempt.attempt.taskId);
1976
+ return record.private || attempt.private || !campaign || this.studyReplayPrivacy(campaign, this.studyParent(campaign.parentTaskId)) || !task || this.isPii(task)
1977
+ || record.protocol.inputs.some(input => { const source = this.studySource(input.sourceId); return !source || source.private; });
1978
+ } catch { return true; }
1979
+ }
1980
+
1981
+ async experimentShow(by: PeerId, input: unknown): Promise<ExperimentView> {
1982
+ return this.experimentRead(by, input);
1983
+ }
1984
+
1985
+ private experimentRead(by: PeerId, input: unknown): ExperimentView {
1986
+ requireLocalTaskPeer(by);
1987
+ const fields = this.studyFields(input, ["attemptId", "campaignId", "after", "eventAfter", "limit", "planHash", "baselineAttemptId", "variantAttemptId"]);
1988
+ 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`); })();
1989
+ const after = cursor(fields.after, "after"), eventAfter = cursor(fields.eventAfter, "eventAfter"), limit = fields.limit === undefined ? 50 : this.studyNumber(fields.limit, "limit");
1990
+ if (limit > 50) throw new PreEffectToolRefusal("experiment readback is limited to 50 rows");
1991
+ if (fields.planHash !== undefined || fields.baselineAttemptId !== undefined || fields.variantAttemptId !== undefined) {
1992
+ if (fields.attemptId !== undefined || fields.campaignId !== undefined || fields.after !== undefined
1993
+ || typeof fields.planHash !== "string" || fields.planHash.length !== 64 || !/^[a-f0-9]{64}$/.test(fields.planHash))
1994
+ throw new PreEffectToolRefusal("comparison readback needs one plan hash and two attempt ids");
1995
+ const plan = this.d.board.experimentComparisonPlan(fields.planHash);
1996
+ if (!plan || plan.project !== this.d.project) throw new PreEffectToolRefusal("comparison plan is unavailable in this project");
1997
+ const baseline = this.experimentAttempt(this.experimentId(fields.baselineAttemptId)), variant = this.experimentAttempt(this.experimentId(fields.variantAttemptId));
1998
+ const first = this.experimentProtocol(baseline.attempt.protocolHash), second = this.experimentProtocol(variant.attempt.protocolHash);
1999
+ if (first.protocol.campaignId !== plan.plan.campaignId || second.protocol.campaignId !== plan.plan.campaignId)
2000
+ throw new PreEffectToolRefusal("comparison attempts must belong to the plan's campaign");
2001
+ const left = this.experimentRead(by, { attemptId: baseline.attemptId, eventAfter, limit });
2002
+ const right = this.experimentRead(by, { attemptId: variant.attemptId, eventAfter, limit });
2003
+ let planPrivate = true;
2004
+ try {
2005
+ const campaign = this.studyRecord(plan.plan.campaignId);
2006
+ planPrivate = plan.private || this.studyReplayPrivacy(campaign, this.studyParent(campaign.parentTaskId))
2007
+ || plan.plan.fixed.inputs.some(input => { const source = this.studySource(input.sourceId); return !source || source.private; });
2008
+ } catch { /* an unavailable privacy classification withholds the comparison */ }
2009
+ const privateRecord = planPrivate || left.attempts.some(row => row.private) || right.attempts.some(row => row.private);
2010
+ return { attempts: [...left.attempts, ...right.attempts], counts: left.counts,
2011
+ comparison: { planHash: plan.hash, baselineAttemptId: baseline.attemptId, variantAttemptId: variant.attemptId,
2012
+ approved: this.experimentApproved(first) && this.experimentApproved(second), private: privateRecord,
2013
+ projection: privateRecord ? "[pii]" : comparisonProjection(plan.plan, first.protocol, second.protocol) } };
2014
+ }
2015
+ let campaignId: string, attempts: ExperimentAttemptRecord[], nextCursor: number | undefined;
2016
+ if (fields.attemptId !== undefined) {
2017
+ if (fields.campaignId !== undefined || fields.after !== undefined) throw new PreEffectToolRefusal("choose one attempt or campaign page");
2018
+ const attempt = this.experimentAttempt(this.experimentId(fields.attemptId));
2019
+ campaignId = this.experimentProtocol(attempt.attempt.protocolHash).protocol.campaignId; attempts = [attempt];
2020
+ } else {
2021
+ campaignId = this.studyId(fields.campaignId); this.studyRecord(campaignId);
2022
+ const page = this.d.board.experimentAttempts(this.d.project, campaignId, after, limit); attempts = page.rows;
2023
+ if (!page.done) nextCursor = page.afterRow;
2024
+ }
2025
+ const summary = this.d.board.experimentSummary(this.d.project, campaignId), views: ExperimentAttemptView[] = [];
2026
+ for (const attempt of attempts) {
2027
+ const record = this.experimentProtocol(attempt.attempt.protocolHash), page = this.d.board.experimentEvents(attempt.attemptId, eventAfter, limit);
2028
+ const privateRecord = this.experimentPrivate(record, attempt) || page.rows.some(row => row.private);
2029
+ const status = this.d.board.experimentAttemptStatus(attempt.attemptId);
2030
+ const observations = page.rows.flatMap(row => row.observations), events = page.rows.map(row => row.event);
2031
+ const evidence = projectExperimentWithObservations(record.protocol, events, observations);
2032
+ views.push({ id: attempt.attemptId, protocolHash: record.hash, taskId: attempt.attempt.taskId, phase: attempt.attempt.phase, status, private: privateRecord,
2033
+ campaignRevision: record.protocol.campaignRevision, approval: this.experimentApproved(record) ? "approved" : "unapproved",
2034
+ postHoc: this.d.board.experimentProtocolContext(record.hash).state,
2035
+ ...(attempt.attempt.retryOf ? { retryOf: attempt.attempt.retryOf } : {}), ...(attempt.attempt.reproducesAttemptId ? { reproducesAttemptId: attempt.attempt.reproducesAttemptId } : {}), independentlyReproduced: false,
2036
+ protocol: { value: privateRecord ? "[pii]" : record.protocol, provenance: "declared" },
2037
+ 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),
2038
+ evidence: privateRecord ? { declared: "[pii]", measured: evidence.measured.map(row => ({ eventSeq: row.eventSeq, source: row.source, provenance: "host-observed", values: "[pii]" })) } : evidence,
2039
+ eventsComplete: page.done, ...(!page.done ? { nextEventCursor: page.afterSeq } : {}) });
2040
+ }
2041
+ return { attempts: views, counts: { all: summary.total, statuses: summary.statuses }, ...(nextCursor === undefined ? {} : { nextCursor }) };
2042
+ }
2043
+
1229
2044
  /** Every new host proposal, including split children, consumes the same pure walk before its first notification. */
1230
2045
  private proposalPlanner(task: Task): PlannerBinding {
1231
2046
  const choice = assign({ ...task, state: "proposed" }, this.states(), this.d.routing(), { ...this.weights(task.class, task), candidates: [] });
@@ -1449,7 +2264,7 @@ export class Tasks {
1449
2264
  const states = this.states();
1450
2265
  const full = this.plannerFull(task);
1451
2266
  // 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 } : {}) });
2267
+ 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
2268
  }
1454
2269
 
1455
2270
  async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }, opts: { onBehalfOf?: PeerId; guard?: () => void } = {}): Promise<Task> {
@@ -1491,6 +2306,7 @@ export class Tasks {
1491
2306
  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
2307
  throw new PreEffectToolRefusal(`${this.hasPersonReservation(task) ? "a person's reservation is retained; " : ""}${refusal}`);
1493
2308
  }
2309
+ this.studyDispatchCheck(task, assignment.owner);
1494
2310
  return { task, owner: assignment.owner, assignment };
1495
2311
  };
1496
2312
  const initial = validate(), withheld = await this.withholds("note", input.reason, initial.task);
@@ -1505,6 +2321,7 @@ export class Tasks {
1505
2321
  } } });
1506
2322
  }
1507
2323
  opts.guard?.();
2324
+ this.studyDispatchCheck(current.task, current.owner);
1508
2325
  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
2326
  this.planningRecorded({ by, task: pulled.id, fields: ["pull", "reserved"], revision: pulled.planningRevision! });
1510
2327
  return pulled;
@@ -2489,6 +3306,12 @@ export class Tasks {
2489
3306
  }
2490
3307
  if (env.from === HUB && env.refs?.task) {
2491
3308
  const task = this.d.board.get(Number(env.refs.task));
3309
+ if (env.kind === "task" && env.refs.taskOffer !== undefined && task?.owner === peer && this.studyDispatchReason(task, peer)) {
3310
+ // 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.
3311
+ try { this.d.board.recordResearchDroppedOffer(task.id, peer); }
3312
+ catch (e) { this.releaseNotice(`task ${this.publicTitle(task)}: could not record the dropped study offer: ${e instanceof Error ? e.message : String(e)}`); }
3313
+ return false;
3314
+ }
2492
3315
  if (task?.state === "cancelled") return env.kind === "task" && typeof env.refs.taskCancelled === "number" && env.refs.taskCancelled === task.cancelled?.flowRevision;
2493
3316
  if (env.refs.taskRecipient === "planner") return !!task && this.taskPlanner(task).includes(peer);
2494
3317
  if (env.refs.planReview !== undefined) return !!task && !isClosedTaskState(task.state) && (task.planningRevision ?? 0) === env.refs.planReview
@@ -2728,6 +3551,12 @@ export class Tasks {
2728
3551
  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
3552
  opts.current?.();
2730
3553
  if (task.state === "backlog" && !opts.plannerPull) throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
3554
+ const studyReason = this.studyDispatchReason(task, opts.namedOwner);
3555
+ if (studyReason) {
3556
+ if (opts.refuseNamed || opts.plannerPull || opts.claim) throw new PreEffectToolRefusal(studyReason);
3557
+ if (task.state === "proposed" && !task.owner) this.holdReady(task, "study dispatch", studyReason);
3558
+ return task;
3559
+ }
2731
3560
  const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
2732
3561
  let stagedClaimGap: string | undefined;
2733
3562
  if (mode !== "off" && this.isBuildTask(task)) {
@@ -2755,7 +3584,7 @@ export class Tasks {
2755
3584
  const waits = this.waitsFor(task);
2756
3585
  const plannedMove = opts.planner || this.plannerPullRecord(task)?.pull === true && (by === HUB || opts.roster) && (opts.reason !== "manual" || opts.roster);
2757
3586
  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.
3587
+ 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
3588
  contextOverride: !!(opts.queueOverride || opts.claim),
2760
3589
  ...(opts.queueOverride || opts.claim ? { queueOverride: opts.queueOverride ?? "owner" as const } : {}) });
2761
3590
 
@@ -2795,6 +3624,7 @@ export class Tasks {
2795
3624
  const binding = this.plannerBinding(task, a, !!opts.plannerPull);
2796
3625
  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
3626
  opts.current?.();
3627
+ this.studyDispatchCheck(this.d.board.get(task.id) ?? task, a.owner);
2798
3628
  const next = opts.plannerPull
2799
3629
  ? 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
3630
  : 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 +3687,7 @@ export class Tasks {
2857
3687
  this.releaseNotice(this.specText(next, "missing", "advice"));
2858
3688
  }
2859
3689
  if (stagedClaimGap) return next; // An explicit claim may reserve its owner, but no offer or acceptance bypasses review.
3690
+ this.studyDispatchCheck(next, by);
2860
3691
  const claimed = this.d.board.update(next.id, by, "accepted", { state: "in_progress" });
2861
3692
  this.captureOwnerAcceptance(claimed);
2862
3693
  this.releaseNotice(`task ${this.publicTitle(claimed)} claimed by ${by}`);
@@ -2917,6 +3748,7 @@ export class Tasks {
2917
3748
  currentAuthority?.();
2918
3749
  const current = this.d.board.get(task.id);
2919
3750
  if (!current || !this.ownerAssignmentCurrent(task, current)) throw new AssignmentSupersededError(current ?? task);
3751
+ this.studyDispatchCheck(current, current.owner ?? undefined);
2920
3752
  task = current;
2921
3753
  const pii = this.isPii(task), delivery = { ...a, reviewer: task.reviewer ?? undefined };
2922
3754
  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 +3777,10 @@ export class Tasks {
2945
3777
  const idle = this.d.bus.stateOf(owner) === "idle";
2946
3778
  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
3779
  currentAuthority?.();
3780
+ this.studyDispatchCheck(task, owner);
2948
3781
  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
3782
  currentAuthority?.();
3783
+ this.studyDispatchCheck(task, owner);
2950
3784
  this.d.bus.publish(env);
2951
3785
  if (idle && this.d.bus.stateOf(owner) === "busy" && !this.d.bus.queueIds(owner).includes(env.id)) this.sent.set(handOver(task, owner), owner);
2952
3786
  }
@@ -3767,7 +4601,7 @@ export class Tasks {
3767
4601
  for (const entry of entries) {
3768
4602
  const row = store.ensure(entry.id, now);
3769
4603
  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;
4604
+ if (now < scheduleNext(entry, row.anchor)) continue;
3771
4605
  out.push(await this.runSchedule(store, entry, now, extraHold));
3772
4606
  }
3773
4607
  } finally { this.schedulesRunning = false; }
@@ -4566,6 +5400,7 @@ export class Tasks {
4566
5400
  this.mine(task, by, "owner", true);
4567
5401
  this.ready(task, true);
4568
5402
  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`);
5403
+ this.studyDispatchCheck(task, task.owner ?? undefined);
4569
5404
  this.acceptanceCapacity(task);
4570
5405
  // S1 spec acceptance and S9 plan review are independent prerequisites; retain S1's missing-spec refusal first.
4571
5406
  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 +5428,7 @@ export class Tasks {
4593
5428
  this.warmRepository(draft);
4594
5429
  const repositoryChanged = this.repositories.scope(task.refs).key !== this.repositories.scope(draft.refs).key;
4595
5430
  const before = new Set(this.overlapHits(task).map((h) => h.task.id));
5431
+ this.studyDispatchCheck(task, task.owner ?? undefined);
4596
5432
  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
5433
  this.captureOwnerAcceptance(next);
4598
5434
  this.d.notify(`task ${this.publicTitle(next)} accepted by ${by}`);
@@ -4745,13 +5581,23 @@ export class Tasks {
4745
5581
  }
4746
5582
 
4747
5583
  /** 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 {
5584
+ private deliverablePorts(task: Task, policy: { failClosedOnDiscoveryLimit?: boolean } = {}): DeliverablePorts {
4749
5585
  const taskPrivate = this.isPii(task);
4750
5586
  // The owner's own root: a declared worktree instance writes under its worktree, so its deliverables are hashed there, never in the canonical
4751
5587
  // checkout (which holds a different file or none). With the root unproven there is nothing safe to hash: refuse rather than guess.
4752
5588
  const root = !this.d.executionRoot || !task.owner || task.owner === USER ? this.d.cwd : this.d.executionRoot(task.owner);
4753
5589
  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) };
5590
+ return { cwd: root, deny: this.d.deny ?? [], private: path => taskPrivate || matchesPolicy(root, path, this.d.piiPaths ?? [], true, policy) };
5591
+ }
5592
+
5593
+ /** Path privacy exists even when measurement cannot open the output. Use the #509 policies on both names. */
5594
+ private experimentOutputPrivate(task: Task, path: string): boolean {
5595
+ try {
5596
+ const ports = this.deliverablePorts(task, { failClosedOnDiscoveryLimit: true }), root = realPath(ports.cwd), deny = [...ports.deny];
5597
+ const typed = relative(root, resolve(root, path)), actual = relative(root, policyRealPath(resolve(root, path)));
5598
+ return !insideRoot(typed) || !insideRoot(actual) || ports.private(path)
5599
+ || [typed, actual].some(name => isHubWritePath(name) || isDenied(name, deny) || isDenied(foldSegment(name), deny.map(foldSegment)));
5600
+ } catch { return true; }
4755
5601
  }
4756
5602
 
4757
5603
  /**
@@ -4782,13 +5628,26 @@ export class Tasks {
4782
5628
  /** Whether a completion check is still running for this task. */
4783
5629
  isChecking = (id: number) => this.checking.has(id);
4784
5630
 
4785
- private completionCurrent(id: unknown, by: PeerId, preEffect: boolean): Task {
5631
+ /** Invalidate stale artifact evidence without changing spec acceptance or open findings. */
5632
+ private completionDraft(task: Task, refresh?: CompletionRefresh): Task {
5633
+ if (!refresh || !task.sdlc) return task;
5634
+ const sdlc = { ...task.sdlc };
5635
+ if (sdlc.head !== refresh.commit) { delete sdlc.head; delete sdlc.observed; }
5636
+ if (sdlc.reviewedHead !== refresh.commit) delete sdlc.reviewedHead;
5637
+ if (sdlc.localHead !== refresh.commit) { delete sdlc.localHead; delete sdlc.localResult; }
5638
+ delete sdlc.merge;
5639
+ return { ...task, sdlc };
5640
+ }
5641
+
5642
+ private completionCurrent(id: unknown, by: PeerId, preEffect: boolean, refresh?: CompletionRefresh): Task {
4786
5643
  const task = this.need(id, false, preEffect);
4787
5644
  this.mine(task, by, "owner", preEffect);
5645
+ if (refresh && (task.state !== "in_review" || task.history.length !== refresh.revision))
5646
+ throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: review changed during re-file; retry on current work`);
4788
5647
  if (preEffect) {
4789
- if (task.state === "in_review" || isClosedTaskState(task.state)) throw new PreEffectToolRefusal(`task #${task.id} is already ${task.state}`);
5648
+ if (task.state === "in_review" && !refresh || isClosedTaskState(task.state)) throw new PreEffectToolRefusal(`task #${task.id} is already ${task.state}`);
4790
5649
  } else {
4791
- if (task.state === "in_review" || isClosedTaskState(task.state)) throw new Error(`task #${task.id} is already ${task.state}`);
5650
+ if (task.state === "in_review" && !refresh || isClosedTaskState(task.state)) throw new Error(`task #${task.id} is already ${task.state}`);
4792
5651
  }
4793
5652
  this.ready(task, preEffect);
4794
5653
  if (task.state === "proposed" || task.state === "changes_requested") this.acceptanceCapacity(task);
@@ -4796,12 +5655,21 @@ export class Tasks {
4796
5655
  // The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
4797
5656
  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
5657
  }
4799
- return task;
5658
+ return this.completionDraft(task, refresh);
4800
5659
  }
4801
5660
 
4802
5661
  async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs, deliverables?: unknown): Promise<Task> {
4803
5662
  const requestCheck = this.requestCheck(by);
4804
- const valid = () => { requestCheck(); return this.completionCurrent(id, by, true); };
5663
+ const initial = this.need(id, false, true);
5664
+ this.mine(initial, by, "owner", true);
5665
+ let refresh: CompletionRefresh | undefined;
5666
+ if (initial.state === "in_review") {
5667
+ const commit = cleanRefs(refs).commit;
5668
+ if (!commit || !/^[a-f0-9]{40}$/.test(commit) || commit === this.reviewHead(initial))
5669
+ throw new PreEffectToolRefusal(`task #${initial.id} is already in_review; re-file requires a different full head SHA`);
5670
+ refresh = { revision: initial.history.length, commit };
5671
+ }
5672
+ const valid = () => { requestCheck(); return this.completionCurrent(id, by, true, refresh); };
4805
5673
  // #509a: a bad claim (a claimed hash, an outside or denied path, a missing file) is refused before any effect, with nothing recorded.
4806
5674
  const claims = deliverables === undefined ? undefined : this.deliverableClaims(deliverables, valid());
4807
5675
  // The PII screen reads the summary of a done that passes these checks (issue #198); they run again after it.
@@ -4823,7 +5691,7 @@ export class Tasks {
4823
5691
  task = current();
4824
5692
  let reviewEvidence: PrEvidence | undefined;
4825
5693
  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;
5694
+ const reviewAdmission = task.reviewer || this.d.prepareReviewBrief ? await this.reviewRequestAdmission(task, refs, true, by, reviewEvidence, undefined, staged => this.withLink(this.completionDraft(staged, refresh), link, refs), !task.reviewer) : undefined;
4827
5695
  task = current();
4828
5696
  // A member of a silent cohort (issue #107): its done is an intent, and the last of them integrates first.
4829
5697
  const cohort = this.cohorts.of(task.id);
@@ -4855,12 +5723,13 @@ export class Tasks {
4855
5723
  }
4856
5724
  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
5725
  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);
5726
+ if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission, link, records, requestCheck, refresh);
4859
5727
  // The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
4860
5728
  this.checking.set(task.id, task.owner);
4861
5729
  const checkedRefs = cleanRefs(refs);
4862
5730
  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);
5731
+ const pending = this.d.board.update(task.id, by, "done (checking)", { ...(refresh ? { state: "in_progress" as const } : {}), 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);
5732
+ if (refresh) { this.ciWatchers.cancel(pending.id); this.heldReviews.delete(pending.id); }
4864
5733
  this.releaseNotice(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
4865
5734
  const seen = { events: pending.history.length, owner: pending.owner };
4866
5735
  this.pendingChecks++;
@@ -5005,7 +5874,7 @@ export class Tasks {
5005
5874
  }
5006
5875
 
5007
5876
  /** `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> {
5877
+ 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
5878
  requestCheck();
5010
5879
  // A check's completion carries the records its `done (checking)` bound: the same bytes the owner delivered, not a later read.
5011
5880
  records ??= latestPendingDeliverables(task);
@@ -5014,15 +5883,15 @@ export class Tasks {
5014
5883
  if (this.d.sdlc && this.d.sdlc.config().mode !== "off" && gated !== (reviewer ? "review" : "approval")) {
5015
5884
  reviewEvidence = await this.lifecycleGate(task, reviewer ? "review" : "approval");
5016
5885
  requestCheck();
5017
- task = this.withLink(this.completionCurrent(task.id, by, false), link, refs);
5886
+ task = this.withLink(this.completionCurrent(task.id, by, false, refresh), link, refs);
5018
5887
  selection = this.poolReview(task, refs);
5019
5888
  reviewer = selection?.reviewer ?? task.reviewer;
5020
5889
  }
5021
5890
  // A delayed local check needs a new readback; a completion-selected reviewer also needs optional facts in off mode.
5022
5891
  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);
5892
+ admission = await this.reviewRequestAdmission(task, refs, false, by, reviewEvidence, undefined, staged => this.withLink(this.completionDraft(staged, refresh), link, refs), !reviewer);
5024
5893
  requestCheck();
5025
- task = this.withLink(this.completionCurrent(task.id, by, false), link, refs);
5894
+ task = this.withLink(this.completionCurrent(task.id, by, false, refresh), link, refs);
5026
5895
  }
5027
5896
  selection = this.poolReview(task, refs);
5028
5897
  reviewer = selection?.reviewer ?? task.reviewer;
@@ -5478,11 +6347,13 @@ export class Tasks {
5478
6347
  // inherited reviewedHead lives there).
5479
6348
  let covered: { commit: string | undefined; receipt: string } | undefined;
5480
6349
  let conformanceIdentity: string | undefined;
6350
+ let reviewRound: number | undefined;
5481
6351
  const valid = (): Task => {
5482
6352
  requestCheck();
5483
6353
  const refused = authority?.();
5484
6354
  if (refused) throw new PreEffectToolRefusal(refused); // rechecked after every await, right before each write
5485
6355
  const t = this.need(id, false, true);
6356
+ if (reviewRound !== undefined && this.reviewRequestRevision(t) !== reviewRound) throw new PreEffectToolRefusal(`task #${t.id}: review request changed during the verdict; review the new head`);
5486
6357
  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
6358
  this.mine(t, by, "reviewer", true);
5488
6359
  if (verdict !== "approved" && verdict !== "changes_requested") throw new PreEffectToolRefusal('verdict must be "approved" or "changes_requested"');
@@ -5498,6 +6369,7 @@ export class Tasks {
5498
6369
  // the gap in the verdict note, off and a person's own verdict skip it.
5499
6370
  const mode = this.d.sdlc?.config().mode ?? "off";
5500
6371
  const initial = valid(), suppliedFindings = findings !== undefined;
6372
+ reviewRound = this.reviewRequestRevision(initial);
5501
6373
  let parsed: ReviewFinding[];
5502
6374
  try { parsed = parseReviewFindings(findings, { person: by === USER, maxNits: this.d.sdlc?.config().maxNits ?? DEFAULT_MAX_NITS }); }
5503
6375
  catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "invalid structured findings"); }