@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
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { types } from "node:util";
|
|
3
|
+
import { canonicalJson } from "./packs.ts";
|
|
4
|
+
|
|
5
|
+
export type ResearchCampaignStatus = "draft" | "approved" | "superseded" | "closed";
|
|
6
|
+
export interface ResearchUnit {
|
|
7
|
+
id: string;
|
|
8
|
+
round: number;
|
|
9
|
+
question: string;
|
|
10
|
+
perspective: string;
|
|
11
|
+
requiredOutput: string;
|
|
12
|
+
sourceIds: string[];
|
|
13
|
+
dependsOn: string[];
|
|
14
|
+
purpose: "discovery" | "independent_check" | "analysis" | "synthesis";
|
|
15
|
+
adoptTaskId?: number;
|
|
16
|
+
}
|
|
17
|
+
export interface ResearchCampaignContract {
|
|
18
|
+
question: string;
|
|
19
|
+
scope: string;
|
|
20
|
+
exclusions: string[];
|
|
21
|
+
sourceIds: string[];
|
|
22
|
+
units: ResearchUnit[];
|
|
23
|
+
deliverableSections: { id: string; title: string; ownerUnitId: string }[];
|
|
24
|
+
stopPolicy: { maxUnits: number; maxFollowUpRounds: number; deadline: string };
|
|
25
|
+
budget?: { id: string; hardCap: ("model_calls" | "tool_calls" | "elapsed_ms" | "tokens")[] };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ID = /^[a-z0-9][a-z0-9_-]{0,63}$/, SOURCE = /^src:[0-9a-f]{16}$/;
|
|
29
|
+
const SHORT = 512, LONG = 4096, BODY = 64 * 1024;
|
|
30
|
+
const PURPOSES = ["discovery", "independent_check", "analysis", "synthesis"] as const;
|
|
31
|
+
const RESOURCES = ["model_calls", "tool_calls", "elapsed_ms", "tokens"] as const;
|
|
32
|
+
function fail(field: string, reason: string): never { throw new Error(`${field}: ${reason}`); }
|
|
33
|
+
|
|
34
|
+
/** Only inert JSON-shaped data reaches validation or canonical hashing; never invoke a caller's accessor/toJSON. */
|
|
35
|
+
function record(input: unknown, keys: readonly string[], field: string): Record<string, unknown> {
|
|
36
|
+
if (!input || typeof input !== "object" || Array.isArray(input) || types.isProxy(input) ||
|
|
37
|
+
![Object.prototype, null].includes(Object.getPrototypeOf(input)) || Object.getOwnPropertySymbols(input).length ||
|
|
38
|
+
Object.values(Object.getOwnPropertyDescriptors(input)).some(descriptor => !Object.hasOwn(descriptor, "value"))) fail(field, "expected a plain data object");
|
|
39
|
+
for (const key of Object.keys(input)) if (!keys.includes(key)) {
|
|
40
|
+
const named = key === key.trim() && /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(key) ? `${field}.${key}` : field;
|
|
41
|
+
fail(named, "unknown field");
|
|
42
|
+
}
|
|
43
|
+
return input as Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
function list(input: unknown, max: number, field: string, min = 0): unknown[] {
|
|
46
|
+
if (!Array.isArray(input) || types.isProxy(input) || Object.getPrototypeOf(input) !== Array.prototype || input.length < min || input.length > max || Object.getOwnPropertySymbols(input).length) fail(field, `expected a list of ${min} to ${max} items`);
|
|
47
|
+
const descriptors = Object.getOwnPropertyDescriptors(input);
|
|
48
|
+
if (Object.keys(descriptors).some(key => key !== "length" && !/^(0|[1-9][0-9]*)$/.test(key)) ||
|
|
49
|
+
Array.from({ length: input.length }, (_, index) => descriptors[String(index)]).some(descriptor => !descriptor || !Object.hasOwn(descriptor, "value"))) fail(field, "expected a dense data list");
|
|
50
|
+
return Array.from({ length: input.length }, (_, index) => descriptors[String(index)]!.value);
|
|
51
|
+
}
|
|
52
|
+
const utf8 = (value: string) => Buffer.from(value, "utf8").toString("utf8") === value;
|
|
53
|
+
function text(input: unknown, max: number, field: string): string {
|
|
54
|
+
if (typeof input !== "string" || input.length > max || Buffer.byteLength(input, "utf8") > max || !input.trim() || !utf8(input)) fail(field, `expected non-empty UTF-8 text of at most ${max} bytes`);
|
|
55
|
+
return input;
|
|
56
|
+
}
|
|
57
|
+
function id(input: unknown, field: string, source = false): string {
|
|
58
|
+
if (typeof input !== "string" || input !== input.trim() || !(source ? SOURCE : ID).test(input)) fail(field, source ? "expected a source id (src:<16 hex>)" : "expected a lowercase id of 1 to 64 characters");
|
|
59
|
+
return input;
|
|
60
|
+
}
|
|
61
|
+
function integer(input: unknown, min: number, max: number, field: string): number {
|
|
62
|
+
if (typeof input !== "number" || !Number.isSafeInteger(input) || input < min || input > max) fail(field, `expected an integer from ${min} to ${max}`);
|
|
63
|
+
return input;
|
|
64
|
+
}
|
|
65
|
+
function distinct(values: readonly (string | number)[], field: string): void {
|
|
66
|
+
if (new Set(values).size !== values.length) fail(field, "duplicate values");
|
|
67
|
+
}
|
|
68
|
+
function ids(input: unknown, max: number, field: string, source = false): string[] {
|
|
69
|
+
const values = list(input, max, field).map((item, index) => id(item, `${field}[${index}]`, source));
|
|
70
|
+
distinct(values, field);
|
|
71
|
+
return values;
|
|
72
|
+
}
|
|
73
|
+
function deadline(input: unknown, field: string): string {
|
|
74
|
+
const value = text(input, SHORT, field);
|
|
75
|
+
const match = /^(\d{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.[0-9]+)?([Zz]|[+-][0-9]{2}:[0-9]{2})$/.exec(value);
|
|
76
|
+
if (!match || match[0] !== value) fail(field, "expected an ISO timestamp with an explicit time zone");
|
|
77
|
+
const year = Number(match[1]), month = Number(match[2]), day = Number(match[3]);
|
|
78
|
+
const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
79
|
+
const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
|
|
80
|
+
if (!days || day < 1 || day > days || Number(match[4]) > 23 || Number(match[5]) > 59 || Number(match[6]) > 59 || !Number.isFinite(Date.parse(value))) fail(field, "expected a valid ISO timestamp");
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Structural contract only. Source existence, adoption authority, approval time and handoff advancement belong to Tasks. */
|
|
85
|
+
export function parseResearchCampaign(input: unknown): ResearchCampaignContract {
|
|
86
|
+
const raw = record(input, ["question", "scope", "exclusions", "sourceIds", "units", "deliverableSections", "stopPolicy", "budget"], "campaign");
|
|
87
|
+
const stop = record(raw.stopPolicy, ["maxUnits", "maxFollowUpRounds", "deadline"], "campaign.stopPolicy");
|
|
88
|
+
const stopPolicy = {
|
|
89
|
+
maxUnits: integer(stop.maxUnits, 1, 32, "campaign.stopPolicy.maxUnits"),
|
|
90
|
+
maxFollowUpRounds: integer(stop.maxFollowUpRounds, 0, 5, "campaign.stopPolicy.maxFollowUpRounds"),
|
|
91
|
+
deadline: deadline(stop.deadline, "campaign.stopPolicy.deadline"),
|
|
92
|
+
};
|
|
93
|
+
const sourceIds = ids(raw.sourceIds, 64, "campaign.sourceIds", true), sourceSet = new Set(sourceIds);
|
|
94
|
+
const units = list(raw.units, 32, "campaign.units", 1).map((value, index): ResearchUnit => {
|
|
95
|
+
const field = `campaign.units[${index}]`, unit = record(value, ["id", "round", "question", "perspective", "requiredOutput", "sourceIds", "dependsOn", "purpose", "adoptTaskId"], field);
|
|
96
|
+
if (typeof unit.purpose !== "string" || !(PURPOSES as readonly string[]).includes(unit.purpose)) fail(`${field}.purpose`, "expected discovery, independent_check, analysis or synthesis");
|
|
97
|
+
const sources = ids(unit.sourceIds, 16, `${field}.sourceIds`, true);
|
|
98
|
+
if (sources.some(source => !sourceSet.has(source))) fail(`${field}.sourceIds`, "must be a subset of campaign.sourceIds");
|
|
99
|
+
return {
|
|
100
|
+
id: id(unit.id, `${field}.id`), round: integer(unit.round, 0, stopPolicy.maxFollowUpRounds, `${field}.round`),
|
|
101
|
+
question: text(unit.question, LONG, `${field}.question`), perspective: text(unit.perspective, SHORT, `${field}.perspective`), requiredOutput: text(unit.requiredOutput, SHORT, `${field}.requiredOutput`),
|
|
102
|
+
sourceIds: sources, dependsOn: ids(unit.dependsOn, 16, `${field}.dependsOn`), purpose: unit.purpose as ResearchUnit["purpose"],
|
|
103
|
+
...(unit.adoptTaskId !== undefined ? { adoptTaskId: integer(unit.adoptTaskId, 1, Number.MAX_SAFE_INTEGER, `${field}.adoptTaskId`) } : {}),
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
if (units.length > stopPolicy.maxUnits) fail("campaign.units", "exceeds stopPolicy.maxUnits");
|
|
107
|
+
distinct(units.map(unit => unit.id), "campaign.units.id");
|
|
108
|
+
distinct(units.flatMap(unit => unit.adoptTaskId === undefined ? [] : [unit.adoptTaskId]), "campaign.units.adoptTaskId");
|
|
109
|
+
const byId = new Map(units.map(unit => [unit.id, unit]));
|
|
110
|
+
for (const [index, unit] of units.entries()) for (const dependency of unit.dependsOn) {
|
|
111
|
+
const previous = byId.get(dependency);
|
|
112
|
+
if (!previous) fail(`campaign.units[${index}].dependsOn`, "names an unknown unit");
|
|
113
|
+
if (previous.round > unit.round) fail(`campaign.units[${index}].dependsOn`, "cannot depend on a later round");
|
|
114
|
+
}
|
|
115
|
+
const visited = new Set<string>(), visiting = new Set<string>();
|
|
116
|
+
const visit = (unit: ResearchUnit): void => {
|
|
117
|
+
if (visiting.has(unit.id)) fail("campaign.units.dependsOn", "dependency cycle");
|
|
118
|
+
if (visited.has(unit.id)) return;
|
|
119
|
+
visiting.add(unit.id);
|
|
120
|
+
for (const dependency of unit.dependsOn) visit(byId.get(dependency)!);
|
|
121
|
+
visiting.delete(unit.id); visited.add(unit.id);
|
|
122
|
+
};
|
|
123
|
+
for (const unit of units) visit(unit);
|
|
124
|
+
const sections = list(raw.deliverableSections, 32, "campaign.deliverableSections").map((value, index) => {
|
|
125
|
+
const field = `campaign.deliverableSections[${index}]`, section = record(value, ["id", "title", "ownerUnitId"], field);
|
|
126
|
+
const ownerUnitId = id(section.ownerUnitId, `${field}.ownerUnitId`), owner = byId.get(ownerUnitId);
|
|
127
|
+
if (!owner || owner.purpose !== "synthesis") fail(`${field}.ownerUnitId`, "must name an existing synthesis unit");
|
|
128
|
+
return { id: id(section.id, `${field}.id`), title: text(section.title, SHORT, `${field}.title`), ownerUnitId };
|
|
129
|
+
});
|
|
130
|
+
distinct(sections.map(section => section.id), "campaign.deliverableSections.id");
|
|
131
|
+
let budget: ResearchCampaignContract["budget"];
|
|
132
|
+
if (raw.budget !== undefined) {
|
|
133
|
+
const value = record(raw.budget, ["id", "hardCap"], "campaign.budget");
|
|
134
|
+
// Reference ids retain ExecutionBudget.configure's existing exact, non-empty <=200-character contract.
|
|
135
|
+
if (typeof value.id !== "string" || !value.id || value.id.length > 200 || !utf8(value.id)) fail("campaign.budget.id", "expected an existing budget id of 1 to 200 characters");
|
|
136
|
+
const hardCap = list(value.hardCap, RESOURCES.length, "campaign.budget.hardCap").map((resource, index) => {
|
|
137
|
+
if (typeof resource !== "string" || !(RESOURCES as readonly string[]).includes(resource)) fail(`campaign.budget.hardCap[${index}]`, "unknown resource");
|
|
138
|
+
return resource as NonNullable<ResearchCampaignContract["budget"]>["hardCap"][number];
|
|
139
|
+
});
|
|
140
|
+
distinct(hardCap, "campaign.budget.hardCap");
|
|
141
|
+
budget = { id: value.id, hardCap };
|
|
142
|
+
}
|
|
143
|
+
const result: ResearchCampaignContract = {
|
|
144
|
+
question: text(raw.question, LONG, "campaign.question"), scope: text(raw.scope, LONG, "campaign.scope"),
|
|
145
|
+
exclusions: list(raw.exclusions, 32, "campaign.exclusions").map((value, index) => text(value, SHORT, `campaign.exclusions[${index}]`)),
|
|
146
|
+
sourceIds, units, deliverableSections: sections, stopPolicy, ...(budget ? { budget } : {}),
|
|
147
|
+
};
|
|
148
|
+
if (Buffer.byteLength(canonicalJson(result), "utf8") > BODY) fail("campaign", "stored body exceeds 64 KiB");
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Content identity only: ordering of lists and the person's literal text remains significant. */
|
|
153
|
+
export function campaignHash(contract: ResearchCampaignContract): string {
|
|
154
|
+
return createHash("sha256").update(canonicalJson(parseResearchCampaign(contract))).digest("hex");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface ResearchHandoffV1 {
|
|
158
|
+
campaignId: string;
|
|
159
|
+
revision: number;
|
|
160
|
+
unitId: string;
|
|
161
|
+
taskId: number;
|
|
162
|
+
evidence: { sourceId: string; locator: string }[];
|
|
163
|
+
unresolved: string[];
|
|
164
|
+
contradictions: { sourceIds: string[]; note: string }[];
|
|
165
|
+
limitations: string[];
|
|
166
|
+
insufficientEvidence?: string;
|
|
167
|
+
}
|
|
168
|
+
export interface ResearchHandoffRecord {
|
|
169
|
+
handoff: ResearchHandoffV1;
|
|
170
|
+
seq: number;
|
|
171
|
+
by: string;
|
|
172
|
+
at: number;
|
|
173
|
+
private: boolean;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Required-field diagnostics are aggregate; no partially validated handoff is persisted. */
|
|
177
|
+
export function parseResearchHandoff(input: unknown): ResearchHandoffV1 {
|
|
178
|
+
const required = ["campaignId", "revision", "unitId", "taskId", "evidence", "unresolved", "contradictions", "limitations"] as const;
|
|
179
|
+
const raw = record(input, [...required, "insufficientEvidence"], "handoff");
|
|
180
|
+
const missing = required.filter(key => raw[key] === undefined);
|
|
181
|
+
if (missing.length) fail("handoff", `missing required fields: ${missing.join(", ")}`);
|
|
182
|
+
if (typeof raw.campaignId !== "string" || !/^rc-[0-9a-f]{12}$/.test(raw.campaignId)) fail("handoff.campaignId", "expected a campaign id");
|
|
183
|
+
const shortList = (value: unknown, field: string) => list(value, 16, field).map((item, index) => text(item, SHORT, `${field}[${index}]`));
|
|
184
|
+
const value: ResearchHandoffV1 = {
|
|
185
|
+
campaignId: raw.campaignId,
|
|
186
|
+
revision: integer(raw.revision, 1, Number.MAX_SAFE_INTEGER, "handoff.revision"),
|
|
187
|
+
unitId: id(raw.unitId, "handoff.unitId"), taskId: integer(raw.taskId, 1, Number.MAX_SAFE_INTEGER, "handoff.taskId"),
|
|
188
|
+
evidence: list(raw.evidence, 64, "handoff.evidence").map((item, index) => {
|
|
189
|
+
const field = `handoff.evidence[${index}]`, entry = record(item, ["sourceId", "locator"], field);
|
|
190
|
+
return { sourceId: id(entry.sourceId, `${field}.sourceId`, true), locator: text(entry.locator, SHORT, `${field}.locator`) };
|
|
191
|
+
}),
|
|
192
|
+
unresolved: shortList(raw.unresolved, "handoff.unresolved"),
|
|
193
|
+
contradictions: list(raw.contradictions, 16, "handoff.contradictions").map((item, index) => {
|
|
194
|
+
const field = `handoff.contradictions[${index}]`, entry = record(item, ["sourceIds", "note"], field);
|
|
195
|
+
return { sourceIds: ids(entry.sourceIds, 16, `${field}.sourceIds`, true), note: text(entry.note, SHORT, `${field}.note`) };
|
|
196
|
+
}),
|
|
197
|
+
limitations: shortList(raw.limitations, "handoff.limitations"),
|
|
198
|
+
...(raw.insufficientEvidence !== undefined ? { insufficientEvidence: text(raw.insufficientEvidence, SHORT, "handoff.insufficientEvidence") } : {}),
|
|
199
|
+
};
|
|
200
|
+
if (Buffer.byteLength(canonicalJson(value), "utf8") > BODY) fail("handoff", "stored body exceeds 64 KiB");
|
|
201
|
+
return value;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export type ResearchResource = NonNullable<ResearchCampaignContract["budget"]>["hardCap"][number];
|
|
205
|
+
export type ResearchBudgetSupport = "enforceable" | "unknown" | "advisory" | "unavailable";
|
|
206
|
+
export interface ResearchProjectionInput {
|
|
207
|
+
campaign: { id: string; revision: number; status: ResearchCampaignStatus; contract: ResearchCampaignContract };
|
|
208
|
+
bindings: readonly { unitId: string; taskId: number; round: number }[];
|
|
209
|
+
tasks: readonly { id: number; state: string; owner: string | null }[];
|
|
210
|
+
handoffs: readonly ResearchHandoffRecord[];
|
|
211
|
+
now: number;
|
|
212
|
+
budget?: { id: string; kind: "task" | "run"; taskId?: number; peers: readonly string[]; limits: Partial<Record<ResearchResource, number>>;
|
|
213
|
+
used?: Partial<Record<ResearchResource, number>>; coveredTaskIds: readonly number[] };
|
|
214
|
+
instrumentedPeers?: readonly string[];
|
|
215
|
+
}
|
|
216
|
+
export interface ResearchProjection {
|
|
217
|
+
id: string;
|
|
218
|
+
revision: number;
|
|
219
|
+
held: boolean;
|
|
220
|
+
reasons: string[];
|
|
221
|
+
sharedPeerLedger: boolean;
|
|
222
|
+
/** Whether the contract names a budget at all. A named budget that cannot be read still has its rows (`unknown`, `unavailable`); with none named the resource labels carry no information. */
|
|
223
|
+
budgetNamed: boolean;
|
|
224
|
+
units: { unitId: string; round: number; taskId: number | null; state: string; purpose: ResearchUnit["purpose"];
|
|
225
|
+
intentionalIndependentCheck: boolean; sourceIds: string[]; duplicateSourceIds: string[];
|
|
226
|
+
handoff: { present: boolean; complete: boolean; insufficientEvidence: boolean; coveredSourceIds: string[] };
|
|
227
|
+
budget: Record<ResearchResource, { support: ResearchBudgetSupport; used: number | null; limit: number | null }> }[];
|
|
228
|
+
discoveryRounds: { round: number; complete: boolean }[];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Facts supplied by the host only; task completion never substitutes for source coverage. */
|
|
232
|
+
export function studyProjection(input: ResearchProjectionInput): ResearchProjection {
|
|
233
|
+
const { campaign, now } = input, contract = campaign.contract;
|
|
234
|
+
const reasons: string[] = [];
|
|
235
|
+
if (campaign.status !== "approved") reasons.push("campaign approval is not current");
|
|
236
|
+
if (!Number.isFinite(now) || !Number.isFinite(Date.parse(contract.stopPolicy.deadline)) || now >= Date.parse(contract.stopPolicy.deadline)) reasons.push("campaign deadline has passed or is unknown");
|
|
237
|
+
if (contract.units.length > contract.stopPolicy.maxUnits || input.bindings.length > contract.stopPolicy.maxUnits) reasons.push("campaign unit limit exceeded");
|
|
238
|
+
if (input.bindings.some(binding => binding.round > contract.stopPolicy.maxFollowUpRounds)) reasons.push("campaign follow-up limit exceeded");
|
|
239
|
+
const assignments = new Map<string, number>();
|
|
240
|
+
for (const unit of contract.units) if (unit.purpose === "discovery") for (const source of unit.sourceIds) assignments.set(source, (assignments.get(source) ?? 0) + 1);
|
|
241
|
+
const units: ResearchProjection["units"] = contract.units.map(unit => {
|
|
242
|
+
const binding = input.bindings.find(value => value.unitId === unit.id), task = binding && input.tasks.find(value => value.id === binding.taskId);
|
|
243
|
+
const latest = binding && input.handoffs.filter(value => value.handoff.campaignId === campaign.id && value.handoff.unitId === unit.id && value.handoff.taskId === binding.taskId && value.handoff.revision <= campaign.revision).sort((a, b) => b.seq - a.seq)[0];
|
|
244
|
+
const covered = new Set(latest?.handoff.evidence.map(value => value.sourceId) ?? []), insufficientEvidence = !!latest?.handoff.insufficientEvidence;
|
|
245
|
+
const budget = Object.fromEntries(RESOURCES.map(resource => {
|
|
246
|
+
const ledger = input.budget, configured = contract.budget;
|
|
247
|
+
let support: ResearchBudgetSupport;
|
|
248
|
+
if (resource === "tokens" || resource === "model_calls" && contract.budget && input.budget?.id === contract.budget.id && input.budget.limits.tokens !== undefined) support = "unavailable";
|
|
249
|
+
else if (!configured || !task?.owner || !ledger || ledger.id !== configured.id) support = "unknown";
|
|
250
|
+
else if (!input.instrumentedPeers?.includes(task.owner)) support = "unavailable";
|
|
251
|
+
else if (!ledger.peers.includes(task.owner) || !ledger.coveredTaskIds.includes(task.id) || ledger.kind === "task" && ledger.taskId !== task.id || ledger.limits[resource] === undefined) support = "advisory";
|
|
252
|
+
else support = "enforceable";
|
|
253
|
+
const matching = configured && ledger?.id === configured.id;
|
|
254
|
+
return [resource, { support, used: matching ? ledger.used?.[resource] ?? null : null, limit: matching ? ledger.limits[resource] ?? null : null }];
|
|
255
|
+
})) as ResearchProjection["units"][number]["budget"];
|
|
256
|
+
return { unitId: unit.id, round: binding?.round ?? unit.round, taskId: binding?.taskId ?? null, state: task?.state ?? "unknown", purpose: unit.purpose,
|
|
257
|
+
intentionalIndependentCheck: unit.purpose === "independent_check", sourceIds: [...unit.sourceIds],
|
|
258
|
+
duplicateSourceIds: unit.purpose === "discovery" ? unit.sourceIds.filter(source => (assignments.get(source) ?? 0) > 1) : [],
|
|
259
|
+
handoff: { present: !!latest, complete: !!latest && unit.sourceIds.every(source => covered.has(source)), insufficientEvidence, coveredSourceIds: unit.sourceIds.filter(source => covered.has(source)) }, budget };
|
|
260
|
+
});
|
|
261
|
+
const discoveryRounds = [...new Set(units.filter(unit => unit.purpose === "discovery").map(unit => unit.round))].sort((a, b) => a - b)
|
|
262
|
+
.map(round => ({ round, complete: units.filter(unit => unit.round === round && unit.purpose === "discovery").every(unit => unit.handoff.complete || unit.handoff.insufficientEvidence) }));
|
|
263
|
+
return { id: campaign.id, revision: campaign.revision, held: reasons.length > 0, reasons,
|
|
264
|
+
sharedPeerLedger: !!contract.budget && input.budget?.id === contract.budget.id && input.budget.kind === "run", budgetNamed: !!contract.budget, units, discoveryRounds };
|
|
265
|
+
}
|