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.
Files changed (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/bin/faberun.mjs +25 -0
  4. package/integrations/claude-code/statusline-bench.sh +42 -0
  5. package/integrations/claude-code/statusline.sh +80 -0
  6. package/package.json +33 -0
  7. package/skills/faberun/SKILL.md +24 -0
  8. package/skills/faberun/references/contract.md +380 -0
  9. package/skills/faberun/references/engineering.md +29 -0
  10. package/skills/faberun/references/handoffs.md +26 -0
  11. package/skills/faberun/references/operations.md +184 -0
  12. package/skills/faberun/references/rules.md +35 -0
  13. package/skills/faberun/references/workflow.md +23 -0
  14. package/skills/init-agentkit/SKILL.md +108 -0
  15. package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
  16. package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
  17. package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
  18. package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
  19. package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
  20. package/skills/init-agentkit/templates/AGENTS.md +110 -0
  21. package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
  22. package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
  23. package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
  24. package/skills/init-agentkit/templates/docs/VISION.md +33 -0
  25. package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
  26. package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
  27. package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
  28. package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
  29. package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
  30. package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
  31. package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
  32. package/src/campaign/brief.mjs +394 -0
  33. package/src/campaign/chain.mjs +555 -0
  34. package/src/campaign/handoff.mjs +516 -0
  35. package/src/campaign/index.mjs +300 -0
  36. package/src/campaign/journal.mjs +347 -0
  37. package/src/campaign/layout.mjs +51 -0
  38. package/src/campaign/metrics-evals.mjs +25 -0
  39. package/src/campaign/metrics.mjs +517 -0
  40. package/src/campaign/projection.mjs +250 -0
  41. package/src/campaign/record.mjs +102 -0
  42. package/src/campaign/unpark.mjs +56 -0
  43. package/src/cli/brand.mjs +205 -0
  44. package/src/cli/campaign.mjs +730 -0
  45. package/src/cli/contract.mjs +67 -0
  46. package/src/cli/init.mjs +170 -0
  47. package/src/cli/launch.mjs +239 -0
  48. package/src/cli/seat.mjs +139 -0
  49. package/src/cli/setup.mjs +294 -0
  50. package/src/cli/skills.mjs +105 -0
  51. package/src/cli/update.mjs +216 -0
  52. package/src/cli.mjs +525 -0
  53. package/src/contract/articles.mjs +12 -0
  54. package/src/contract/assert.mjs +162 -0
  55. package/src/contract/definition-of-done.mjs +97 -0
  56. package/src/contract/final-verification.mjs +96 -0
  57. package/src/contract/index.mjs +641 -0
  58. package/src/contract/judge-envelope.mjs +25 -0
  59. package/src/contract/review-modes.mjs +151 -0
  60. package/src/contract/runtime.mjs +204 -0
  61. package/src/contract/schema-version.mjs +25 -0
  62. package/src/contract/scope-findings.mjs +77 -0
  63. package/src/contract/snapshot.mjs +639 -0
  64. package/src/contract/task-packet.mjs +495 -0
  65. package/src/contract/untrusted.mjs +75 -0
  66. package/src/contract/verification.mjs +185 -0
  67. package/src/contract/worker-result.mjs +138 -0
  68. package/src/engine/assignment.mjs +63 -0
  69. package/src/engine/backoff.mjs +492 -0
  70. package/src/engine/bulk-read.mjs +361 -0
  71. package/src/engine/cancel.mjs +177 -0
  72. package/src/engine/detach.mjs +101 -0
  73. package/src/engine/dispatch.mjs +752 -0
  74. package/src/engine/failover.mjs +192 -0
  75. package/src/engine/gate.mjs +183 -0
  76. package/src/engine/judge-gate.mjs +517 -0
  77. package/src/engine/lifecycle.mjs +772 -0
  78. package/src/engine/live-preflight.mjs +299 -0
  79. package/src/engine/mutation.mjs +146 -0
  80. package/src/engine/notify-queue.mjs +327 -0
  81. package/src/engine/process-identity.mjs +72 -0
  82. package/src/engine/process.mjs +774 -0
  83. package/src/engine/prompts.mjs +289 -0
  84. package/src/engine/recover.mjs +300 -0
  85. package/src/engine/result-file.mjs +222 -0
  86. package/src/engine/resume.mjs +635 -0
  87. package/src/engine/retry.mjs +334 -0
  88. package/src/engine/review.mjs +228 -0
  89. package/src/engine/run-command.mjs +287 -0
  90. package/src/engine/run-identity.mjs +411 -0
  91. package/src/engine/runtime-discovery.mjs +235 -0
  92. package/src/engine/scheduler.mjs +526 -0
  93. package/src/engine/scope.mjs +378 -0
  94. package/src/engine/settle.mjs +207 -0
  95. package/src/engine/state.mjs +148 -0
  96. package/src/engine/supervise.mjs +713 -0
  97. package/src/engine/verify.mjs +167 -0
  98. package/src/harnesses/agy/index.mjs +62 -0
  99. package/src/harnesses/catalogue.mjs +509 -0
  100. package/src/harnesses/claude/index.mjs +90 -0
  101. package/src/harnesses/codex/index.mjs +87 -0
  102. package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
  103. package/src/harnesses/dsh/index.mjs +210 -0
  104. package/src/harnesses/dsh/runner.mjs +259 -0
  105. package/src/harnesses/exec-jsonl/index.mjs +788 -0
  106. package/src/harnesses/index.mjs +508 -0
  107. package/src/harnesses/protocol.mjs +531 -0
  108. package/src/harnesses/replay/bin.mjs +386 -0
  109. package/src/harnesses/replay/index.mjs +238 -0
  110. package/src/harnesses/zcode/index.mjs +276 -0
  111. package/src/host/config.mjs +87 -0
  112. package/src/host/home.mjs +149 -0
  113. package/src/host/package.mjs +23 -0
  114. package/src/host/preflight.mjs +520 -0
  115. package/src/host/tool-policy-decisions.mjs +341 -0
  116. package/src/host/tool-policy-hook.mjs +270 -0
  117. package/src/notify/index.mjs +359 -0
  118. package/src/notify/os-macos.mjs +81 -0
  119. package/src/repo/declared-paths.mjs +220 -0
  120. package/src/repo/integrate.mjs +546 -0
  121. package/src/repo/scope-closure.mjs +665 -0
  122. package/src/repo/signal-block.mjs +16 -0
  123. package/src/repo/signal.mjs +222 -0
  124. package/src/repo/source-identity.mjs +295 -0
  125. package/src/repo/workspace.mjs +557 -0
  126. package/src/repo/worktree.mjs +352 -0
  127. package/src/report/final.mjs +200 -0
  128. package/src/report/metrics-report.mjs +99 -0
  129. package/src/report/next.mjs +383 -0
  130. package/src/report/render.mjs +716 -0
  131. package/src/run/disk-gc.mjs +251 -0
  132. package/src/run/lock.mjs +329 -0
  133. package/src/run/node-store.mjs +62 -0
  134. package/src/run/operations.mjs +286 -0
  135. package/src/run/store.mjs +187 -0
  136. package/src/run/usage.mjs +337 -0
  137. package/src/seat/harnesses.mjs +83 -0
  138. package/src/seat/index.mjs +239 -0
  139. package/src/seat/tmux.mjs +208 -0
  140. package/src/util.mjs +0 -0
  141. package/src/web/api.mjs +371 -0
  142. package/src/web/boundary.mjs +88 -0
  143. package/src/web/index.html +299 -0
  144. 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";