faberun 0.3.0 → 0.7.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 (51) hide show
  1. package/README.md +152 -100
  2. package/package.json +10 -2
  3. package/skills/faberun/SKILL.md +6 -5
  4. package/skills/faberun/references/contract.md +23 -11
  5. package/skills/faberun/references/engineering.md +3 -1
  6. package/skills/faberun/references/operations.md +19 -12
  7. package/skills/faberun/references/rules.md +3 -1
  8. package/src/campaign/chain.mjs +6 -2
  9. package/src/campaign/index.mjs +17 -1
  10. package/src/campaign/metrics.mjs +3 -3
  11. package/src/cli/brand.mjs +2 -1
  12. package/src/cli/campaign.mjs +2 -0
  13. package/src/cli/contract.mjs +2 -0
  14. package/src/cli/manual.mjs +341 -0
  15. package/src/cli/seat.mjs +2 -0
  16. package/src/cli/setup.mjs +109 -30
  17. package/src/cli/skills.mjs +310 -8
  18. package/src/cli.mjs +3 -2
  19. package/src/contract/final-verification.mjs +31 -2
  20. package/src/contract/index.mjs +28 -25
  21. package/src/contract/runtime.mjs +5 -1
  22. package/src/contract/snapshot.mjs +7 -1
  23. package/src/contract/task-packet.mjs +20 -9
  24. package/src/contract/verification.mjs +1 -1
  25. package/src/engine/backoff.mjs +1 -1
  26. package/src/engine/dispatch.mjs +31 -4
  27. package/src/engine/gate.mjs +12 -0
  28. package/src/engine/process-identity.mjs +39 -0
  29. package/src/engine/prompts.mjs +18 -0
  30. package/src/engine/resume.mjs +2 -2
  31. package/src/engine/review.mjs +9 -1
  32. package/src/engine/run-command.mjs +23 -2
  33. package/src/engine/run-identity.mjs +14 -0
  34. package/src/engine/scheduler.mjs +45 -12
  35. package/src/engine/settle.mjs +29 -0
  36. package/src/engine/supervise.mjs +32 -6
  37. package/src/engine/verify.mjs +98 -9
  38. package/src/harnesses/agy/index.mjs +3 -0
  39. package/src/harnesses/claude/index.mjs +5 -0
  40. package/src/harnesses/codex/index.mjs +3 -0
  41. package/src/harnesses/dsh/index.mjs +26 -0
  42. package/src/harnesses/exec-jsonl/index.mjs +2 -0
  43. package/src/harnesses/index.mjs +10 -3
  44. package/src/harnesses/replay/index.mjs +2 -0
  45. package/src/harnesses/zcode/index.mjs +3 -0
  46. package/src/host/preflight.mjs +5 -1
  47. package/src/notify/index.mjs +45 -2
  48. package/src/repo/source-identity.mjs +4 -3
  49. package/src/report/final.mjs +3 -2
  50. package/src/report/render.mjs +134 -51
  51. package/src/web/index.html +1 -1
@@ -1,18 +1,38 @@
1
1
  /**
2
- * `skills` argv: list, install.
2
+ * `skills` argv: list, install, register.
3
3
  *
4
4
  * Per-operation options only, so `--force` is rejected by `list`. The
5
- * behavior lives in `installSkills`, exported because `faberun init` installs
6
- * the same catalogue into a target repository; this file owns the wire, the
7
- * same split `seat.mjs` uses.
5
+ * behavior lives in `installSkills` and `registerSkills`, exported because
6
+ * `faberun init` installs the same catalogue into a target repository and
7
+ * `faberun setup` registers the skill into every installed harness; this file
8
+ * owns the wire, the same split `seat.mjs` uses.
9
+ *
10
+ * `install` copies the catalogue into a caller-chosen `.claude/skills`.
11
+ * `register` discovers each installed harness's own skills directory and links
12
+ * (or, with `--copy`, copies) the `faberun` skill into it. Discovery is a
13
+ * measured table below, not a probe of live harness state.
8
14
  */
9
- import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
15
+ import {
16
+ cpSync,
17
+ existsSync,
18
+ lstatSync,
19
+ mkdirSync,
20
+ readdirSync,
21
+ realpathSync,
22
+ rmSync,
23
+ symlinkSync,
24
+ } from "node:fs";
10
25
  import { homedir } from "node:os";
11
- import { join, resolve } from "node:path";
26
+ import { join, resolve, sep } from "node:path";
12
27
  import { fileURLToPath } from "node:url";
13
28
  import { parseArgs as parseFlags } from "node:util";
14
29
 
30
+ import { faberunHome, installedVersionDir } from "../host/home.mjs";
31
+ import { findExecutable } from "../host/preflight.mjs";
32
+ import { colorLevel, statusToken } from "./brand.mjs";
33
+
15
34
  const SKILLS_DIR = fileURLToPath(new URL("../../skills", import.meta.url));
35
+ const CHECKOUT_SKILL = join(SKILLS_DIR, "faberun");
16
36
 
17
37
  /** Flags are scoped to the operation that declares them; all others are rejected. */
18
38
  /** @type {Record<string, import("node:util").ParseArgsOptionsConfig>} */
@@ -23,8 +43,64 @@ const OPERATION_OPTIONS = {
23
43
  global: { type: "boolean" },
24
44
  force: { type: "boolean" },
25
45
  },
46
+ register: {
47
+ harness: { type: "string" },
48
+ copy: { type: "boolean" },
49
+ force: { type: "boolean" },
50
+ json: { type: "boolean" },
51
+ },
26
52
  };
27
53
 
54
+ /**
55
+ * Where each operator harness keeps the skills a user can add, measured
56
+ * 2026-09-16 on the machine this node ran on.
57
+ *
58
+ * - `claude` -> `~/.claude/skills` and `codex` -> `~/.codex/skills` are the
59
+ * documented conventions; on this machine both directories hold symlinks into
60
+ * `~/.agents/skills`.
61
+ * - `agents` -> `~/.agents/skills` is the shared convention. It is registered
62
+ * on its own too, because a harness directory is often a symlink to it, and
63
+ * it is optional so a machine without it stays quiet.
64
+ * - `zcode` 0.16.5: `zcode skills list` labels every local, non-plugin entry
65
+ * `(user/agents)` and resolves it under `~/.agents/skills`; `~/.zcode` holds
66
+ * only plugin and CLI state, so its user skill convention is the shared one.
67
+ * - `agy` (Antigravity CLI): the installed binary's embedded guide names
68
+ * `~/.gemini/config/` as the global customization root and
69
+ * `~/.gemini/config/skills/<name>/` as a global skill; the
70
+ * `~/.gemini/antigravity-cli/builtin/skills` tree is shipped, read-only.
71
+ * - `dsh`: `dsh --help` lists no skills command and no skills directory exists,
72
+ * so it has no registration target. It is kept in the table to record the
73
+ * measurement, and reported as `no skill support`.
74
+ *
75
+ * @type {Record<string, {binary: string|null, optional: boolean, dir: ((home: string) => string)|null}>}
76
+ */
77
+ const HARNESS_SKILL_DIRS = {
78
+ claude: { binary: "claude", optional: false, dir: (home) => join(home, ".claude", "skills") },
79
+ codex: { binary: "codex", optional: false, dir: (home) => join(home, ".codex", "skills") },
80
+ zcode: { binary: "zcode", optional: false, dir: (home) => join(home, ".agents", "skills") },
81
+ agy: { binary: "agy", optional: false, dir: (home) => join(home, ".gemini", "config", "skills") },
82
+ dsh: { binary: "dsh", optional: false, dir: null },
83
+ agents: { binary: null, optional: true, dir: (home) => join(home, ".agents", "skills") },
84
+ };
85
+
86
+ /** @typedef {(text: string) => void} Writer */
87
+ /**
88
+ * @typedef {object} SkillTarget
89
+ * @property {string} harness
90
+ * @property {string|null} dir
91
+ * @property {string|null} binary
92
+ * @property {boolean} installed
93
+ * @property {boolean} dirExists
94
+ * @property {boolean} unsupported
95
+ * @property {boolean} optional
96
+ */
97
+ /**
98
+ * @typedef {object} SkillRegistration
99
+ * @property {string} harness
100
+ * @property {string|null} dir
101
+ * @property {"linked"|"copied"|"unchanged"|"skipped"|"no_dir"|"not_installed"|"unsupported"} action
102
+ */
103
+
28
104
  /**
29
105
  * @param {string[]} args
30
106
  * @returns {void}
@@ -39,12 +115,30 @@ export function skillsCli(args) {
39
115
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
40
116
  return usage();
41
117
  }
42
- const values = /** @type {{target?: string, global?: boolean, force?: boolean}} */ (parsed.values);
118
+ const values = /** @type {{target?: string, global?: boolean, force?: boolean, copy?: boolean, json?: boolean, harness?: string}} */ (parsed.values);
43
119
  if (operation === "list") {
44
120
  if (parsed.positionals.length) return usage();
45
121
  for (const name of catalog()) process.stdout.write(`${name}\n`);
46
122
  return;
47
123
  }
124
+ if (operation === "register") {
125
+ if (parsed.positionals.length) return usage();
126
+ try {
127
+ const results = registerSkills({
128
+ harnesses: splitHarnesses(values.harness),
129
+ copy: values.copy === true,
130
+ force: values.force === true,
131
+ env: process.env,
132
+ level: values.json === true ? 0 : colorLevel(process.env, process.stdout.isTTY),
133
+ stdout: values.json === true ? () => {} : (text) => process.stdout.write(text),
134
+ });
135
+ if (values.json === true) process.stdout.write(`${JSON.stringify(results, null, 2)}\n`);
136
+ } catch (error) {
137
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
138
+ return usage();
139
+ }
140
+ return;
141
+ }
48
142
  const skillsDir = values.global
49
143
  ? join(homedir(), ".claude", "skills")
50
144
  : join(values.target ? resolve(values.target) : process.cwd(), ".claude", "skills");
@@ -88,6 +182,212 @@ export function installSkills({ names, skillsDir, force, stdout }) {
88
182
  return { installed, skipped };
89
183
  }
90
184
 
185
+ /**
186
+ * The harness skills directories that exist on this machine, in table order,
187
+ * deduplicated by directory. `zcode` and the shared `agents` entry name the
188
+ * same `~/.agents/skills`; whichever is installed first claims it. A filter of
189
+ * harness names limits the result and rejects a name that is not in the table.
190
+ *
191
+ * @param {{env?: NodeJS.ProcessEnv, harnesses?: string[], isInstalled?: (name: string) => boolean}} [options]
192
+ * @returns {SkillTarget[]}
193
+ */
194
+ export function discoverSkillTargets(options = {}) {
195
+ const env = options.env ?? process.env;
196
+ const home = skillHome(env);
197
+ const requested = options.harnesses && options.harnesses.length ? options.harnesses : null;
198
+ if (requested) {
199
+ for (const name of requested) {
200
+ if (!Object.hasOwn(HARNESS_SKILL_DIRS, name)) {
201
+ throw new Error(`no harness named "${name}" (choose from ${Object.keys(HARNESS_SKILL_DIRS).join(", ")})`);
202
+ }
203
+ }
204
+ }
205
+ const filter = requested ? new Set(requested) : null;
206
+ const isInstalled = options.isInstalled ?? ((name) => findExecutable(name) !== null);
207
+ /** @type {SkillTarget[]} */
208
+ const targets = [];
209
+ const claimed = new Set();
210
+ for (const [harness, entry] of Object.entries(HARNESS_SKILL_DIRS)) {
211
+ if (filter && !filter.has(harness)) continue;
212
+ const dir = entry.dir ? entry.dir(home) : null;
213
+ const installed = entry.binary ? isInstalled(entry.binary) : true;
214
+ if (dir && claimed.has(dir)) continue;
215
+ if (installed && dir && !entry.optional) claimed.add(dir);
216
+ targets.push({
217
+ harness,
218
+ dir,
219
+ binary: entry.binary,
220
+ installed,
221
+ dirExists: dir !== null && existsSync(dir),
222
+ unsupported: dir === null,
223
+ optional: entry.optional,
224
+ });
225
+ }
226
+ return targets;
227
+ }
228
+
229
+ /**
230
+ * Link or copy the `faberun` skill into every installed harness's skills
231
+ * directory. A harness whose binary is absent is reported and skipped; one
232
+ * whose directory is missing is a `[warn]` unless `force` creates it. A real
233
+ * directory already at the destination is left alone unless `force`. The
234
+ * caller owns the wire and the streams; `--json` passes a no-op writer.
235
+ *
236
+ * @param {{harnesses?: string[], copy?: boolean, force?: boolean, env?: NodeJS.ProcessEnv, level?: number, stdout?: Writer, isInstalled?: (name: string) => boolean}} [options]
237
+ * @returns {SkillRegistration[]}
238
+ */
239
+ export function registerSkills(options = {}) {
240
+ const env = options.env ?? process.env;
241
+ const home = skillHome(env);
242
+ const stdout = options.stdout ?? ((text) => process.stdout.write(text));
243
+ const level = options.level ?? 0;
244
+ const force = options.force === true;
245
+ const source = skillSource(env);
246
+ const targets = discoverSkillTargets({ env, harnesses: options.harnesses, isInstalled: options.isInstalled });
247
+ /** @type {SkillRegistration[]} */
248
+ const results = [];
249
+ for (const target of targets) {
250
+ if (target.unsupported || target.dir === null) {
251
+ results.push({ harness: target.harness, dir: null, action: "unsupported" });
252
+ stdout(`${statusToken("ok", level)} ${target.harness} · no skill support\n`);
253
+ continue;
254
+ }
255
+ const dir = target.dir;
256
+ const destination = join(dir, "faberun");
257
+ if (!target.installed) {
258
+ results.push({ harness: target.harness, dir: destination, action: "not_installed" });
259
+ stdout(`${statusToken("ok", level)} ${target.harness} · not installed\n`);
260
+ continue;
261
+ }
262
+ if (!target.dirExists && !force) {
263
+ if (target.optional) continue;
264
+ results.push({ harness: target.harness, dir: destination, action: "no_dir" });
265
+ stdout(`${statusToken("warn", level)} ${target.harness} · no skills directory (${displayPath(dir, home)})\n`);
266
+ continue;
267
+ }
268
+ mkdirSync(dir, { recursive: true });
269
+ const action = options.copy === true ? copySkill(source, destination, force) : linkSkill(source, destination, force);
270
+ results.push({ harness: target.harness, dir: destination, action });
271
+ const path = displayPath(destination, home);
272
+ if (action === "skipped") {
273
+ stdout(`${statusToken("warn", level)} ${target.harness} · ${path} · exists, use --force\n`);
274
+ } else {
275
+ stdout(`${statusToken("ok", level)} ${target.harness} · ${path} · ${action}\n`);
276
+ }
277
+ }
278
+ return results;
279
+ }
280
+
281
+ /**
282
+ * The skill tree a registration points at: `$FABERUN_HOME/current/skills/faberun`
283
+ * when this CLI runs from the installed home layout, so the link follows every
284
+ * update, and this checkout's `skills/faberun` otherwise.
285
+ *
286
+ * @param {NodeJS.ProcessEnv} env
287
+ * @returns {string}
288
+ */
289
+ function skillSource(env) {
290
+ const home = faberunHome(env);
291
+ return installedVersionDir(process.argv[1], home) ? join(home, "current", "skills", "faberun") : CHECKOUT_SKILL;
292
+ }
293
+
294
+ /**
295
+ * @param {string} source
296
+ * @param {string} destination
297
+ * @param {boolean} force
298
+ * @returns {"linked"|"unchanged"|"skipped"}
299
+ */
300
+ function linkSkill(source, destination, force) {
301
+ const existing = lstatOrNull(destination);
302
+ if (existing) {
303
+ if (existing.isSymbolicLink() && !force && resolvesTo(destination, source)) return "unchanged";
304
+ if (!existing.isSymbolicLink() && !force) return "skipped";
305
+ rmSync(destination, { recursive: true, force: true });
306
+ }
307
+ symlinkSync(source, destination);
308
+ return "linked";
309
+ }
310
+
311
+ /**
312
+ * @param {string} source
313
+ * @param {string} destination
314
+ * @param {boolean} force
315
+ * @returns {"copied"|"unchanged"|"skipped"}
316
+ */
317
+ function copySkill(source, destination, force) {
318
+ const existing = lstatOrNull(destination);
319
+ if (existing) {
320
+ if (existing.isDirectory() && !force) return "unchanged";
321
+ if (!force && !existing.isSymbolicLink()) return "skipped";
322
+ rmSync(destination, { recursive: true, force: true });
323
+ }
324
+ cpSync(source, destination, { recursive: true });
325
+ return "copied";
326
+ }
327
+
328
+ /**
329
+ * @param {string} path
330
+ * @returns {import("node:fs").Stats|null}
331
+ */
332
+ function lstatOrNull(path) {
333
+ try {
334
+ return lstatSync(path);
335
+ } catch {
336
+ // A path that does not exist has no stats; the caller treats that as "free".
337
+ return null;
338
+ }
339
+ }
340
+
341
+ /**
342
+ * @param {string} destination
343
+ * @param {string} source
344
+ * @returns {boolean}
345
+ */
346
+ function resolvesTo(destination, source) {
347
+ try {
348
+ return realpathSync(destination) === realpathSync(source);
349
+ } catch {
350
+ // Either path may be a dangling symlink; it cannot already point at source.
351
+ return false;
352
+ }
353
+ }
354
+
355
+ /**
356
+ * The home directory the skill directories hang off. `HOME` is honoured so a
357
+ * test or an install can point the whole discovery at a temporary tree.
358
+ *
359
+ * @param {NodeJS.ProcessEnv} env
360
+ * @returns {string}
361
+ */
362
+ function skillHome(env) {
363
+ const configured = env.HOME;
364
+ if (typeof configured === "string" && configured) return configured;
365
+ return homedir();
366
+ }
367
+
368
+ /**
369
+ * Render a path under the home directory as `~/…` for the human lines; the
370
+ * machine-readable `dir` stays absolute.
371
+ *
372
+ * @param {string} path
373
+ * @param {string} home
374
+ * @returns {string}
375
+ */
376
+ function displayPath(path, home) {
377
+ const prefix = home.endsWith(sep) ? home : `${home}${sep}`;
378
+ if (path === home) return "~";
379
+ return path.startsWith(prefix) ? `~${sep}${path.slice(prefix.length)}` : path;
380
+ }
381
+
382
+ /**
383
+ * @param {string|undefined} text
384
+ * @returns {string[]}
385
+ */
386
+ function splitHarnesses(text) {
387
+ if (typeof text !== "string" || !text) return [];
388
+ return [...new Set(text.split(",").map((name) => name.trim()).filter(Boolean))];
389
+ }
390
+
91
391
  /**
92
392
  * @returns {string[]} catalogue entries that carry a SKILL.md
93
393
  */
@@ -100,6 +400,8 @@ function catalog() {
100
400
 
101
401
  /** @returns {void} */
102
402
  function usage() {
103
- process.stderr.write("usage: faberun skills <list|install> [<name>...] [--target <dir>] [--global] [--force]\n");
403
+ process.stderr.write("usage: faberun skills <list|install|register> [<name>...] [--target <dir>] [--global] [--force] [--copy] [--harness <a,b>] [--json]\n");
104
404
  process.exitCode = 2;
105
405
  }
406
+
407
+ export default OPERATION_OPTIONS;
package/src/cli.mjs CHANGED
@@ -87,7 +87,7 @@ export function hasDetachedBootstrapNonce() {
87
87
  }
88
88
 
89
89
  /** @type {Record<string, import("node:util").ParseArgsOptionsConfig>} */
90
- const COMMAND_OPTIONS = {
90
+ export const COMMAND_OPTIONS = {
91
91
  run: { detach: { type: "boolean" }, "base-ref": { type: "string" } },
92
92
  resume: { detach: { type: "boolean" }, node: { type: "string" }, reconcile: { type: "string" }, answer: { type: "string" } },
93
93
  supervise: { detach: { type: "boolean" }, interval: { type: "string" } },
@@ -102,7 +102,7 @@ const COMMAND_OPTIONS = {
102
102
  "bulk-read": { question: { type: "string" }, paths: { type: "string", multiple: true }, json: { type: "boolean" } },
103
103
  next: { cwd: { type: "string" }, json: { type: "boolean" } },
104
104
  update: { check: { type: "boolean" }, json: { type: "boolean" } },
105
- setup: { yes: { type: "boolean" }, harnesses: { type: "string" }, worker: { type: "string" }, judge: { type: "string" }, json: { type: "boolean" } },
105
+ setup: { yes: { type: "boolean" }, harnesses: { type: "string" }, worker: { type: "string" }, judge: { type: "string" }, "no-skill": { type: "boolean" }, json: { type: "boolean" } },
106
106
  init: { cwd: { type: "string" }, yes: { type: "boolean" }, "no-skill": { type: "boolean" }, agentkit: { type: "boolean" }, greenfield: { type: "boolean" }, stable: { type: "boolean" }, json: { type: "boolean" } },
107
107
  metrics: METRICS_OPTIONS,
108
108
  };
@@ -251,6 +251,7 @@ async function main(argv) {
251
251
  harnesses: typeof values.harnesses === "string" ? values.harnesses : undefined,
252
252
  worker: typeof values.worker === "string" ? values.worker : undefined,
253
253
  judge: typeof values.judge === "string" ? values.judge : undefined,
254
+ skill: values["no-skill"] !== true,
254
255
  json: values.json === true,
255
256
  env: process.env,
256
257
  isTTY: Boolean(process.stdin.isTTY && process.stdout.isTTY),
@@ -5,8 +5,11 @@
5
5
  * `contract.finalVerification` is the contract-wide proof that the phase as a
6
6
  * whole closes: the controller runs it before the judge on the phase-terminal
7
7
  * node (the node no other node depends on), so no final checkpoint is ever
8
- * approved on partial verification. The persisted node-snapshot shape for
9
- * verification evidence lives here too, next to the schema it records.
8
+ * approved on partial verification. `contract.sharedVerification` carries the
9
+ * same command schema but is appended to every node's attempt and integration
10
+ * candidate, for the fast repository ratchets a node's write set can break.
11
+ * The persisted node-snapshot shape for verification evidence lives here too,
12
+ * next to the schema it records.
10
13
  */
11
14
 
12
15
  import { Buffer } from "node:buffer";
@@ -29,6 +32,32 @@ export function validateFinalVerification(value, label = "contract.finalVerifica
29
32
  return validateVerificationCommands(value, label);
30
33
  }
31
34
 
35
+ /**
36
+ * Validate the optional contract-level `sharedVerification` field. It carries
37
+ * the identical verification-command schema as `finalVerification`; only the
38
+ * audience differs.
39
+ *
40
+ * @param {unknown} value
41
+ * @param {string} label
42
+ * @returns {VerificationCommand[]|undefined}
43
+ */
44
+ export function validateSharedVerification(value, label = "contract.sharedVerification") {
45
+ if (value === undefined) return undefined;
46
+ return validateVerificationCommands(value, label);
47
+ }
48
+
49
+ /**
50
+ * The contract's `sharedVerification` commands, which every node's verification
51
+ * carries on both its attempt and its integration candidate. Absent means none,
52
+ * so a contract that declares nothing is unchanged.
53
+ *
54
+ * @param {{sharedVerification?: VerificationCommand[]}} contract
55
+ * @returns {VerificationCommand[]}
56
+ */
57
+ export function sharedVerificationCommands(contract) {
58
+ return contract.sharedVerification ?? [];
59
+ }
60
+
32
61
  /**
33
62
  * The contract's `finalVerification` commands when this node is the one that
34
63
  * closes the phase, otherwise none. A node is phase-terminal when no other
@@ -4,7 +4,7 @@ import { dirname, resolve } from "node:path";
4
4
  import { loadTaskPacket, renderWorkerPrompt } from "./task-packet.mjs";
5
5
  import { RESERVED_ARTICLES } from "./articles.mjs";
6
6
  import { validateDefinitionOfDone } from "./definition-of-done.mjs";
7
- import { validateFinalVerification } from "./final-verification.mjs";
7
+ import { validateFinalVerification, validateSharedVerification } from "./final-verification.mjs";
8
8
  import { VERIFICATION_LIMITS } from "./verification.mjs";
9
9
  import {
10
10
  validateCapabilityRequirements,
@@ -23,7 +23,7 @@ export { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION } from "../harnesses/index.mj
23
23
  const CONTRACT_FIELDS = new Set([
24
24
  "schemaVersion", "contractVersion", "id", "campaignId", "goal", "cwd", "sourceIdentity",
25
25
  "maxParallel", "pollIntervalMs", "stallTimeoutSec", "timeoutSec",
26
- "runtimeDefaults", "runtimes", "nodes", "warnings", "finalVerification", "nodeAdvisory",
26
+ "runtimeDefaults", "runtimes", "nodes", "warnings", "finalVerification", "sharedVerification", "nodeAdvisory",
27
27
  ]);
28
28
  const DEFAULTS_FIELDS = new Set(["worker", "judge"]);
29
29
  const NODE_FIELDS = new Set([
@@ -39,7 +39,7 @@ const GATE_REVIEWS = new Set(["none", "advisory", "blocking"]);
39
39
 
40
40
  /** @typedef {{structuredOutput?: boolean, promptTransport?: "stdin"|"argv", sandbox?: boolean, permissions?: boolean, continuation?: boolean, tokenBudget?: boolean, costBudget?: boolean, usage?: boolean, cost?: boolean}} CapabilityRequirements */
41
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 */
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>, baseRef?: string|null}} SourceIdentity */
43
43
 
44
44
  /** @typedef {{argv: string[], cwd?: string, timeoutSec?: number, repeat?: number, env?: string[]}} VerificationCommand */
45
45
 
@@ -51,7 +51,7 @@ const GATE_REVIEWS = new Set(["none", "advisory", "blocking"]);
51
51
 
52
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
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 */
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[], sharedVerification?: VerificationCommand[], nodeAdvisory?: NodeAdvisoryPolicy, warnings: string[]}} ValidatedContract */
55
55
  /** @typedef {{costUsd?: number, durationSec?: number}} NodeAdvisoryPolicy */
56
56
 
57
57
  /** @typedef {"pending"|"running"|"done"|"no-op"|"blocked"|"failed"|"exhausted"|"stalled"|"canceled"} NodeStatus */
@@ -79,7 +79,7 @@ const GATE_REVIEWS = new Set(["none", "advisory", "blocking"]);
79
79
  /** @typedef {{history: RoutingHistoryEntry[], currentOverride: RoutingOverride|null, assignments?: RuntimeAssignments, availability?: Record<string, RuntimeAvailability>, tierExhaustion?: TierExhaustion, tierExhaustionCycle?: number}} RoutingState */
80
80
  /** @typedef {{revision?: number, heartbeatCount: number, dryHeartbeatCount: number, progressSignature?: string|null, lastHeartbeatAt: string|null, lastProgressAt: string|null, nextCheckAt?: string|null}} ProgressState */
81
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 */
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, declaredReadBytes?: number|null}} NodeSnapshot */
83
83
  /** @typedef {{path: string, sha: string}} ControllerIdentity */
84
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
85
  /** @typedef {{at: string, base: string|null, dirtyTreeFingerprint: string|null}} ScopeDecision */
@@ -147,7 +147,7 @@ export function validateContract(raw, contractPath, options = {}) {
147
147
  }
148
148
  const rawNodes = /** @type {JsonObject[]} */ (raw.nodes);
149
149
  const ids = new Set();
150
- /** @type {{path: string, label: string}[][]} */
150
+ /** @type {{path: string, label: string, kind: "read"|"acknowledged"}[][]} */
151
151
  const deferredReadsByNode = [];
152
152
  const nodes = rawNodes.map((node, index) => {
153
153
  assertObject(node, `nodes[${index}]`);
@@ -165,12 +165,12 @@ export function validateContract(raw, contractPath, options = {}) {
165
165
  if (!Array.isArray(dependsOn) || dependsOn.some((id) => typeof id !== "string")) {
166
166
  throw new TypeError(`nodes[${index}].dependsOn must be an array of ids`);
167
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}[]} */
168
+ // A readFiles -- or scopeAcknowledged -- entry that names a file no
169
+ // dependency has produced yet is a missing path today, but the graph is not
170
+ // known until every node is loaded. Collect the candidate here; the second
171
+ // pass below resolves each against the node's transitive closure once all
172
+ // packets and dependsOn edges are in hand.
173
+ /** @type {{path: string, label: string, kind: "read"|"acknowledged"}[]} */
174
174
  const deferredReads = [];
175
175
  const taskPacket = loadTaskPacket(node, contractDir, cwd, index, { deferMissingReads: true, deferredReads, persisted });
176
176
  deferredReadsByNode.push(deferredReads);
@@ -234,21 +234,22 @@ export function validateContract(raw, contractPath, options = {}) {
234
234
  }
235
235
  assertAcyclic(nodes);
236
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.
237
+ // Second pass: a readFiles or scopeAcknowledged entry deferred at packet
238
+ // load is accepted only when some transitive dependency produces it --
239
+ // declares the identical path in its writeFiles, or the path sits under a
240
+ // dependency's directory-shaped writeRoots entry (a file-shaped entry
241
+ // authorizes exactly that path). Every other caller of validateTaskPacket
242
+ // keeps rejecting the missing path inline; this graph-aware deferral is a
243
+ // contract-loading capability only. Persisted loads never defer -- they
244
+ // skipped the existence probe, so there is nothing to resolve and nothing to
245
+ // stat.
245
246
  if (!persisted) {
246
247
  for (const [index, node] of nodes.entries()) {
247
248
  const deferredReads = deferredReadsByNode[index];
248
249
  if (deferredReads.length === 0) continue;
249
250
  const closure = transitiveDependencyClosure(node, nodes);
250
251
  for (const { path, label } of deferredReads) {
251
- if (!dependencyCoversRead(closure, path, cwd)) {
252
+ if (!dependencyCoversPath(closure, path, cwd)) {
252
253
  throw new TypeError(`${label} does not exist: ${path}`);
253
254
  }
254
255
  }
@@ -351,13 +352,15 @@ export function validateContract(raw, contractPath, options = {}) {
351
352
  stallTimeoutSec: positiveNumber(raw.stallTimeoutSec ?? 300, "contract.stallTimeoutSec"),
352
353
  timeoutSec: positiveNumber(raw.timeoutSec ?? 2_400, "contract.timeoutSec"),
353
354
  finalVerification: validateFinalVerification(raw.finalVerification, "contract.finalVerification"),
355
+ sharedVerification: validateSharedVerification(raw.sharedVerification, "contract.sharedVerification"),
354
356
  nodeAdvisory: validateNodeAdvisory(raw.nodeAdvisory),
355
357
  warnings,
356
358
  });
357
359
  // The persisted load is a replay, not a re-authoring: it accepts only bytes
358
360
  // 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
+ // runtime selection, timeout, definition of done, finalVerification or
362
+ // sharedVerification leaves every packetHash untouched, so only this digest
363
+ // refuses it.
361
364
  if (persisted && options.contractDigest !== undefined && contractDigest(raw) !== options.contractDigest) {
362
365
  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
366
  }
@@ -593,7 +596,7 @@ function transitiveDependencyClosure(node, nodes) {
593
596
  }
594
597
 
595
598
  /**
596
- * Whether a transitive dependency produces the deferred read: it declares the
599
+ * Whether a transitive dependency produces the deferred path: it declares the
597
600
  * identical path in `writeFiles`, or the path sits under a directory-shaped
598
601
  * `writeRoots` entry. A `writeRoots` entry that names an existing regular file
599
602
  * authorizes exactly that path and nothing beneath it, mirroring the
@@ -604,7 +607,7 @@ function transitiveDependencyClosure(node, nodes) {
604
607
  * @param {string} cwd
605
608
  * @returns {boolean}
606
609
  */
607
- function dependencyCoversRead(closure, path, cwd) {
610
+ function dependencyCoversPath(closure, path, cwd) {
608
611
  for (const dependency of closure) {
609
612
  const packet = dependency.taskPacket;
610
613
  if ((packet.writeFiles ?? []).includes(path)) return true;
@@ -41,7 +41,7 @@ const PRICING_FIELDS = new Set(["inputPerMTok", "cachedInputPerMTok", "outputPer
41
41
  const SNAPSHOT_RUNTIME_FIELDS = new Set(["id", ...RUNTIME_FIELDS, "capabilities"]);
42
42
  const CAPABILITY_FIELDS = new Set([
43
43
  "structuredOutput", "promptTransport", "sandbox", "permissions", "continuation", "tokenBudget", "costBudget",
44
- "usage", "cost", "toolPolicy", "streamsOutput", "maxArgvPromptBytes",
44
+ "usage", "cost", "toolPolicy", "streamsOutput", "signalsProcesses", "maxArgvPromptBytes",
45
45
  ]);
46
46
  /** @typedef {{id: string, type?: string, runtime?: string, gate: {runtime?: string}, status?: NodeStatus, errorCode?: string, currentRuntime?: string}} RoutableNode */
47
47
  /** @typedef {{status?: NodeStatus, errorCode?: string, currentRuntime?: string, assignment?: string, availability?: Record<string, RuntimeAvailability>}} RoutingEvent */
@@ -187,6 +187,10 @@ export function validateCapabilities(value, label) {
187
187
  for (const name of ["structuredOutput", "sandbox", "permissions", "continuation", "tokenBudget", "costBudget", "usage", "cost", "toolPolicy", "streamsOutput"]) {
188
188
  if (typeof value[name] !== "boolean") throw new TypeError(`${label}.${name} must be boolean`);
189
189
  }
190
+ // `signalsProcesses` is tri-state: null is a sandbox nobody has measured.
191
+ if (value.signalsProcesses !== null && typeof value.signalsProcesses !== "boolean") {
192
+ throw new TypeError(`${label}.signalsProcesses must be boolean or null`);
193
+ }
190
194
  if (!["stdin", "argv"].includes(/** @type {string} */ (value.promptTransport))) {
191
195
  throw new TypeError(`${label}.promptTransport is invalid`);
192
196
  }
@@ -129,7 +129,7 @@ export function validateNodeSnapshot(value, expectedNode = null) {
129
129
  "schemaVersion", "contractVersion", "id", "type", "sourceIdentity", "packetHash", "status", "phase",
130
130
  "attempt", "revisions", "judgeFailures", "runtime", "blockedBy", "startedAt", "updatedAt", "result", "gate", "error", "usage",
131
131
  "costUsd", "routing", "progress", "worktree", "invocations", "executionOverrides", "verification", "scope",
132
- "scopeFindings", "review", "previousAttempt", "sessionPolicy", "integratedHead",
132
+ "scopeFindings", "review", "previousAttempt", "sessionPolicy", "integratedHead", "declaredReadBytes",
133
133
  ]), "node snapshot");
134
134
  validateMetadata(value, "node snapshot");
135
135
  requireId(value.id, "node snapshot.id");
@@ -166,6 +166,12 @@ export function validateNodeSnapshot(value, expectedNode = null) {
166
166
  validateSnapshotError(value.error, "node snapshot.error");
167
167
  if (value.usage !== undefined) validateUsage(value.usage, "node snapshot.usage");
168
168
  if (value.costUsd !== undefined) nonNegativeNumber(value.costUsd, "node snapshot.costUsd");
169
+ // The summed byte size of the node's declared readFiles in the attempt
170
+ // worktree at dispatch time -- the one quantity the controller can measure
171
+ // about a packet's reference load, since the worker reads the files itself.
172
+ if (value.declaredReadBytes !== undefined && value.declaredReadBytes !== null) {
173
+ nonNegativeInteger(value.declaredReadBytes, "node snapshot.declaredReadBytes");
174
+ }
169
175
  if (value.routing !== undefined && value.routing !== null) validateRoutingState(value.routing, "node snapshot.routing");
170
176
  if (value.progress !== undefined && value.progress !== null) validateProgressState(value.progress, "node snapshot.progress");
171
177
  if (value.worktree !== undefined && value.worktree !== null) validateWorktreeState(value.worktree, "node snapshot.worktree");