faberun 0.13.0 → 0.14.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/package.json +1 -1
- package/src/campaign/index.mjs +74 -2
- package/src/campaign/record.mjs +28 -0
- package/src/contract/index.mjs +15 -4
- package/src/contract/snapshot.mjs +21 -2
- package/src/engine/settle.mjs +3 -0
- package/src/engine/state.mjs +1 -0
- package/src/plan/freeze.mjs +29 -0
- package/src/plan/sizing.mjs +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Faberun is a development orchestration system that turns intent into verified software: harness- and model-agnostic, it keeps the intent, coordinates the work, verifies the result and decides what happens next.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/campaign/index.mjs
CHANGED
|
@@ -21,7 +21,9 @@ import { handoffFromState, materializeHandoff } from "./handoff.mjs";
|
|
|
21
21
|
/** @typedef {{path: string, digest: string}} CampaignContract */
|
|
22
22
|
/** @typedef {{runId: string, contractPath?: string, branch: string, sha: string, previousSha: string|null, at: string}} PromotionRecord */
|
|
23
23
|
/** @typedef {{code: string, message: string, at: string, contractPath?: string, contractId?: string, runId?: string, node?: string|null, status?: string|null, resume?: string}} CampaignAttention */
|
|
24
|
-
/** @typedef {{
|
|
24
|
+
/** @typedef {{runId: string, node: string, passed: boolean|null, verdict: string|null}} RequirementNodeEvidence */
|
|
25
|
+
/** @typedef {{requirementId: string, status: "covered"|"open", nodes: RequirementNodeEvidence[]}} RequirementClosure */
|
|
26
|
+
/** @typedef {{id: string, goal: string, status: "active"|"closed", linkedRunIds: string[], contracts: CampaignContract[], landBranch: string, promotions: PromotionRecord[], attention?: CampaignAttention, requirements?: RequirementClosure[], createdAt: string, updatedAt: string, closedAt?: string}} Campaign */
|
|
25
27
|
/** @typedef {{type: string, eventId: string, at: string, sessionId?: string, text?: string, tool?: string, transcript?: string|null, transcriptUnavailable?: boolean, format?: string|null, cursor?: string|null, decisionId?: string, supersedes?: string, runId?: string, questionId?: string, campaignId?: string, nodeId?: string|null, phase?: string, checkpointsDone?: number, checkpointsTotal?: number, runtime?: string|null, state?: string, lastProgressAt?: string, attention?: string|null}} JournalEntry */
|
|
26
28
|
/** @typedef {{updatedAt: string|null, decisions: Record<string, JournalEntry>, questions: Record<string, JournalEntry>, constraints: JournalEntry[], intents: JournalEntry[], outcomes: JournalEntry[], sessions: JournalEntry[], next: JournalEntry|null, evicted: Record<string, number>}} Projection */
|
|
27
29
|
/** @typedef {{cursor: number, byte: number, size: number, projection: Projection}} ProjectionRecord */
|
|
@@ -135,12 +137,82 @@ export function closeCampaign(campaignPath, { at = new Date().toISOString(), eve
|
|
|
135
137
|
}
|
|
136
138
|
const repoRoot = campaignRepoRoot(campaignPath);
|
|
137
139
|
const ledgerFiles = preserveCampaignLedger(campaignPath, repoRoot);
|
|
138
|
-
|
|
140
|
+
// The closure travels on the record itself, computed in one deterministic
|
|
141
|
+
// pass over the linked runs' own files before the close is written.
|
|
142
|
+
const requirements = buildRequirementClosure(campaignPath, campaign);
|
|
143
|
+
/** @type {Campaign} */
|
|
144
|
+
const closed = { ...campaign, status: "closed", closedAt: at, updatedAt: at, requirements };
|
|
139
145
|
writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), closed);
|
|
140
146
|
appendJournal(campaignPath, { type: "campaign.closed", at, eventId });
|
|
141
147
|
return { path: campaignPath, campaign: closed, ledgerFiles };
|
|
142
148
|
}
|
|
143
149
|
|
|
150
|
+
/**
|
|
151
|
+
* The requirement closure a close records: one entry per requirement id the
|
|
152
|
+
* linked runs' contracts declared, correlated only by the identifiers the runs
|
|
153
|
+
* carried -- the contract node's declaration and the id the done node snapshot
|
|
154
|
+
* itself carries, stamped there by the engine -- never by requirement text. A
|
|
155
|
+
* requirement no done node carries is recorded as open rather than omitted.
|
|
156
|
+
* Deterministic and free of external calls: runs are visited in sorted id
|
|
157
|
+
* order, nodes in contract order, requirement ids sorted, and the only inputs
|
|
158
|
+
* are files already on disk. A run that never launched (or was pruned)
|
|
159
|
+
* contributes nothing.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} campaignPath
|
|
162
|
+
* @param {Campaign} campaign
|
|
163
|
+
* @returns {RequirementClosure[]}
|
|
164
|
+
*/
|
|
165
|
+
function buildRequirementClosure(campaignPath, campaign) {
|
|
166
|
+
const runsDir = resolve(campaignPath, "..", "..");
|
|
167
|
+
/** @type {Map<string, RequirementNodeEvidence[]>} */
|
|
168
|
+
const covered = new Map();
|
|
169
|
+
/** @type {Set<string>} */
|
|
170
|
+
const declared = new Set();
|
|
171
|
+
for (const runId of [...campaign.linkedRunIds].sort()) {
|
|
172
|
+
const contract = readRunJson(join(runsDir, runId, "contract.json"));
|
|
173
|
+
const nodes = contract !== null && Array.isArray(contract.nodes) ? /** @type {JsonObject[]} */ (contract.nodes) : [];
|
|
174
|
+
for (const node of nodes) {
|
|
175
|
+
const nodeId = typeof node.id === "string" ? node.id : "";
|
|
176
|
+
const requirementIds = Array.isArray(node.requirementIds) ? node.requirementIds : [];
|
|
177
|
+
if (!nodeId || requirementIds.length === 0) continue;
|
|
178
|
+
const snapshot = readRunJson(join(runsDir, runId, "nodes", `${nodeId}.json`));
|
|
179
|
+
const done = snapshot !== null && snapshot.status === "done";
|
|
180
|
+
const carried = snapshot !== null && Array.isArray(snapshot.requirementIds) ? /** @type {unknown[]} */ (snapshot.requirementIds) : [];
|
|
181
|
+
const verification = snapshot !== null ? /** @type {JsonObject|null|undefined} */ (snapshot.verification) : undefined;
|
|
182
|
+
const gate = snapshot !== null ? /** @type {JsonObject|null|undefined} */ (snapshot.gate) : undefined;
|
|
183
|
+
const passed = verification && typeof verification.passed === "boolean" ? verification.passed : null;
|
|
184
|
+
const verdict = gate && typeof gate.verdict === "string" ? gate.verdict : null;
|
|
185
|
+
for (const requirementId of requirementIds) {
|
|
186
|
+
if (typeof requirementId !== "string") continue;
|
|
187
|
+
declared.add(requirementId);
|
|
188
|
+
if (!done || !carried.includes(requirementId)) continue;
|
|
189
|
+
const evidence = covered.get(requirementId) ?? [];
|
|
190
|
+
evidence.push({ runId, node: nodeId, passed, verdict });
|
|
191
|
+
covered.set(requirementId, evidence);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return [...declared].sort().map((requirementId) => {
|
|
196
|
+
const nodes = covered.get(requirementId) ?? [];
|
|
197
|
+
return { requirementId, status: nodes.length > 0 ? "covered" : "open", nodes };
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {string} path
|
|
203
|
+
* @returns {JsonObject|null} null when the file is absent or not JSON: a run
|
|
204
|
+
* that never launched contributes nothing to the closure rather than failing
|
|
205
|
+
* the close, the same discipline `preserveCampaignLedger` applies to a run
|
|
206
|
+
* without a usage ledger.
|
|
207
|
+
*/
|
|
208
|
+
function readRunJson(path) {
|
|
209
|
+
try {
|
|
210
|
+
return /** @type {JsonObject} */ (JSON.parse(readFileSync(path, "utf8")));
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
144
216
|
/**
|
|
145
217
|
* The repository a campaign's ledger is preserved into. Under the home layout
|
|
146
218
|
* the campaign path carries the project id at the resolver's fixed position,
|
package/src/campaign/record.mjs
CHANGED
|
@@ -99,4 +99,32 @@ function validateCampaign(campaign) {
|
|
|
99
99
|
if (promotion.contractPath !== undefined) requireString(promotion.contractPath, `campaign.promotions[${index}].contractPath`);
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
|
+
// The requirement closure a close records: one entry per requirement id the
|
|
103
|
+
// linked runs' contracts declared, correlated by the identifiers the runs
|
|
104
|
+
// carried, with every uncovered requirement kept as open. Optional on read
|
|
105
|
+
// so a campaign closed before the field existed stays readable.
|
|
106
|
+
if (record.requirements !== undefined) {
|
|
107
|
+
if (!Array.isArray(record.requirements)) throw new TypeError("campaign.requirements must be an array");
|
|
108
|
+
for (const [index, entry] of record.requirements.entries()) {
|
|
109
|
+
assertObject(entry, `campaign.requirements[${index}]`);
|
|
110
|
+
const closure = /** @type {JsonObject} */ (entry);
|
|
111
|
+
requireId(closure.requirementId, `campaign.requirements[${index}].requirementId`);
|
|
112
|
+
if (closure.status !== "covered" && closure.status !== "open") {
|
|
113
|
+
throw new TypeError(`campaign.requirements[${index}].status must be covered or open`);
|
|
114
|
+
}
|
|
115
|
+
if (!Array.isArray(closure.nodes)) throw new TypeError(`campaign.requirements[${index}].nodes must be an array`);
|
|
116
|
+
for (const [position, evidence] of closure.nodes.entries()) {
|
|
117
|
+
assertObject(evidence, `campaign.requirements[${index}].nodes[${position}]`);
|
|
118
|
+
const node = /** @type {JsonObject} */ (evidence);
|
|
119
|
+
requireId(node.runId, `campaign.requirements[${index}].nodes[${position}].runId`);
|
|
120
|
+
requireId(node.node, `campaign.requirements[${index}].nodes[${position}].node`);
|
|
121
|
+
if (node.passed !== null && typeof node.passed !== "boolean") {
|
|
122
|
+
throw new TypeError(`campaign.requirements[${index}].nodes[${position}].passed must be a boolean or null`);
|
|
123
|
+
}
|
|
124
|
+
if (node.verdict !== null && typeof node.verdict !== "string") {
|
|
125
|
+
throw new TypeError(`campaign.requirements[${index}].nodes[${position}].verdict must be a string or null`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
102
130
|
}
|
package/src/contract/index.mjs
CHANGED
|
@@ -37,7 +37,7 @@ const CONTRACT_FIELDS = new Set([
|
|
|
37
37
|
]);
|
|
38
38
|
const DEFAULTS_FIELDS = new Set(["worker", "judge"]);
|
|
39
39
|
const NODE_FIELDS = new Set([
|
|
40
|
-
"id", "type", "phase", "runtime", "dependsOn", "taskPacket", "taskPacketFile", "prompt", "promptFile",
|
|
40
|
+
"id", "type", "phase", "requirementIds", "runtime", "dependsOn", "taskPacket", "taskPacketFile", "prompt", "promptFile",
|
|
41
41
|
"definitionOfDone", "gate", "timeoutSec", "maxTurns",
|
|
42
42
|
"requiredCapabilities", "packetHash", "sourceIdentity", "replayPolicy",
|
|
43
43
|
]);
|
|
@@ -68,7 +68,7 @@ const GATE_REVIEWS = new Set(["none", "advisory", "blocking"]);
|
|
|
68
68
|
|
|
69
69
|
/** @typedef {{enabled: boolean, review?: ("none"|"advisory"|"blocking"), runtime?: string, failOn?: ("minor"|"major"|"critical")[], maxRevisions?: number, requiredCapabilities?: CapabilityRequirements, skipWhen?: {verificationGreen: true, maxChangedPaths: number}}} ValidatedGate */
|
|
70
70
|
|
|
71
|
-
/** @typedef {{id: string, type: string, phase: string, runtime?: string, dependsOn: string[], taskPacket: TaskPacket, taskPacketFile?: string, prompt: string, definitionOfDone: import("./definition-of-done.mjs").DefinitionOfDoneItem[], gate: ValidatedGate, timeoutSec?: number, maxTurns?: number, requiredCapabilities: CapabilityRequirements, packetHash: string, sourceIdentity: SourceIdentity, replayPolicy: "safe"|"reconcile"|"never"}} ValidatedNode */
|
|
71
|
+
/** @typedef {{id: string, type: string, phase: string, requirementIds?: string[], runtime?: string, dependsOn: string[], taskPacket: TaskPacket, taskPacketFile?: string, prompt: string, definitionOfDone: import("./definition-of-done.mjs").DefinitionOfDoneItem[], gate: ValidatedGate, timeoutSec?: number, maxTurns?: number, requiredCapabilities: CapabilityRequirements, packetHash: string, sourceIdentity: SourceIdentity, replayPolicy: "safe"|"reconcile"|"never"}} ValidatedNode */
|
|
72
72
|
|
|
73
73
|
/** @typedef {{schemaVersion: number, contractVersion: string, id: string, campaignId: string, goal: string, cwd: string, sourceIdentity: SourceIdentity, runtimes: Record<string, ValidatedRuntime>, runtimeDefaults: {worker?: string, judge?: string}, nodes: ValidatedNode[], maxParallel: number, pollIntervalMs: number, stallTimeoutSec: number, timeoutSec: number, maxTurns: number, phaseSessionReuse: boolean, finalVerification?: VerificationCommand[], sharedVerification?: VerificationCommand[], nodeAdvisory?: NodeAdvisoryPolicy, warnings: string[]}} ValidatedContract */
|
|
74
74
|
/** @typedef {{costUsd?: number, durationSec?: number}} NodeAdvisoryPolicy */
|
|
@@ -98,11 +98,11 @@ const GATE_REVIEWS = new Set(["none", "advisory", "blocking"]);
|
|
|
98
98
|
/** @typedef {{history: RoutingHistoryEntry[], currentOverride: RoutingOverride|null, assignments?: RuntimeAssignments, availability?: Record<string, RuntimeAvailability>, tierExhaustion?: TierExhaustion, tierExhaustionCycle?: number}} RoutingState */
|
|
99
99
|
/** @typedef {{revision?: number, heartbeatCount: number, dryHeartbeatCount: number, progressSignature?: string|null, lastHeartbeatAt: string|null, lastProgressAt: string|null, nextCheckAt?: string|null}} ProgressState */
|
|
100
100
|
/** @typedef {{status: "unassigned"|"provisioning"|"ready"|"failed"|"removed", path: string|null, branch: string|null, commit: string|null, baseSha?: string|null, sealedSha?: string|null, sealError?: string|null, previousAttempt?: number|null}} WorktreeState */
|
|
101
|
-
/** @typedef {{schemaVersion: number, contractVersion: string, id: string, type: string, sourceIdentity: SourceIdentity, packetHash: string, status: NodeStatus, phase: NodePhase, attempt: number, revisions: number, judgeFailures?: number, review?: ("none"|"advisory"|"blocking"), runtime: RuntimeSnapshot|null, blockedBy: string[], startedAt: string|null, updatedAt: string, result: unknown, gate: GateResult|null, error: SnapshotError|null, usage?: Usage, costUsd?: number, routing?: RoutingState|null, progress?: ProgressState|null, worktree?: WorktreeState|null, integratedHead?: string|null, invocations?: Invocation[], executionOverrides?: ExecutionOverride[], verification?: VerificationState|null, scope?: BoundedScope|null, scopeFindings?: ScopeFindings|null, previousAttempt?: string, sessionPolicy?: {forceFresh?: boolean}|null, declaredReadBytes?: number|null}} NodeSnapshot */
|
|
101
|
+
/** @typedef {{schemaVersion: number, contractVersion: string, id: string, type: string, sourceIdentity: SourceIdentity, packetHash: string, requirementIds?: string[], status: NodeStatus, phase: NodePhase, attempt: number, revisions: number, judgeFailures?: number, review?: ("none"|"advisory"|"blocking"), runtime: RuntimeSnapshot|null, blockedBy: string[], startedAt: string|null, updatedAt: string, result: unknown, gate: GateResult|null, error: SnapshotError|null, usage?: Usage, costUsd?: number, routing?: RoutingState|null, progress?: ProgressState|null, worktree?: WorktreeState|null, integratedHead?: string|null, invocations?: Invocation[], executionOverrides?: ExecutionOverride[], verification?: VerificationState|null, scope?: BoundedScope|null, scopeFindings?: ScopeFindings|null, previousAttempt?: string, sessionPolicy?: {forceFresh?: boolean}|null, declaredReadBytes?: number|null}} NodeSnapshot */
|
|
102
102
|
/** @typedef {{path: string, sha: string}} ControllerIdentity */
|
|
103
103
|
/** @typedef {{schemaVersion: number, contractVersion: string, pid: number, processStartToken: string|null, startedAt: string, sourceIdentity: SourceIdentity, controllerIdentity?: ControllerIdentity, integrationRef?: string, identityWarnings?: string[], relaunchCount?: number, lastRelaunchProgressAt?: string|null, attention?: {code: string, message: string, at: string}|null, contractDigest?: string, scopeDecision?: ScopeDecision, autoRetries?: Record<string, {code: string, at: string}>}} RunMetadata */
|
|
104
104
|
/** @typedef {{at: string, base: string|null, dirtyTreeFingerprint: string|null}} ScopeDecision */
|
|
105
|
-
/** @typedef {{schemaVersion: number, contractVersion: string, at: string, node: string, from?: string, to: string, type?: string, phase?: string, attempt?: number, role?: "worker"|"judge", status?: NodeStatus, runtime?: string, currentRuntime?: string, errorCode?: string, error?: SnapshotError, verdict?: string, summary?: string, revisions?: number, sourceIdentity: SourceIdentity, packetHash: string, override?: unknown, recovery?: unknown, invocationId?: string, unexpectedPaths?: string[], unexpectedPathCount?: number}} EventRecord */
|
|
105
|
+
/** @typedef {{schemaVersion: number, contractVersion: string, at: string, node: string, from?: string, to: string, type?: string, phase?: string, attempt?: number, role?: "worker"|"judge", status?: NodeStatus, runtime?: string, currentRuntime?: string, errorCode?: string, error?: SnapshotError, verdict?: string, summary?: string, revisions?: number, requirementIds?: string[], sourceIdentity: SourceIdentity, packetHash: string, override?: unknown, recovery?: unknown, invocationId?: string, unexpectedPaths?: string[], unexpectedPathCount?: number}} EventRecord */
|
|
106
106
|
|
|
107
107
|
/**
|
|
108
108
|
* Validate and canonicalize the versioned contract. Runtime JSON remains
|
|
@@ -179,6 +179,17 @@ export function validateContract(raw, contractPath, options = {}) {
|
|
|
179
179
|
ids.add(node.id);
|
|
180
180
|
requireString(node.type, `nodes[${index}].type`);
|
|
181
181
|
boundedString(node.phase, `nodes[${index}].phase`, 128);
|
|
182
|
+
// A node's requirementIds are inherited from its phase at freeze time
|
|
183
|
+
// (src/plan/freeze.mjs stamps them) and are optional on read: a contract
|
|
184
|
+
// without the field loads unchanged, which is what keeps CONTRACT_VERSION
|
|
185
|
+
// at 0.3.0 while requirement ids reach the node.
|
|
186
|
+
const requirementIds = node.requirementIds;
|
|
187
|
+
if (requirementIds !== undefined) {
|
|
188
|
+
if (!Array.isArray(requirementIds) || requirementIds.length > 64) {
|
|
189
|
+
throw new TypeError(`nodes[${index}].requirementIds must be an array of at most 64 requirement ids`);
|
|
190
|
+
}
|
|
191
|
+
requirementIds.forEach((id, position) => boundedString(id, `nodes[${index}].requirementIds[${position}]`, 128));
|
|
192
|
+
}
|
|
182
193
|
if (node.runtime !== undefined) requireRuntime(runtimes, node.runtime, `nodes[${index}].runtime`);
|
|
183
194
|
const dependsOn = node.dependsOn ?? [];
|
|
184
195
|
if (!Array.isArray(dependsOn) || dependsOn.some((id) => typeof id !== "string")) {
|
|
@@ -127,7 +127,7 @@ export function validateNodeSnapshot(value, expectedNode = null) {
|
|
|
127
127
|
assertObject(value, "node snapshot");
|
|
128
128
|
rejectUnknown(value, new Set([
|
|
129
129
|
"schemaVersion", "contractVersion", "id", "type", "sourceIdentity", "packetHash", "status", "phase",
|
|
130
|
-
"attempt", "revisions", "judgeFailures", "runtime", "blockedBy", "startedAt", "updatedAt", "result", "gate", "error", "usage",
|
|
130
|
+
"attempt", "revisions", "judgeFailures", "requirementIds", "runtime", "blockedBy", "startedAt", "updatedAt", "result", "gate", "error", "usage",
|
|
131
131
|
"costUsd", "routing", "progress", "worktree", "invocations", "executionOverrides", "verification", "scope",
|
|
132
132
|
"scopeFindings", "review", "previousAttempt", "sessionPolicy", "integratedHead", "declaredReadBytes",
|
|
133
133
|
]), "node snapshot");
|
|
@@ -139,6 +139,10 @@ export function validateNodeSnapshot(value, expectedNode = null) {
|
|
|
139
139
|
nonNegativeInteger(value.attempt, "node snapshot.attempt");
|
|
140
140
|
nonNegativeInteger(value.revisions, "node snapshot.revisions");
|
|
141
141
|
if (value.judgeFailures !== undefined) nonNegativeInteger(value.judgeFailures, "node snapshot.judgeFailures");
|
|
142
|
+
// The phase requirement ids the engine stamped onto the node when its result
|
|
143
|
+
// was accepted; optional on read so snapshots written before the stamp load
|
|
144
|
+
// unchanged.
|
|
145
|
+
if (value.requirementIds !== undefined) validateRequirementIdList(value.requirementIds, "node snapshot.requirementIds");
|
|
142
146
|
// The review mode that governed the attempt's gate, recorded so a status
|
|
143
147
|
// surface can tell an advisory finding from a below-threshold blocking one.
|
|
144
148
|
if (value.review !== undefined && !REVIEW_MODES.has(/** @type {string} */ (value.review))) {
|
|
@@ -206,7 +210,7 @@ export function validateEvent(value) {
|
|
|
206
210
|
assertObject(value, "event");
|
|
207
211
|
rejectUnknown(value, new Set([
|
|
208
212
|
"schemaVersion", "contractVersion", "at", "node", "from", "to", "type", "phase", "attempt", "runtime",
|
|
209
|
-
"role", "status", "currentRuntime", "errorCode", "error", "verdict", "summary", "revisions", "sourceIdentity", "packetHash", "override", "recovery", "invocationId", "unexpectedPaths", "unexpectedPathCount",
|
|
213
|
+
"role", "status", "currentRuntime", "errorCode", "error", "verdict", "summary", "revisions", "requirementIds", "sourceIdentity", "packetHash", "override", "recovery", "invocationId", "unexpectedPaths", "unexpectedPathCount",
|
|
210
214
|
]), "event");
|
|
211
215
|
validateMetadata(value, "event");
|
|
212
216
|
requireString(value.at, "event.at");
|
|
@@ -230,8 +234,23 @@ export function validateEvent(value) {
|
|
|
230
234
|
}
|
|
231
235
|
}
|
|
232
236
|
if (value.unexpectedPathCount !== undefined) nonNegativeInteger(value.unexpectedPathCount, "event.unexpectedPathCount");
|
|
237
|
+
if (value.requirementIds !== undefined) validateRequirementIdList(value.requirementIds, "event.requirementIds");
|
|
233
238
|
return /** @type {EventRecord} */ (value);
|
|
234
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* The requirement ids a node inherited from its phase, stamped by the engine
|
|
242
|
+
* and carried beside the result on both the snapshot and the transition event.
|
|
243
|
+
* Bounded like every persisted list: at most 64 ids of at most 128 bytes.
|
|
244
|
+
*
|
|
245
|
+
* @param {unknown} value
|
|
246
|
+
* @param {string} label
|
|
247
|
+
*/
|
|
248
|
+
function validateRequirementIdList(value, label) {
|
|
249
|
+
if (!Array.isArray(value) || value.length > 64) {
|
|
250
|
+
throw new TypeError(`${label} must be an array of at most 64 requirement ids`);
|
|
251
|
+
}
|
|
252
|
+
for (const [index, id] of value.entries()) boundedString(id, `${label}[${index}]`, 128);
|
|
253
|
+
}
|
|
235
254
|
/**
|
|
236
255
|
* @param {unknown} value
|
|
237
256
|
* @param {string} label
|
package/src/engine/settle.mjs
CHANGED
|
@@ -181,6 +181,9 @@ export async function settleDone(contract, node, state, runDir, lock, states, ca
|
|
|
181
181
|
const retryNote = candidateRetryNote(/** @type {{verificationEvidence?: {candidate?: unknown}}} */ (transaction).verificationEvidence?.candidate);
|
|
182
182
|
transition(runDir, state, "done", {
|
|
183
183
|
...patch,
|
|
184
|
+
// The engine carries the node's inherited requirement ids back with
|
|
185
|
+
// the accepted result; the worker never declares them.
|
|
186
|
+
...(node.requirementIds?.length ? { requirementIds: node.requirementIds } : {}),
|
|
184
187
|
...(retryNote ? { gate: withCandidateRetryNote(/** @type {import("../contract/index.mjs").GateResult|null|undefined} */ (patch.gate ?? state.gate), retryNote) } : {}),
|
|
185
188
|
integratedHead: transaction.candidateSha,
|
|
186
189
|
worktree: { ...(state.worktree ?? {}), status: "removed", commit: transaction.attemptSha, baseSha: transaction.previousRunRefTip },
|
package/src/engine/state.mjs
CHANGED
|
@@ -121,6 +121,7 @@ export function appendTransitionEvent(runDir, state, from, to, details = {}, loc
|
|
|
121
121
|
if (state.gate?.verdict) event.verdict = state.gate.verdict;
|
|
122
122
|
if (state.gate?.summary) event.summary = state.gate.summary;
|
|
123
123
|
if (state.revisions) event.revisions = state.revisions;
|
|
124
|
+
if (state.requirementIds?.length) event.requirementIds = state.requirementIds;
|
|
124
125
|
const invocation = state.invocations?.at(-1);
|
|
125
126
|
if (invocation?.id) event.invocationId = invocation.id;
|
|
126
127
|
validateEvent(event);
|
package/src/plan/freeze.mjs
CHANGED
|
@@ -36,6 +36,32 @@ function packageVersion() {
|
|
|
36
36
|
return JSON.parse(readFileSync(packageJsonPath, "utf8")).version;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Nodes inherit the requirement ids of the phase they belong to: the frozen
|
|
41
|
+
* contract preserves them per node, so the engine can stamp them onto the
|
|
42
|
+
* node's accepted result without the worker packet or the worker ever
|
|
43
|
+
* declaring one. A node whose phase has no declaration, or declares none,
|
|
44
|
+
* carries none. Nodes are re-listed rather than mutated in place, so the
|
|
45
|
+
* caller's plan keeps the shape it was reviewed with.
|
|
46
|
+
*
|
|
47
|
+
* @param {unknown} nodes
|
|
48
|
+
* @param {PlanPhase[]} phases
|
|
49
|
+
* @returns {unknown} the node list with the inherited ids stamped on
|
|
50
|
+
*/
|
|
51
|
+
function stampPhaseRequirementIds(nodes, phases) {
|
|
52
|
+
if (!Array.isArray(nodes)) return nodes;
|
|
53
|
+
const byPhase = new Map(
|
|
54
|
+
phases
|
|
55
|
+
.filter((phase) => phase.requirementIds.length > 0)
|
|
56
|
+
.map((phase) => [phase.id, phase.requirementIds]),
|
|
57
|
+
);
|
|
58
|
+
return nodes.map((node) => {
|
|
59
|
+
const phase = typeof node?.phase === "string" ? node.phase : undefined;
|
|
60
|
+
const inherited = phase === undefined ? undefined : byPhase.get(phase);
|
|
61
|
+
return inherited ? { ...node, requirementIds: [...inherited] } : node;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
39
65
|
/**
|
|
40
66
|
* Validate `plan` as a contract and, only once it is valid, write it and a
|
|
41
67
|
* sibling plan.json naming its digest and provenance. `plan` supplies
|
|
@@ -69,6 +95,9 @@ export function freezePlan(plan, { outDir, provenance, phases }) {
|
|
|
69
95
|
schemaVersion: PROTOCOL_SCHEMA_VERSION,
|
|
70
96
|
contractVersion: CONTRACT_VERSION,
|
|
71
97
|
...plan,
|
|
98
|
+
// Listed again, not mutated in place, so the caller's plan object keeps
|
|
99
|
+
// the shape it was reviewed with.
|
|
100
|
+
...(phaseDeclarations ? { nodes: stampPhaseRequirementIds(plan.nodes, phaseDeclarations) } : {}),
|
|
72
101
|
});
|
|
73
102
|
writeJsonAtomic(contractPath, raw);
|
|
74
103
|
try {
|
package/src/plan/sizing.mjs
CHANGED
|
@@ -145,6 +145,18 @@ function foldNodeInto(nodes, child, parent) {
|
|
|
145
145
|
const parentReads = Array.isArray(parent.taskPacket.readFiles) ? /** @type {string[]} */ (parent.taskPacket.readFiles) : null;
|
|
146
146
|
const childReads = Array.isArray(child.taskPacket.readFiles) ? /** @type {string[]} */ (child.taskPacket.readFiles) : [];
|
|
147
147
|
if (parentReads !== null || childReads.length > 0) parent.taskPacket.readFiles = dedupe([...(parentReads ?? []), ...childReads]);
|
|
148
|
+
// The merged node inherits the importers either node had acknowledged and
|
|
149
|
+
// the turns both expected. Found 2026-09-21 in review with the
|
|
150
|
+
// state-location session: a fold that kept only the parent's
|
|
151
|
+
// acknowledgements sent the merged node to scope closure without the
|
|
152
|
+
// child's, so it failed for the fold rather than for the work, and the
|
|
153
|
+
// over-cap flag read one node's expectation for two nodes' worth of work.
|
|
154
|
+
const parentAcks = Array.isArray(parent.taskPacket.scopeAcknowledged) ? /** @type {string[]} */ (parent.taskPacket.scopeAcknowledged) : [];
|
|
155
|
+
const childAcks = Array.isArray(child.taskPacket.scopeAcknowledged) ? /** @type {string[]} */ (child.taskPacket.scopeAcknowledged) : [];
|
|
156
|
+
if (parentAcks.length > 0 || childAcks.length > 0) parent.taskPacket.scopeAcknowledged = dedupe([...parentAcks, ...childAcks]);
|
|
157
|
+
const parentTurns = typeof parent.expectedTurns === "number" ? parent.expectedTurns : null;
|
|
158
|
+
const childTurns = typeof child.expectedTurns === "number" ? child.expectedTurns : null;
|
|
159
|
+
if (parentTurns !== null || childTurns !== null) parent.expectedTurns = (parentTurns ?? 0) + (childTurns ?? 0);
|
|
148
160
|
if (typeof parent.objective === "string" && typeof child.objective === "string" && child.objective !== parent.objective) {
|
|
149
161
|
parent.objective = `${parent.objective} Also: ${child.objective}`;
|
|
150
162
|
}
|