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.
- package/README.md +152 -100
- package/package.json +10 -2
- package/skills/faberun/SKILL.md +6 -5
- package/skills/faberun/references/contract.md +23 -11
- package/skills/faberun/references/engineering.md +3 -1
- package/skills/faberun/references/operations.md +19 -12
- package/skills/faberun/references/rules.md +3 -1
- package/src/campaign/chain.mjs +6 -2
- package/src/campaign/index.mjs +17 -1
- package/src/campaign/metrics.mjs +3 -3
- package/src/cli/brand.mjs +2 -1
- package/src/cli/campaign.mjs +2 -0
- package/src/cli/contract.mjs +2 -0
- package/src/cli/manual.mjs +341 -0
- package/src/cli/seat.mjs +2 -0
- package/src/cli/setup.mjs +109 -30
- package/src/cli/skills.mjs +310 -8
- package/src/cli.mjs +3 -2
- package/src/contract/final-verification.mjs +31 -2
- package/src/contract/index.mjs +28 -25
- package/src/contract/runtime.mjs +5 -1
- package/src/contract/snapshot.mjs +7 -1
- package/src/contract/task-packet.mjs +20 -9
- package/src/contract/verification.mjs +1 -1
- package/src/engine/backoff.mjs +1 -1
- package/src/engine/dispatch.mjs +31 -4
- package/src/engine/gate.mjs +12 -0
- package/src/engine/process-identity.mjs +39 -0
- package/src/engine/prompts.mjs +18 -0
- package/src/engine/resume.mjs +2 -2
- package/src/engine/review.mjs +9 -1
- package/src/engine/run-command.mjs +23 -2
- package/src/engine/run-identity.mjs +14 -0
- package/src/engine/scheduler.mjs +45 -12
- package/src/engine/settle.mjs +29 -0
- package/src/engine/supervise.mjs +32 -6
- package/src/engine/verify.mjs +98 -9
- package/src/harnesses/agy/index.mjs +3 -0
- package/src/harnesses/claude/index.mjs +5 -0
- package/src/harnesses/codex/index.mjs +3 -0
- package/src/harnesses/dsh/index.mjs +26 -0
- package/src/harnesses/exec-jsonl/index.mjs +2 -0
- package/src/harnesses/index.mjs +10 -3
- package/src/harnesses/replay/index.mjs +2 -0
- package/src/harnesses/zcode/index.mjs +3 -0
- package/src/host/preflight.mjs +5 -1
- package/src/notify/index.mjs +45 -2
- package/src/repo/source-identity.mjs +4 -3
- package/src/report/final.mjs +3 -2
- package/src/report/render.mjs +134 -51
- package/src/web/index.html +1 -1
package/src/cli/skills.mjs
CHANGED
|
@@ -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
|
|
6
|
-
* the same catalogue into a target repository
|
|
7
|
-
*
|
|
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 {
|
|
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.
|
|
9
|
-
*
|
|
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
|
package/src/contract/index.mjs
CHANGED
|
@@ -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
|
|
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
|
|
169
|
-
// missing
|
|
170
|
-
// loaded. Collect the candidate here; the second
|
|
171
|
-
// against the node's transitive closure once all
|
|
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
|
|
238
|
-
// when some transitive dependency produces it --
|
|
239
|
-
// in its writeFiles, or the path sits under a
|
|
240
|
-
// writeRoots entry (a file-shaped entry
|
|
241
|
-
//
|
|
242
|
-
// this graph-aware deferral is a
|
|
243
|
-
// loads never defer -- they
|
|
244
|
-
// to resolve and nothing to
|
|
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 (!
|
|
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
|
|
360
|
-
// every packetHash untouched, so only this digest
|
|
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
|
|
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
|
|
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;
|
package/src/contract/runtime.mjs
CHANGED
|
@@ -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");
|