@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.
- package/CHANGELOG.md +13 -0
- package/README.md +27 -5
- package/docs/agent-notes/adapters.md +1 -0
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +15 -2
- package/docs/agent-notes/tasks.md +9 -2
- package/docs/agent-notes/tests.md +2 -1
- package/docs/dogfood.md +12 -2
- package/docs/memory.md +1 -1
- package/docs/operations.md +137 -4
- package/docs/profiles.md +2 -0
- package/docs/quickstart.md +10 -3
- package/docs/security.md +155 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +25 -1
- package/docs/specs/2026-09-19-multi-project-design.md +12 -1
- package/docs/specs/2026-11-01-research-campaigns-design.md +351 -0
- package/docs/verification/2026-11-01-0.25.0.md +91 -0
- package/docs/verified.json +31 -15
- package/package.json +4 -3
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +5118 -4708
- package/src/adapters/claude-channel.ts +29 -6
- package/src/adapters/definitions/native-cli-doctor.ts +30 -0
- package/src/adapters/jcode.ts +3 -0
- package/src/adapters/registry.ts +1 -1
- package/src/cli/acon-register.ts +29 -0
- package/src/cli/handoff-args.ts +27 -0
- package/src/cli/handoff-output.ts +25 -0
- package/src/cli/help.ts +6 -3
- package/src/cli/identity.ts +7 -1
- package/src/cli/main.ts +76 -7
- package/src/cli/output.ts +8 -5
- package/src/cli/schedule-output.ts +8 -4
- package/src/cli/study-output.ts +71 -0
- package/src/cli/study.ts +58 -0
- package/src/cli/task-output.ts +1 -0
- package/src/hub/board.ts +871 -2
- package/src/hub/channel-project.ts +61 -0
- package/src/hub/control-client.ts +10 -1
- package/src/hub/daemon.ts +247 -11
- package/src/hub/envelope.ts +2 -0
- package/src/hub/events.ts +4 -1
- package/src/hub/execution-budget.ts +12 -0
- package/src/hub/handoff.ts +126 -0
- package/src/hub/hub-tools.ts +12 -2
- package/src/hub/manager.ts +64 -57
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/path-policy.ts +12 -8
- package/src/hub/project.ts +14 -5
- package/src/hub/provenance.ts +30 -0
- package/src/hub/research-campaign.ts +265 -0
- package/src/hub/research-experiments.ts +445 -0
- package/src/hub/restart.ts +26 -0
- package/src/hub/schedules.ts +210 -9
- package/src/hub/session-identity.ts +49 -0
- package/src/hub/tasks.ts +894 -22
- package/src/hub/ui.ts +15 -6
- package/src/memory/capture.ts +2 -2
- package/src/ui/index.html +5 -3
- package/src/ui/office-space.ts +118 -0
- package/src/ui/plugin.ts +293 -0
- package/templates/AGENTS.block.md +1 -0
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +4 -4
- package/templates/profiles/writing.json +1 -1
- package/templates/roles/research/analyst.md +1 -0
- package/templates/roles/research/fact-checker.md +1 -0
- package/templates/roles/research/research-lead.md +1 -0
- package/templates/roles/research/researcher.md +1 -0
- 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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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"); }
|