faberun 0.3.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/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/faberun.mjs +25 -0
- package/integrations/claude-code/statusline-bench.sh +42 -0
- package/integrations/claude-code/statusline.sh +80 -0
- package/package.json +33 -0
- package/skills/faberun/SKILL.md +24 -0
- package/skills/faberun/references/contract.md +380 -0
- package/skills/faberun/references/engineering.md +29 -0
- package/skills/faberun/references/handoffs.md +26 -0
- package/skills/faberun/references/operations.md +184 -0
- package/skills/faberun/references/rules.md +35 -0
- package/skills/faberun/references/workflow.md +23 -0
- package/skills/init-agentkit/SKILL.md +108 -0
- package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
- package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
- package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
- package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
- package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
- package/skills/init-agentkit/templates/AGENTS.md +110 -0
- package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
- package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
- package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
- package/skills/init-agentkit/templates/docs/VISION.md +33 -0
- package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
- package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
- package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
- package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
- package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
- package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
- package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
- package/src/campaign/brief.mjs +394 -0
- package/src/campaign/chain.mjs +555 -0
- package/src/campaign/handoff.mjs +516 -0
- package/src/campaign/index.mjs +300 -0
- package/src/campaign/journal.mjs +347 -0
- package/src/campaign/layout.mjs +51 -0
- package/src/campaign/metrics-evals.mjs +25 -0
- package/src/campaign/metrics.mjs +517 -0
- package/src/campaign/projection.mjs +250 -0
- package/src/campaign/record.mjs +102 -0
- package/src/campaign/unpark.mjs +56 -0
- package/src/cli/brand.mjs +205 -0
- package/src/cli/campaign.mjs +730 -0
- package/src/cli/contract.mjs +67 -0
- package/src/cli/init.mjs +170 -0
- package/src/cli/launch.mjs +239 -0
- package/src/cli/seat.mjs +139 -0
- package/src/cli/setup.mjs +294 -0
- package/src/cli/skills.mjs +105 -0
- package/src/cli/update.mjs +216 -0
- package/src/cli.mjs +525 -0
- package/src/contract/articles.mjs +12 -0
- package/src/contract/assert.mjs +162 -0
- package/src/contract/definition-of-done.mjs +97 -0
- package/src/contract/final-verification.mjs +96 -0
- package/src/contract/index.mjs +641 -0
- package/src/contract/judge-envelope.mjs +25 -0
- package/src/contract/review-modes.mjs +151 -0
- package/src/contract/runtime.mjs +204 -0
- package/src/contract/schema-version.mjs +25 -0
- package/src/contract/scope-findings.mjs +77 -0
- package/src/contract/snapshot.mjs +639 -0
- package/src/contract/task-packet.mjs +495 -0
- package/src/contract/untrusted.mjs +75 -0
- package/src/contract/verification.mjs +185 -0
- package/src/contract/worker-result.mjs +138 -0
- package/src/engine/assignment.mjs +63 -0
- package/src/engine/backoff.mjs +492 -0
- package/src/engine/bulk-read.mjs +361 -0
- package/src/engine/cancel.mjs +177 -0
- package/src/engine/detach.mjs +101 -0
- package/src/engine/dispatch.mjs +752 -0
- package/src/engine/failover.mjs +192 -0
- package/src/engine/gate.mjs +183 -0
- package/src/engine/judge-gate.mjs +517 -0
- package/src/engine/lifecycle.mjs +772 -0
- package/src/engine/live-preflight.mjs +299 -0
- package/src/engine/mutation.mjs +146 -0
- package/src/engine/notify-queue.mjs +327 -0
- package/src/engine/process-identity.mjs +72 -0
- package/src/engine/process.mjs +774 -0
- package/src/engine/prompts.mjs +289 -0
- package/src/engine/recover.mjs +300 -0
- package/src/engine/result-file.mjs +222 -0
- package/src/engine/resume.mjs +635 -0
- package/src/engine/retry.mjs +334 -0
- package/src/engine/review.mjs +228 -0
- package/src/engine/run-command.mjs +287 -0
- package/src/engine/run-identity.mjs +411 -0
- package/src/engine/runtime-discovery.mjs +235 -0
- package/src/engine/scheduler.mjs +526 -0
- package/src/engine/scope.mjs +378 -0
- package/src/engine/settle.mjs +207 -0
- package/src/engine/state.mjs +148 -0
- package/src/engine/supervise.mjs +713 -0
- package/src/engine/verify.mjs +167 -0
- package/src/harnesses/agy/index.mjs +62 -0
- package/src/harnesses/catalogue.mjs +509 -0
- package/src/harnesses/claude/index.mjs +90 -0
- package/src/harnesses/codex/index.mjs +87 -0
- package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
- package/src/harnesses/dsh/index.mjs +210 -0
- package/src/harnesses/dsh/runner.mjs +259 -0
- package/src/harnesses/exec-jsonl/index.mjs +788 -0
- package/src/harnesses/index.mjs +508 -0
- package/src/harnesses/protocol.mjs +531 -0
- package/src/harnesses/replay/bin.mjs +386 -0
- package/src/harnesses/replay/index.mjs +238 -0
- package/src/harnesses/zcode/index.mjs +276 -0
- package/src/host/config.mjs +87 -0
- package/src/host/home.mjs +149 -0
- package/src/host/package.mjs +23 -0
- package/src/host/preflight.mjs +520 -0
- package/src/host/tool-policy-decisions.mjs +341 -0
- package/src/host/tool-policy-hook.mjs +270 -0
- package/src/notify/index.mjs +359 -0
- package/src/notify/os-macos.mjs +81 -0
- package/src/repo/declared-paths.mjs +220 -0
- package/src/repo/integrate.mjs +546 -0
- package/src/repo/scope-closure.mjs +665 -0
- package/src/repo/signal-block.mjs +16 -0
- package/src/repo/signal.mjs +222 -0
- package/src/repo/source-identity.mjs +295 -0
- package/src/repo/workspace.mjs +557 -0
- package/src/repo/worktree.mjs +352 -0
- package/src/report/final.mjs +200 -0
- package/src/report/metrics-report.mjs +99 -0
- package/src/report/next.mjs +383 -0
- package/src/report/render.mjs +716 -0
- package/src/run/disk-gc.mjs +251 -0
- package/src/run/lock.mjs +329 -0
- package/src/run/node-store.mjs +62 -0
- package/src/run/operations.mjs +286 -0
- package/src/run/store.mjs +187 -0
- package/src/run/usage.mjs +337 -0
- package/src/seat/harnesses.mjs +83 -0
- package/src/seat/index.mjs +239 -0
- package/src/seat/tmux.mjs +208 -0
- package/src/util.mjs +0 -0
- package/src/web/api.mjs +371 -0
- package/src/web/boundary.mjs +88 -0
- package/src/web/index.html +299 -0
- package/src/web/server.mjs +552 -0
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { loadTaskPacket, renderWorkerPrompt } from "./task-packet.mjs";
|
|
5
|
+
import { RESERVED_ARTICLES } from "./articles.mjs";
|
|
6
|
+
import { validateDefinitionOfDone } from "./definition-of-done.mjs";
|
|
7
|
+
import { validateFinalVerification } from "./final-verification.mjs";
|
|
8
|
+
import { VERIFICATION_LIMITS } from "./verification.mjs";
|
|
9
|
+
import {
|
|
10
|
+
validateCapabilityRequirements,
|
|
11
|
+
} from "../harnesses/index.mjs";
|
|
12
|
+
import { DISCOVERY_RUNTIME_DEFINITIONS } from "../engine/runtime-discovery.mjs";
|
|
13
|
+
import { stableJson } from "../util.mjs";
|
|
14
|
+
import { assertObject, boundedString, nonNegativeInteger, nonNegativeNumber, positiveInteger, positiveNumber, rejectUnknown, requireId, requireString } from "./assert.mjs";
|
|
15
|
+
import { validateMetadata } from "./schema-version.mjs";
|
|
16
|
+
import { assertRuntimeExecutesCommands, requireRuntime, validateRuntime } from "./runtime.mjs";
|
|
17
|
+
import { validateSourceIdentity } from "../repo/source-identity.mjs";
|
|
18
|
+
import { commandCoverageWarnings, unsnapshottedWriteWarnings } from "../repo/declared-paths.mjs";
|
|
19
|
+
import { crossNodeScopeFindings, scopeClosureFindings } from "../repo/scope-closure.mjs";
|
|
20
|
+
|
|
21
|
+
export { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION } from "../harnesses/index.mjs";
|
|
22
|
+
|
|
23
|
+
const CONTRACT_FIELDS = new Set([
|
|
24
|
+
"schemaVersion", "contractVersion", "id", "campaignId", "goal", "cwd", "sourceIdentity",
|
|
25
|
+
"maxParallel", "pollIntervalMs", "stallTimeoutSec", "timeoutSec",
|
|
26
|
+
"runtimeDefaults", "runtimes", "nodes", "warnings", "finalVerification", "nodeAdvisory",
|
|
27
|
+
]);
|
|
28
|
+
const DEFAULTS_FIELDS = new Set(["worker", "judge"]);
|
|
29
|
+
const NODE_FIELDS = new Set([
|
|
30
|
+
"id", "type", "phase", "runtime", "dependsOn", "taskPacket", "taskPacketFile", "prompt", "promptFile",
|
|
31
|
+
"definitionOfDone", "gate", "timeoutSec",
|
|
32
|
+
"requiredCapabilities", "packetHash", "sourceIdentity", "replayPolicy",
|
|
33
|
+
]);
|
|
34
|
+
const REPLAY_POLICIES = new Set(["safe", "reconcile", "never"]);
|
|
35
|
+
const GATE_FIELDS = new Set(["enabled", "runtime", "review", "failOn", "maxRevisions", "requiredCapabilities", "skipWhen"]);
|
|
36
|
+
const GATE_REVIEWS = new Set(["none", "advisory", "blocking"]);
|
|
37
|
+
|
|
38
|
+
/** @typedef {Record<string, unknown>} JsonObject */
|
|
39
|
+
|
|
40
|
+
/** @typedef {{structuredOutput?: boolean, promptTransport?: "stdin"|"argv", sandbox?: boolean, permissions?: boolean, continuation?: boolean, tokenBudget?: boolean, costBudget?: boolean, usage?: boolean, cost?: boolean}} CapabilityRequirements */
|
|
41
|
+
|
|
42
|
+
/** @typedef {{kind: string, id?: string, campaignId?: string, contractId?: string, nodeId?: string, cwd?: string, gitHead?: string|null, dirtyTreeFingerprint?: string|null, packetHashes?: Record<string, string>, harnessVersions?: Record<string, string|null>}} SourceIdentity */
|
|
43
|
+
|
|
44
|
+
/** @typedef {{argv: string[], cwd?: string, timeoutSec?: number, repeat?: number, env?: string[]}} VerificationCommand */
|
|
45
|
+
|
|
46
|
+
/** @typedef {{mode: "execution"|"discovery"|"autonomous", objective: string, instructions: string[], readFiles: string[], writeFiles?: string[], writeRoots?: string[], symbols: string[], scopeAcknowledged?: string[], decisions: string[], nonGoals: string[], verification: VerificationCommand[]}} TaskPacket */
|
|
47
|
+
|
|
48
|
+
/** @typedef {{harness: "claude"|"codex"|"agy"|"dsh"|"zcode"|"exec-jsonl"|"replay", model: string, reasoning?: string, sandbox?: "read-only"|"workspace-write"|"danger-full-access", permissionMode?: string, config?: Record<string, unknown>, printTimeout?: string, tools?: string[], executable?: string, args?: string[], versionArgs?: string[], maxArgvPromptBytes?: number, requiredCapabilities?: CapabilityRequirements, costRank?: number, fallback?: string, vendor: string, tier?: number|string, stallTimeoutSec?: number}} ValidatedRuntime */
|
|
49
|
+
|
|
50
|
+
/** @typedef {{enabled: boolean, review?: ("none"|"advisory"|"blocking"), runtime?: string, failOn?: ("minor"|"major"|"critical")[], maxRevisions?: number, requiredCapabilities?: CapabilityRequirements, skipWhen?: {verificationGreen: true, maxChangedPaths: number}}} ValidatedGate */
|
|
51
|
+
|
|
52
|
+
/** @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, requiredCapabilities: CapabilityRequirements, packetHash: string, sourceIdentity: SourceIdentity, replayPolicy: "safe"|"reconcile"|"never"}} ValidatedNode */
|
|
53
|
+
|
|
54
|
+
/** @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, finalVerification?: VerificationCommand[], nodeAdvisory?: NodeAdvisoryPolicy, warnings: string[]}} ValidatedContract */
|
|
55
|
+
/** @typedef {{costUsd?: number, durationSec?: number}} NodeAdvisoryPolicy */
|
|
56
|
+
|
|
57
|
+
/** @typedef {"pending"|"running"|"done"|"no-op"|"blocked"|"failed"|"exhausted"|"stalled"|"canceled"} NodeStatus */
|
|
58
|
+
/** @typedef {"waiting"|"worker"|"judge"|"complete"|"dependency"|"canceled"} NodePhase */
|
|
59
|
+
/** @typedef {{severity: "minor"|"major"|"critical", description: string, evidence: string}} Finding */
|
|
60
|
+
/** @typedef {{verdict: "pass"|"fail"|"invalid_judge_output", maxSeverity: "none"|"minor"|"major"|"critical", summary: string, findings: Finding[]}} GateResult */
|
|
61
|
+
/** @typedef {{code: string, message: string, exhaustedUntil?: string|null}} SnapshotError */
|
|
62
|
+
/** @typedef {{inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}} Usage */
|
|
63
|
+
/** @typedef {ValidatedRuntime & {id: string, capabilities: import("../harnesses/index.mjs").HarnessCapabilities}} RuntimeSnapshot */
|
|
64
|
+
/** @typedef {import("../engine/lifecycle.mjs").Invocation} Invocation */
|
|
65
|
+
/** @typedef {import("./verification.mjs").VerificationCommandResult} VerificationCommandResult */
|
|
66
|
+
/** @typedef {import("./verification.mjs").VerificationAttempt} VerificationAttempt */
|
|
67
|
+
/** @typedef {{passed: boolean, commands?: VerificationCommandResult[], completed?: boolean, error?: string, attempts?: VerificationAttempt[]}} VerificationState */
|
|
68
|
+
/** @typedef {{kind: "recovery"|"timeout"|"rotation", decision?: string, invocationId?: string, phase?: "worker"|"judge", result?: unknown, usage?: Usage, costUsd?: number|null, reason?: string, timeoutSec?: number, at?: string}} ExecutionOverride */
|
|
69
|
+
/** @typedef {{literal: string, paths: string[]}} WorkspaceScopeOrigin */
|
|
70
|
+
/** @typedef {{schemaVersion: 1, files: string[], roots: string[], fileRoots?: string[], fileOrigins: WorkspaceScopeOrigin[], rootOrigins: WorkspaceScopeOrigin[]}} WorkspaceScopeBoundary */
|
|
71
|
+
/** @typedef {{changedPaths: string[], unexpectedPaths: string[], changedPathCount: number, unexpectedPathCount: number, truncated: boolean, boundary?: WorkspaceScopeBoundary}} BoundedScope */
|
|
72
|
+
/** @typedef {{unexpectedPaths: string[]}} ScopeFindings */
|
|
73
|
+
/** @typedef {{at: string, role: "worker"|"judge", runtime: string, nextRuntime?: string, rule?: number, ruleIndex?: number, revision?: number, hop?: number, status?: NodeStatus, errorCode?: string, backoffSec?: number, backoffUntil?: string, usage?: Usage, costUsd?: number|null, costProvenance?: "priced"}} RoutingHistoryEntry */
|
|
74
|
+
/** @typedef {{at: string, role: "worker"|"judge", runtime: string, nextRuntime?: string, rule?: number, ruleIndex?: number, revision?: number, hop?: number, reason: string, backoffSec?: number, backoffUntil?: string, usage?: Usage, costUsd?: number|null, costProvenance?: "priced"}} RoutingOverride */
|
|
75
|
+
/** @typedef {{worker: string, judge: string, composedWorker?: boolean, composedJudge?: boolean}} RuntimeAssignments */
|
|
76
|
+
/** @typedef {{available: boolean, exhaustedUntil: string|null, reason: string}} RuntimeAvailability */
|
|
77
|
+
/** @typedef {{runtimeId: string, exhaustedUntil: string|null}} TierExhaustionCandidate */
|
|
78
|
+
/** @typedef {{role: "worker"|"judge", candidates: TierExhaustionCandidate[]}} TierExhaustion */
|
|
79
|
+
/** @typedef {{history: RoutingHistoryEntry[], currentOverride: RoutingOverride|null, assignments?: RuntimeAssignments, availability?: Record<string, RuntimeAvailability>, tierExhaustion?: TierExhaustion, tierExhaustionCycle?: number}} RoutingState */
|
|
80
|
+
/** @typedef {{revision?: number, heartbeatCount: number, dryHeartbeatCount: number, progressSignature?: string|null, lastHeartbeatAt: string|null, lastProgressAt: string|null, nextCheckAt?: string|null}} ProgressState */
|
|
81
|
+
/** @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 */
|
|
82
|
+
/** @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}} NodeSnapshot */
|
|
83
|
+
/** @typedef {{path: string, sha: string}} ControllerIdentity */
|
|
84
|
+
/** @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 */
|
|
85
|
+
/** @typedef {{at: string, base: string|null, dirtyTreeFingerprint: string|null}} ScopeDecision */
|
|
86
|
+
/** @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 */
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Validate and canonicalize the versioned contract. Runtime JSON remains
|
|
90
|
+
* authoritative; JSDoc types document the validated shape only.
|
|
91
|
+
*
|
|
92
|
+
* `persisted` is the frozen-replay path: the contract was already validated at
|
|
93
|
+
* run creation, so every tree-dependent decision (the cwd directory, readFiles
|
|
94
|
+
* and writeRoots existence and anchors, realpath and symlink checks,
|
|
95
|
+
* verification cwds, scope closure, ignore probes) is skipped. A persisted
|
|
96
|
+
* load touches no filesystem at all; it differs from authoring only in what it
|
|
97
|
+
* refuses to re-derive from the mutated tree.
|
|
98
|
+
*
|
|
99
|
+
* @param {JsonObject} raw
|
|
100
|
+
* @param {string} contractPath
|
|
101
|
+
* @param {{persisted?: boolean, contractDigest?: string}} [options]
|
|
102
|
+
* @returns {ValidatedContract}
|
|
103
|
+
*/
|
|
104
|
+
export function validateContract(raw, contractPath, options = {}) {
|
|
105
|
+
const persisted = options.persisted === true;
|
|
106
|
+
assertObject(raw, "contract");
|
|
107
|
+
rejectUnknown(raw, CONTRACT_FIELDS, "contract");
|
|
108
|
+
validateMetadata(raw, "contract");
|
|
109
|
+
requireId(raw.id, "contract.id");
|
|
110
|
+
requireId(raw.campaignId, "contract.campaignId");
|
|
111
|
+
requireString(raw.goal, "contract.goal");
|
|
112
|
+
|
|
113
|
+
const contractDir = dirname(resolve(contractPath));
|
|
114
|
+
const cwd = resolve(contractDir, typeof raw.cwd === "string" ? raw.cwd : ".");
|
|
115
|
+
if (!persisted && !statSync(cwd).isDirectory()) throw new TypeError("contract.cwd must be a directory");
|
|
116
|
+
|
|
117
|
+
const sourceIdentity = validateSourceIdentity(
|
|
118
|
+
raw.sourceIdentity ?? { kind: "contract", id: raw.id, campaignId: raw.campaignId },
|
|
119
|
+
"contract.sourceIdentity",
|
|
120
|
+
{ kind: "contract", id: raw.id, campaignId: raw.campaignId },
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const rawRuntimes = /** @type {Record<string, JsonObject>} */ (raw.runtimes ?? DISCOVERY_RUNTIME_DEFINITIONS);
|
|
124
|
+
if (!rawRuntimes || typeof rawRuntimes !== "object" || Array.isArray(rawRuntimes)) {
|
|
125
|
+
throw new TypeError("contract.runtimes must be an object");
|
|
126
|
+
}
|
|
127
|
+
const runtimes = /** @type {Record<string, ValidatedRuntime>} */ ({});
|
|
128
|
+
for (const [id, runtime] of Object.entries(rawRuntimes)) runtimes[id] = validateRuntime(id, runtime);
|
|
129
|
+
// A runtime's fallback is validated against sibling runtimes once every
|
|
130
|
+
// runtime is known, so declaration order never matters.
|
|
131
|
+
for (const [id, runtime] of Object.entries(runtimes)) {
|
|
132
|
+
if (runtime.fallback === undefined) continue;
|
|
133
|
+
if (runtime.fallback === id) throw new TypeError(`runtime ${id}.fallback cannot name itself`);
|
|
134
|
+
requireRuntime(runtimes, runtime.fallback, `runtime ${id}.fallback`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const defaults = /** @type {JsonObject} */ (raw.runtimeDefaults ?? {});
|
|
138
|
+
if (!defaults || typeof defaults !== "object" || Array.isArray(defaults)) {
|
|
139
|
+
throw new TypeError("contract.runtimeDefaults must be an object when provided");
|
|
140
|
+
}
|
|
141
|
+
rejectUnknown(defaults, DEFAULTS_FIELDS, "contract.runtimeDefaults");
|
|
142
|
+
if (defaults.worker !== undefined) requireRuntime(runtimes, defaults.worker, "runtimeDefaults.worker");
|
|
143
|
+
if (defaults.judge !== undefined) requireRuntime(runtimes, defaults.judge, "runtimeDefaults.judge");
|
|
144
|
+
|
|
145
|
+
if (!Array.isArray(raw.nodes) || raw.nodes.length === 0) {
|
|
146
|
+
throw new TypeError("contract.nodes must be a non-empty array");
|
|
147
|
+
}
|
|
148
|
+
const rawNodes = /** @type {JsonObject[]} */ (raw.nodes);
|
|
149
|
+
const ids = new Set();
|
|
150
|
+
/** @type {{path: string, label: string}[][]} */
|
|
151
|
+
const deferredReadsByNode = [];
|
|
152
|
+
const nodes = rawNodes.map((node, index) => {
|
|
153
|
+
assertObject(node, `nodes[${index}]`);
|
|
154
|
+
rejectUnknown(node, NODE_FIELDS, `nodes[${index}]`);
|
|
155
|
+
if (node.prompt !== undefined || node.promptFile !== undefined) {
|
|
156
|
+
throw new TypeError(`nodes[${index}] must not use prompt or promptFile; provide exactly one of taskPacket or taskPacketFile`);
|
|
157
|
+
}
|
|
158
|
+
requireId(node.id, `nodes[${index}].id`);
|
|
159
|
+
if (ids.has(node.id)) throw new TypeError(`duplicate node id: ${node.id}`);
|
|
160
|
+
ids.add(node.id);
|
|
161
|
+
requireString(node.type, `nodes[${index}].type`);
|
|
162
|
+
boundedString(node.phase, `nodes[${index}].phase`, 128);
|
|
163
|
+
if (node.runtime !== undefined) requireRuntime(runtimes, node.runtime, `nodes[${index}].runtime`);
|
|
164
|
+
const dependsOn = node.dependsOn ?? [];
|
|
165
|
+
if (!Array.isArray(dependsOn) || dependsOn.some((id) => typeof id !== "string")) {
|
|
166
|
+
throw new TypeError(`nodes[${index}].dependsOn must be an array of ids`);
|
|
167
|
+
}
|
|
168
|
+
// A readFiles entry that names a file no dependency has produced yet is a
|
|
169
|
+
// missing read today, but the graph is not known until every node is
|
|
170
|
+
// loaded. Collect the candidate here; the second pass below resolves each
|
|
171
|
+
// against the node's transitive closure once all packets and dependsOn
|
|
172
|
+
// edges are in hand.
|
|
173
|
+
/** @type {{path: string, label: string}[]} */
|
|
174
|
+
const deferredReads = [];
|
|
175
|
+
const taskPacket = loadTaskPacket(node, contractDir, cwd, index, { deferMissingReads: true, deferredReads, persisted });
|
|
176
|
+
deferredReadsByNode.push(deferredReads);
|
|
177
|
+
// The reserved articles are the common law: a contract adds its own as
|
|
178
|
+
// references/local-*.md and may never claim a reserved name, at any
|
|
179
|
+
// directory depth, or a run could overwrite the constitution mid-flight.
|
|
180
|
+
const reservedClaim = reservedArticleClaim(taskPacket);
|
|
181
|
+
if (reservedClaim !== undefined) {
|
|
182
|
+
throw new TypeError(`nodes[${index}] (${node.id}): ${reservedClaim} is a reserved article; declare contract articles as references/local-*.md`);
|
|
183
|
+
}
|
|
184
|
+
const prompt = renderWorkerPrompt(taskPacket, /** @type {string} */ (node.id));
|
|
185
|
+
const packetHash = hashPacket(taskPacket);
|
|
186
|
+
if (node.packetHash !== undefined && node.packetHash !== packetHash) {
|
|
187
|
+
throw new TypeError(`nodes[${index}].packetHash does not match taskPacket`);
|
|
188
|
+
}
|
|
189
|
+
const source = validateSourceIdentity(
|
|
190
|
+
node.sourceIdentity ?? { kind: "node", contractId: raw.id, nodeId: node.id },
|
|
191
|
+
`nodes[${index}].sourceIdentity`,
|
|
192
|
+
{ kind: "node", contractId: raw.id, nodeId: node.id },
|
|
193
|
+
);
|
|
194
|
+
const definitionOfDone = validateDefinitionOfDone(
|
|
195
|
+
node.definitionOfDone ?? [],
|
|
196
|
+
`nodes[${index}].definitionOfDone`,
|
|
197
|
+
{
|
|
198
|
+
// A `verification` proof names an entry of this packet's verification
|
|
199
|
+
// array by position and reuses its recorded result at gate time; an
|
|
200
|
+
// entry the node snapshot cannot record could never be reused.
|
|
201
|
+
verificationCount: taskPacket.verification.length,
|
|
202
|
+
recordableCount: VERIFICATION_LIMITS.stateCommands,
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
const requiredCapabilities = validateCapabilityRequirements(
|
|
206
|
+
/** @type {import("../harnesses/index.mjs").CapabilityRequirements|undefined} */ (node.requiredCapabilities),
|
|
207
|
+
`nodes[${index}].requiredCapabilities`,
|
|
208
|
+
);
|
|
209
|
+
const gate = validateGate(node.gate, runtimes, index, /** @type {string} */ (node.id));
|
|
210
|
+
const timeoutSec = node.timeoutSec === undefined
|
|
211
|
+
? undefined
|
|
212
|
+
: positiveNumber(node.timeoutSec, `nodes[${index}].timeoutSec`);
|
|
213
|
+
const replayPolicy = validateReplayPolicy(node.replayPolicy, `nodes[${index}]`);
|
|
214
|
+
return /** @type {ValidatedNode} */ ({
|
|
215
|
+
...node,
|
|
216
|
+
dependsOn,
|
|
217
|
+
definitionOfDone,
|
|
218
|
+
requiredCapabilities,
|
|
219
|
+
taskPacket,
|
|
220
|
+
packetHash,
|
|
221
|
+
sourceIdentity: source,
|
|
222
|
+
prompt,
|
|
223
|
+
gate,
|
|
224
|
+
timeoutSec,
|
|
225
|
+
replayPolicy,
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
for (const node of nodes) {
|
|
230
|
+
for (const dependency of node.dependsOn) {
|
|
231
|
+
if (!ids.has(dependency)) throw new TypeError(`${node.id} depends on unknown node ${dependency}`);
|
|
232
|
+
if (dependency === node.id) throw new TypeError(`${node.id} cannot depend on itself`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
assertAcyclic(nodes);
|
|
236
|
+
|
|
237
|
+
// Second pass: a readFiles entry deferred at packet load is accepted only
|
|
238
|
+
// when some transitive dependency produces it -- declares the identical path
|
|
239
|
+
// in its writeFiles, or the path sits under a dependency's directory-shaped
|
|
240
|
+
// writeRoots entry (a file-shaped entry authorizes exactly that path). Every
|
|
241
|
+
// other caller of validateTaskPacket keeps rejecting the missing read inline;
|
|
242
|
+
// this graph-aware deferral is a contract-loading capability only. Persisted
|
|
243
|
+
// loads never defer -- they skipped the existence probe, so there is nothing
|
|
244
|
+
// to resolve and nothing to stat.
|
|
245
|
+
if (!persisted) {
|
|
246
|
+
for (const [index, node] of nodes.entries()) {
|
|
247
|
+
const deferredReads = deferredReadsByNode[index];
|
|
248
|
+
if (deferredReads.length === 0) continue;
|
|
249
|
+
const closure = transitiveDependencyClosure(node, nodes);
|
|
250
|
+
for (const { path, label } of deferredReads) {
|
|
251
|
+
if (!dependencyCoversRead(closure, path, cwd)) {
|
|
252
|
+
throw new TypeError(`${label} does not exist: ${path}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// A gated node whose worker and judge share a vendor cannot produce an
|
|
259
|
+
// independent review — the same vendor grading its own output is not a
|
|
260
|
+
// gate, so this is rejected outright rather than left to reach dispatch.
|
|
261
|
+
// The worker's declared fallback chain is checked the same way, since it is
|
|
262
|
+
// statically known which runtime a worker failover lands on; the symmetric
|
|
263
|
+
// case — the judge's own fallback landing on the worker's vendor — depends
|
|
264
|
+
// on which worker runtime actually ran and is refused at execution instead
|
|
265
|
+
// (node.mjs, `judge_fallback_vendor_conflict`).
|
|
266
|
+
for (const [index, node] of nodes.entries()) {
|
|
267
|
+
if (!node.gate.enabled) continue;
|
|
268
|
+
const workerRuntimeId = node.runtime ?? defaults.worker;
|
|
269
|
+
const judgeRuntimeId = node.gate.runtime ?? defaults.judge;
|
|
270
|
+
if (!workerRuntimeId || !judgeRuntimeId) continue;
|
|
271
|
+
const workerVendor = runtimes[/** @type {string} */ (workerRuntimeId)].vendor;
|
|
272
|
+
const judgeVendor = runtimes[/** @type {string} */ (judgeRuntimeId)].vendor;
|
|
273
|
+
if (workerVendor === judgeVendor) {
|
|
274
|
+
throw new TypeError(`nodes[${index}] worker runtime ${workerRuntimeId} and judge runtime ${judgeRuntimeId} share vendor ${workerVendor}`);
|
|
275
|
+
}
|
|
276
|
+
const seenFallbacks = new Set([/** @type {string} */ (workerRuntimeId)]);
|
|
277
|
+
let fallbackId = runtimes[/** @type {string} */ (workerRuntimeId)].fallback;
|
|
278
|
+
while (fallbackId !== undefined) {
|
|
279
|
+
if (seenFallbacks.has(fallbackId)) {
|
|
280
|
+
throw new TypeError(`nodes[${index}] worker runtime ${workerRuntimeId} fallback chain cycles back to ${fallbackId}`);
|
|
281
|
+
}
|
|
282
|
+
seenFallbacks.add(fallbackId);
|
|
283
|
+
const fallbackVendor = runtimes[fallbackId].vendor;
|
|
284
|
+
if (fallbackVendor === judgeVendor) {
|
|
285
|
+
throw new TypeError(`nodes[${index}] worker runtime ${workerRuntimeId} fallback runtime ${fallbackId} and judge runtime ${judgeRuntimeId} share vendor ${fallbackVendor}`);
|
|
286
|
+
}
|
|
287
|
+
fallbackId = runtimes[fallbackId].fallback;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Every worker prompt tells the worker to run its packet verification.
|
|
292
|
+
// Refuse a statically known permission mode that makes that instruction
|
|
293
|
+
// impossible, including every reachable worker fallback. Judges only review
|
|
294
|
+
// captured results, so their permission mode is intentionally irrelevant.
|
|
295
|
+
for (const [index, node] of nodes.entries()) {
|
|
296
|
+
if (node.taskPacket.verification.length === 0) continue;
|
|
297
|
+
const workerRuntimeId = node.runtime ?? defaults.worker;
|
|
298
|
+
if (!workerRuntimeId) continue;
|
|
299
|
+
assertRuntimeExecutesCommands(runtimes, /** @type {string} */ (workerRuntimeId), index, node.id, "worker runtime");
|
|
300
|
+
const seenFallbacks = new Set([/** @type {string} */ (workerRuntimeId)]);
|
|
301
|
+
let fallbackId = runtimes[/** @type {string} */ (workerRuntimeId)].fallback;
|
|
302
|
+
while (fallbackId !== undefined && !seenFallbacks.has(fallbackId)) {
|
|
303
|
+
seenFallbacks.add(fallbackId);
|
|
304
|
+
assertRuntimeExecutesCommands(runtimes, fallbackId, index, node.id, "worker fallback runtime");
|
|
305
|
+
fallbackId = runtimes[fallbackId].fallback;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Scope closure is a refusal, not a warning: the warnings below are for the
|
|
310
|
+
// author's attention, but a file that must change with the write set and was
|
|
311
|
+
// neither declared nor acknowledged makes the packet incomplete, and the
|
|
312
|
+
// node would either break it or be structurally unable to touch it. The
|
|
313
|
+
// three incidents this catches (p3 bulk-read, sp1 lossy-notify, sp2
|
|
314
|
+
// seat-switch) each cost a node. This runs on every load, replay included: a
|
|
315
|
+
// persisted packet is the same packet, and a scope gap does not heal because
|
|
316
|
+
// it was recorded.
|
|
317
|
+
//
|
|
318
|
+
// The per-node detectors cannot see the pair that made seat-switch cost a
|
|
319
|
+
// node: seat-lifecycle wrote the test, seat-switch wrote the module, and each
|
|
320
|
+
// packet read alone is clean. `crossNodeScopeFindings` reads all nodes
|
|
321
|
+
// together and names the node whose packet must gain the test.
|
|
322
|
+
const scopeErrors = persisted ? [] : [
|
|
323
|
+
...nodes.flatMap((node, index) =>
|
|
324
|
+
scopeClosureFindings(node, index, cwd).map(
|
|
325
|
+
(finding) => `nodes[${index}] (${node.id}): ${finding.path} (${finding.detector}: ${finding.reason})`,
|
|
326
|
+
),
|
|
327
|
+
),
|
|
328
|
+
...crossNodeScopeFindings(nodes, cwd).map(
|
|
329
|
+
(finding) => `nodes[${finding.nodeIndex}] (${finding.nodeId}): ${finding.path} (${finding.detector}: ${finding.reason})`,
|
|
330
|
+
),
|
|
331
|
+
];
|
|
332
|
+
if (scopeErrors.length) {
|
|
333
|
+
throw new TypeError(`task packet scope does not close; declare in readFiles or writeFiles, or acknowledge in scopeAcknowledged: ${scopeErrors.join("; ")}`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const warnings = nodes.flatMap((node, index) => [
|
|
337
|
+
...commandCoverageWarnings(node, index),
|
|
338
|
+
...(persisted ? [] : unsnapshottedWriteWarnings(node, index, cwd)),
|
|
339
|
+
]);
|
|
340
|
+
const contract = /** @type {ValidatedContract} */ ({
|
|
341
|
+
...raw,
|
|
342
|
+
schemaVersion: /** @type {number} */ (raw.schemaVersion),
|
|
343
|
+
contractVersion: /** @type {string} */ (raw.contractVersion),
|
|
344
|
+
sourceIdentity,
|
|
345
|
+
cwd,
|
|
346
|
+
runtimes,
|
|
347
|
+
runtimeDefaults: /** @type {{worker?: string, judge?: string}} */ (defaults),
|
|
348
|
+
nodes,
|
|
349
|
+
maxParallel: validateMaxParallel(raw.maxParallel ?? 1),
|
|
350
|
+
pollIntervalMs: positiveInteger(raw.pollIntervalMs ?? 1_000, "contract.pollIntervalMs"),
|
|
351
|
+
stallTimeoutSec: positiveNumber(raw.stallTimeoutSec ?? 300, "contract.stallTimeoutSec"),
|
|
352
|
+
timeoutSec: positiveNumber(raw.timeoutSec ?? 2_400, "contract.timeoutSec"),
|
|
353
|
+
finalVerification: validateFinalVerification(raw.finalVerification, "contract.finalVerification"),
|
|
354
|
+
nodeAdvisory: validateNodeAdvisory(raw.nodeAdvisory),
|
|
355
|
+
warnings,
|
|
356
|
+
});
|
|
357
|
+
// The persisted load is a replay, not a re-authoring: it accepts only bytes
|
|
358
|
+
// whose digest matches the decision frozen at launch. A changed DAG, gate,
|
|
359
|
+
// runtime selection, timeout, definition of done or finalVerification leaves
|
|
360
|
+
// every packetHash untouched, so only this digest refuses it.
|
|
361
|
+
if (persisted && options.contractDigest !== undefined && contractDigest(raw) !== options.contractDigest) {
|
|
362
|
+
throw new TypeError("persisted contract does not match the contractDigest recorded at run creation; the stored contract was modified after the run was created");
|
|
363
|
+
}
|
|
364
|
+
return contract;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* A persisted load: read the one contract.json the caller handed in, validate
|
|
369
|
+
* it without touching the tree, and refuse it when its digest does not match
|
|
370
|
+
* the decision frozen at launch. This is the only filesystem call the load
|
|
371
|
+
* makes, which is what done-when 5 asserts structurally.
|
|
372
|
+
*
|
|
373
|
+
* @param {string} contractPath
|
|
374
|
+
* @param {string|undefined} expectedDigest the `contractDigest` recorded in run.json
|
|
375
|
+
* @returns {ValidatedContract}
|
|
376
|
+
*/
|
|
377
|
+
export function loadPersistedContract(contractPath, expectedDigest) {
|
|
378
|
+
const raw = /** @type {JsonObject} */ (JSON.parse(readFileSync(contractPath, "utf8")));
|
|
379
|
+
return validateContract(raw, contractPath, {
|
|
380
|
+
persisted: true,
|
|
381
|
+
...(expectedDigest === undefined ? {} : { contractDigest: expectedDigest }),
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Canonical digest of the contract that was actually validated, minus
|
|
387
|
+
* `sourceIdentity` (which carries the absolute cwd, git head and fingerprint),
|
|
388
|
+
* minus `warnings` (authoring-attention text, not contract content), and minus
|
|
389
|
+
* the absolute `cwd`. The derived fields a stored contract.json drops --
|
|
390
|
+
* `prompt`, `promptFile`, `taskPacketFile` -- are excluded too, so the digest a
|
|
391
|
+
* load recomputes from the stored bytes matches the one computed at creation.
|
|
392
|
+
*
|
|
393
|
+
* @param {Record<string, unknown>|JsonObject} contract a validated contract or
|
|
394
|
+
* the stored raw contract it was serialized from
|
|
395
|
+
* @returns {string}
|
|
396
|
+
*/
|
|
397
|
+
export function contractDigest(contract) {
|
|
398
|
+
const record = /** @type {Record<string, unknown>} */ ({ ...contract });
|
|
399
|
+
delete record.sourceIdentity;
|
|
400
|
+
delete record.warnings;
|
|
401
|
+
delete record.cwd;
|
|
402
|
+
const nodes = Array.isArray(record.nodes) ? record.nodes : [];
|
|
403
|
+
record.nodes = nodes.map((node) => {
|
|
404
|
+
const copy = /** @type {Record<string, unknown>} */ ({ ...(/** @type {Record<string, unknown>} */ (node)) });
|
|
405
|
+
delete copy.prompt;
|
|
406
|
+
delete copy.promptFile;
|
|
407
|
+
delete copy.taskPacketFile;
|
|
408
|
+
return copy;
|
|
409
|
+
});
|
|
410
|
+
return createHash("sha256").update(stableJson(record)).digest("hex");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Stable hash for the exact validated packet content.
|
|
415
|
+
*
|
|
416
|
+
* @param {TaskPacket} packet
|
|
417
|
+
* @returns {string}
|
|
418
|
+
*/
|
|
419
|
+
export function hashPacket(packet) {
|
|
420
|
+
return createHash("sha256").update(stableJson(packet)).digest("hex");
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* @param {unknown} gate
|
|
425
|
+
* @param {Record<string, ValidatedRuntime>} runtimes
|
|
426
|
+
* @param {number} index
|
|
427
|
+
* @param {string} nodeId
|
|
428
|
+
* @returns {ValidatedGate}
|
|
429
|
+
*/
|
|
430
|
+
function validateGate(gate, runtimes, index, nodeId) {
|
|
431
|
+
const label = `nodes[${index}] (${nodeId})`;
|
|
432
|
+
if (gate === false || gate === undefined) return { enabled: false };
|
|
433
|
+
assertObject(gate, `nodes[${index}].gate`);
|
|
434
|
+
rejectUnknown(gate, GATE_FIELDS, `nodes[${index}].gate`);
|
|
435
|
+
if (gate.enabled === false) {
|
|
436
|
+
if (Object.keys(gate).length !== 1) throw new TypeError(`nodes[${index}].gate disabled shape only allows enabled`);
|
|
437
|
+
return { enabled: false };
|
|
438
|
+
}
|
|
439
|
+
if (gate.enabled !== undefined && gate.enabled !== true) {
|
|
440
|
+
throw new TypeError(`nodes[${index}].gate.enabled must be true or false`);
|
|
441
|
+
}
|
|
442
|
+
if (gate.review !== undefined && !GATE_REVIEWS.has(/** @type {string} */ (gate.review))) {
|
|
443
|
+
throw new TypeError(`nodes[${index}].gate.review must be none, advisory, or blocking`);
|
|
444
|
+
}
|
|
445
|
+
const review = /** @type {("none"|"advisory"|"blocking")} */ (gate.review ?? "advisory");
|
|
446
|
+
if (gate.runtime !== undefined) requireRuntime(runtimes, gate.runtime, `nodes[${index}].gate.runtime`);
|
|
447
|
+
const failOnValue = /** @type {unknown} */ (gate.failOn ?? ["critical"]);
|
|
448
|
+
if (!Array.isArray(failOnValue) || failOnValue.some((value) => !["minor", "major", "critical"].includes(value))) {
|
|
449
|
+
throw new TypeError(`nodes[${index}].gate.failOn contains an invalid severity`);
|
|
450
|
+
}
|
|
451
|
+
const failOn = /** @type {("minor"|"major"|"critical")[]} */ (failOnValue);
|
|
452
|
+
// The runner compares the verdict severity against this set by exact
|
|
453
|
+
// membership, so `["major"]` admits a critical finding: the threshold set
|
|
454
|
+
// has to be closed downwards (TECH-SPEC lean, rule 2).
|
|
455
|
+
if (failOn.includes("major") && !failOn.includes("critical")) {
|
|
456
|
+
throw new TypeError(`${label}: gate.failOn lists major without critical, and the gate checks exact membership, so a critical finding would pass (TECH-SPEC lean, rule 2)`);
|
|
457
|
+
}
|
|
458
|
+
// A blocking review that never fails on a major can never reject one, so it
|
|
459
|
+
// is not a review at all. Advisory review ignores failOn and may declare any.
|
|
460
|
+
if (review === "blocking" && !failOn.includes("major")) {
|
|
461
|
+
throw new TypeError(`${label}: gate.review blocking requires major in gate.failOn (TECH-SPEC lean, rule 2)`);
|
|
462
|
+
}
|
|
463
|
+
return {
|
|
464
|
+
enabled: true,
|
|
465
|
+
review,
|
|
466
|
+
runtime: /** @type {string|undefined} */ (gate.runtime),
|
|
467
|
+
failOn,
|
|
468
|
+
maxRevisions: nonNegativeInteger(gate.maxRevisions ?? 1, `nodes[${index}].gate.maxRevisions`),
|
|
469
|
+
skipWhen: validateGateSkipWhen(gate.skipWhen, `nodes[${index}].gate.skipWhen`),
|
|
470
|
+
requiredCapabilities: validateCapabilityRequirements(
|
|
471
|
+
/** @type {import("../harnesses/index.mjs").CapabilityRequirements|undefined} */ (gate.requiredCapabilities),
|
|
472
|
+
`nodes[${index}].gate.requiredCapabilities`,
|
|
473
|
+
),
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* The green-and-small escape hatch for the judge gate. Both conditions must
|
|
479
|
+
* hold — controller verification green and no more changed workspace paths
|
|
480
|
+
* than the declared ceiling — for `startJudge` to skip the judge even though a
|
|
481
|
+
* Definition of Done item carries `judgment: true`. `verificationGreen` is
|
|
482
|
+
* fixed at `true`: a skip rule keyed on red verification would be the opposite
|
|
483
|
+
* of the intent.
|
|
484
|
+
*
|
|
485
|
+
* @param {unknown} value
|
|
486
|
+
* @param {string} label
|
|
487
|
+
* @returns {{verificationGreen: true, maxChangedPaths: number}|undefined}
|
|
488
|
+
*/
|
|
489
|
+
function validateGateSkipWhen(value, label) {
|
|
490
|
+
if (value === undefined) return undefined;
|
|
491
|
+
assertObject(value, label);
|
|
492
|
+
rejectUnknown(value, new Set(["verificationGreen", "maxChangedPaths"]), label);
|
|
493
|
+
if (value.verificationGreen !== true) throw new TypeError(`${label}.verificationGreen must be true`);
|
|
494
|
+
return {
|
|
495
|
+
verificationGreen: true,
|
|
496
|
+
maxChangedPaths: nonNegativeInteger(value.maxChangedPaths, `${label}.maxChangedPaths`),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* The contract-level advisory thresholds, in USD and seconds. Absent means no
|
|
502
|
+
* per-node advisory is configured; the values are advisory only and never stop
|
|
503
|
+
* a node.
|
|
504
|
+
*
|
|
505
|
+
* @param {unknown} value
|
|
506
|
+
* @returns {NodeAdvisoryPolicy}
|
|
507
|
+
*/
|
|
508
|
+
function validateNodeAdvisory(value) {
|
|
509
|
+
if (value === undefined) return {};
|
|
510
|
+
assertObject(value, "contract.nodeAdvisory");
|
|
511
|
+
rejectUnknown(value, new Set(["costUsd", "durationSec"]), "contract.nodeAdvisory");
|
|
512
|
+
/** @type {NodeAdvisoryPolicy} */
|
|
513
|
+
const policy = {};
|
|
514
|
+
if (value.costUsd !== undefined) policy.costUsd = nonNegativeNumber(value.costUsd, "contract.nodeAdvisory.costUsd");
|
|
515
|
+
if (value.durationSec !== undefined) policy.durationSec = nonNegativeNumber(value.durationSec, "contract.nodeAdvisory.durationSec");
|
|
516
|
+
return policy;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* @param {unknown} value
|
|
521
|
+
* @param {string} label
|
|
522
|
+
* @returns {"safe"|"reconcile"|"never"}
|
|
523
|
+
*/
|
|
524
|
+
function validateReplayPolicy(value, label) {
|
|
525
|
+
if (value === undefined) return "safe";
|
|
526
|
+
if (typeof value !== "string" || !REPLAY_POLICIES.has(value)) {
|
|
527
|
+
throw new TypeError(`${label}.replayPolicy must be one of safe, reconcile, never`);
|
|
528
|
+
}
|
|
529
|
+
return /** @type {"safe"|"reconcile"|"never"} */ (value);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* The first declared write path that claims a reserved article name, matched
|
|
534
|
+
* on the trailing `references/<name>` segments so the skill's location inside
|
|
535
|
+
* the target repository is not hardcoded here. scopeAcknowledged is checked
|
|
536
|
+
* alongside the write set because an acknowledged path is expected to change.
|
|
537
|
+
*
|
|
538
|
+
* @param {TaskPacket} packet
|
|
539
|
+
* @returns {string|undefined}
|
|
540
|
+
*/
|
|
541
|
+
function reservedArticleClaim(packet) {
|
|
542
|
+
const declared = [
|
|
543
|
+
...(packet.writeFiles ?? []),
|
|
544
|
+
...(packet.writeRoots ?? []),
|
|
545
|
+
...(packet.scopeAcknowledged ?? []),
|
|
546
|
+
];
|
|
547
|
+
return declared.find((path) =>
|
|
548
|
+
RESERVED_ARTICLES.some((article) => path === article || path.endsWith(`/${article}`)),
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* @param {ValidatedNode[]} nodes
|
|
554
|
+
*/
|
|
555
|
+
function assertAcyclic(nodes) {
|
|
556
|
+
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
557
|
+
const visiting = new Set();
|
|
558
|
+
const visited = new Set();
|
|
559
|
+
/** @type {(id: string) => void} */
|
|
560
|
+
const visit = (id) => {
|
|
561
|
+
if (visiting.has(id)) throw new TypeError(`dependency cycle includes ${id}`);
|
|
562
|
+
if (visited.has(id)) return;
|
|
563
|
+
visiting.add(id);
|
|
564
|
+
const node = byId.get(id);
|
|
565
|
+
if (!node) throw new TypeError(`dependency cycle includes ${id}`);
|
|
566
|
+
for (const dependency of node.dependsOn) visit(dependency);
|
|
567
|
+
visiting.delete(id);
|
|
568
|
+
visited.add(id);
|
|
569
|
+
};
|
|
570
|
+
for (const node of nodes) visit(node.id);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Every node reachable from `node` through `dependsOn` (and the dependencies
|
|
575
|
+
* of those, transitively), itself excluded.
|
|
576
|
+
*
|
|
577
|
+
* @param {ValidatedNode} node
|
|
578
|
+
* @param {ValidatedNode[]} nodes
|
|
579
|
+
* @returns {Set<ValidatedNode>}
|
|
580
|
+
*/
|
|
581
|
+
function transitiveDependencyClosure(node, nodes) {
|
|
582
|
+
const byId = new Map(nodes.map((candidate) => [candidate.id, candidate]));
|
|
583
|
+
const closure = /** @type {Set<ValidatedNode>} */ (new Set());
|
|
584
|
+
/** @param {string} id */
|
|
585
|
+
const visit = (id) => {
|
|
586
|
+
const dependency = byId.get(id);
|
|
587
|
+
if (!dependency || closure.has(dependency)) return;
|
|
588
|
+
closure.add(dependency);
|
|
589
|
+
for (const next of dependency.dependsOn) visit(next);
|
|
590
|
+
};
|
|
591
|
+
for (const id of node.dependsOn) visit(id);
|
|
592
|
+
return closure;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Whether a transitive dependency produces the deferred read: it declares the
|
|
597
|
+
* identical path in `writeFiles`, or the path sits under a directory-shaped
|
|
598
|
+
* `writeRoots` entry. A `writeRoots` entry that names an existing regular file
|
|
599
|
+
* authorizes exactly that path and nothing beneath it, mirroring the
|
|
600
|
+
* file-root/directory-root rule workspace.mjs's scope comparison applies.
|
|
601
|
+
*
|
|
602
|
+
* @param {Set<ValidatedNode>} closure
|
|
603
|
+
* @param {string} path
|
|
604
|
+
* @param {string} cwd
|
|
605
|
+
* @returns {boolean}
|
|
606
|
+
*/
|
|
607
|
+
function dependencyCoversRead(closure, path, cwd) {
|
|
608
|
+
for (const dependency of closure) {
|
|
609
|
+
const packet = dependency.taskPacket;
|
|
610
|
+
if ((packet.writeFiles ?? []).includes(path)) return true;
|
|
611
|
+
for (const root of packet.writeRoots ?? []) {
|
|
612
|
+
if (path === root) return true;
|
|
613
|
+
if (isRegularFileRoot(root, cwd)) continue;
|
|
614
|
+
if (path.startsWith(`${root}/`)) return true;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* @param {string} root
|
|
622
|
+
* @param {string} cwd
|
|
623
|
+
* @returns {boolean}
|
|
624
|
+
*/
|
|
625
|
+
function isRegularFileRoot(root, cwd) {
|
|
626
|
+
try {
|
|
627
|
+
return statSync(resolve(cwd, root)).isFile();
|
|
628
|
+
} catch {
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* @param {unknown} value
|
|
635
|
+
* @returns {number}
|
|
636
|
+
*/
|
|
637
|
+
function validateMaxParallel(value) {
|
|
638
|
+
// Filesystem isolation (attempt worktrees) exists now, so nothing caps this
|
|
639
|
+
// beyond being a sane positive integer.
|
|
640
|
+
return positiveInteger(value, "contract.maxParallel");
|
|
641
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The judge verdict envelope: the limits `parseJudge` enforces and the reasons
|
|
3
|
+
* it rejects by.
|
|
4
|
+
*
|
|
5
|
+
* They live apart from the parser because the judge prompt must render the same
|
|
6
|
+
* numbers. A judge that is never told the limit can only be discarded by it: a
|
|
7
|
+
* thorough arbitration that overshoots the envelope is rejected unread, and the
|
|
8
|
+
* bounded re-ask repeats the defect, because nothing in the prompt said what to
|
|
9
|
+
* shorten. One source keeps the advertised envelope and the enforced one
|
|
10
|
+
* unable to drift.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** The verdict envelope, in the units `parseJudge` measures: bytes, and a count. */
|
|
14
|
+
export const JUDGE_LIMITS = {
|
|
15
|
+
summaryBytes: 4 * 1024,
|
|
16
|
+
findings: 32,
|
|
17
|
+
descriptionBytes: 2 * 1024,
|
|
18
|
+
evidenceBytes: 4 * 1024,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** The reason `parseJudge` throws when the verdict envelope itself overshoots. */
|
|
22
|
+
export const JUDGE_ENVELOPE_REASON = "judge result exceeds limits";
|
|
23
|
+
|
|
24
|
+
/** The reason `parseJudge` throws when a single finding overshoots the envelope. */
|
|
25
|
+
export const JUDGE_FINDING_ENVELOPE_REASON = "judge finding exceeds limits";
|