workflow-supervisor 0.1.0 → 0.1.2

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.
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import crypto from "node:crypto";
3
+ import { spawnSync } from "node:child_process";
3
4
  import fs from "node:fs";
4
5
  import os from "node:os";
5
6
  import path from "node:path";
@@ -8,9 +9,16 @@ import { fileURLToPath } from "node:url";
8
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
10
  const packageRoot = path.resolve(__dirname, "..");
10
11
  const PACKAGE_NAME = "workflow-supervisor";
11
- const PACKAGE_VERSION = "0.1.0";
12
- const INSTALLABLE_AGENTS = ["codex", "claude-code", "opencode", "hermesagent"];
12
+ const PACKAGE_VERSION = "0.1.1";
13
+ const WORKER_REPORT_SCHEMA_PATH = path.join(packageRoot, "schemas", "worker-report-v1.schema.json");
14
+ const DOSSIER_SCHEMA_PATH = path.join(packageRoot, "schemas", "dossier-v1.schema.json");
15
+ const ADAPTERS_ROOT = path.join(packageRoot, "adapters");
16
+ const INSTALLABLE_AGENTS = ["codex", "claude-code"];
13
17
  const AGENTS = new Set([...INSTALLABLE_AGENTS, "generic"]);
18
+ const DELEGATE_AGENTS = new Set(["codex", "claude-code"]);
19
+ const WORKER_ROLES = new Set(["implementer", "verifier", "repair", "documenter"]);
20
+ const REPORT_STATUSES = new Set(["PASS", "FAIL", "BLOCKED"]);
21
+ const WORKFLOW_STATE_IGNORE_ENTRY = ".workflow/";
14
22
 
15
23
  function usage() {
16
24
  return `workflow-supervisor
@@ -18,13 +26,16 @@ function usage() {
18
26
  Usage:
19
27
  workflow-supervisor list [--root <path>]
20
28
  workflow-supervisor validate [--root <path>]
29
+ workflow-supervisor validate-dossier <path> [--role <role>] [--unit <unit-id>] [--json]
21
30
  workflow-supervisor doctor [--agent <agent|all>] [--scope user|project] [--project <path>] [--target <path>]
22
31
  workflow-supervisor install --agent <agent|all> [--scope user|project] [--project <path>] [--target <path>] [--skills all|a,b] [--force] [--dry-run]
23
32
  workflow-supervisor uninstall --agent <agent|all> [--scope user|project] [--project <path>] [--target <path>] [--skills all|a,b] [--dry-run]
24
33
  workflow-supervisor emit-context --agent <agent> [--scope user|project] [--project <path>] [--target <path>] [--skills all|a,b] [--out <path>] [--root <path>]
34
+ workflow-supervisor delegate --agent <agent> --role <role> --unit <unit-id> [--cwd <path>] [--dossier <path>] [--adapter-command <json-array>] [--prompt-mode stdin|arg] [--timeout-ms <ms>] [--allow-dirty]
35
+ workflow-supervisor delegate-doctor --agent <agent|all> [--adapter-command <json-array>] [--prompt-mode stdin|arg] [--probe] [--require-pass] [--cwd <path>]
25
36
 
26
37
  Agents:
27
- codex, claude-code, opencode, hermesagent, generic, all
38
+ codex, claude-code, generic, all
28
39
 
29
40
  Alias:
30
41
  workflow-skills
@@ -33,13 +44,15 @@ Examples:
33
44
  npx workflow-supervisor install --agent codex --scope user
34
45
  npx workflow-supervisor install --agent all --scope project --project .
35
46
  npx workflow-supervisor install --agent generic --target ./agent-skills
36
- npx workflow-supervisor emit-context --agent opencode --skills workflow-supervisor,workflow-docs --out AGENTS.md
47
+ npx workflow-supervisor validate-dossier .workflow/dossiers/U1-implementer.yaml --role implementer --unit U1 --json
48
+ npx workflow-supervisor emit-context --agent generic --skills workflow-supervisor,workflow-docs --out AGENTS.md
49
+ npx workflow-supervisor delegate --agent claude-code --role verifier --unit U1 --dossier .workflow/DOSSIER.md
37
50
  `;
38
51
  }
39
52
 
40
53
  function parseArgs(argv) {
41
54
  const result = { _: [] };
42
- const booleans = new Set(["force", "dry-run", "help"]);
55
+ const booleans = new Set(["force", "dry-run", "help", "allow-dirty", "probe", "require-pass", "json"]);
43
56
  for (let i = 0; i < argv.length; i += 1) {
44
57
  const arg = argv[i];
45
58
  if (!arg.startsWith("--")) {
@@ -70,6 +83,22 @@ function skillsRoot(root = packageRoot) {
70
83
  return path.join(root, "skills");
71
84
  }
72
85
 
86
+ function schemasRoot(root = packageRoot) {
87
+ return path.join(root, "schemas");
88
+ }
89
+
90
+ function adaptersRoot(root = packageRoot) {
91
+ return path.join(root, "adapters");
92
+ }
93
+
94
+ function workerReportSchemaPath(root = packageRoot) {
95
+ return path.join(schemasRoot(root), "worker-report-v1.schema.json");
96
+ }
97
+
98
+ function dossierSchemaPath(root = packageRoot) {
99
+ return path.join(schemasRoot(root), "dossier-v1.schema.json");
100
+ }
101
+
73
102
  function listSkills(root = packageRoot) {
74
103
  const rootDir = skillsRoot(root);
75
104
  if (!fs.existsSync(rootDir)) throw new Error(`Missing skills directory: ${rootDir}`);
@@ -98,14 +127,6 @@ function defaultTarget(agent, { scope = "user", project = process.cwd() } = {})
98
127
  return resolvedScope === "project"
99
128
  ? path.join(projectRoot, ".claude", "skills")
100
129
  : path.join(process.env.CLAUDE_HOME || path.join(home, ".claude"), "skills");
101
- case "opencode":
102
- return resolvedScope === "project"
103
- ? path.join(projectRoot, ".opencode", "skills")
104
- : path.join(process.env.OPENCODE_HOME || path.join(home, ".config", "opencode"), "skills");
105
- case "hermesagent":
106
- return resolvedScope === "project"
107
- ? path.join(projectRoot, ".hermes", "skills")
108
- : path.join(process.env.HERMESAGENT_HOME || process.env.HERMES_HOME || path.join(home, ".hermes"), "skills");
109
130
  case "generic":
110
131
  return null;
111
132
  default:
@@ -117,6 +138,34 @@ function readText(file) {
117
138
  return fs.readFileSync(file, "utf8");
118
139
  }
119
140
 
141
+ function workflowStateAlreadyIgnored(text) {
142
+ return text.split(/\r?\n/).some((line) => {
143
+ const trimmed = line.trim();
144
+ return trimmed === WORKFLOW_STATE_IGNORE_ENTRY || trimmed === ".workflow" || trimmed === ".workflow/**";
145
+ });
146
+ }
147
+
148
+ function ensureWorkflowStateIgnored(project, dryRun = false) {
149
+ const projectRoot = path.resolve(expandHome(project || process.cwd()));
150
+ const file = path.join(projectRoot, ".gitignore");
151
+ const existing = fs.existsSync(file) ? readText(file) : "";
152
+ const alreadyPresent = workflowStateAlreadyIgnored(existing);
153
+ const result = {
154
+ file,
155
+ entry: WORKFLOW_STATE_IGNORE_ENTRY,
156
+ changed: !alreadyPresent,
157
+ alreadyPresent,
158
+ dryRun: Boolean(dryRun),
159
+ };
160
+
161
+ if (alreadyPresent || dryRun) return result;
162
+
163
+ fs.mkdirSync(projectRoot, { recursive: true });
164
+ const separator = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
165
+ fs.writeFileSync(file, `${existing}${separator}${WORKFLOW_STATE_IGNORE_ENTRY}\n`);
166
+ return result;
167
+ }
168
+
120
169
  function parseFrontmatter(text) {
121
170
  if (!text.startsWith("---\n")) return null;
122
171
  const end = text.indexOf("\n---\n", 4);
@@ -159,12 +208,65 @@ function validateSkill(root, name) {
159
208
  return errors;
160
209
  }
161
210
 
211
+ function validateRuntimeArtifacts(root = packageRoot) {
212
+ const errors = [];
213
+ const schemaFile = workerReportSchemaPath(root);
214
+ if (!fs.existsSync(schemaFile)) {
215
+ errors.push(`schema: missing ${schemaFile}`);
216
+ } else {
217
+ try {
218
+ const schema = parseJsonFile(schemaFile, "WorkerReportV1 schema");
219
+ if (schema.title !== "WorkerReportV1") errors.push("schema: title must be WorkerReportV1");
220
+ if (schema.properties?.schema?.const !== "WorkerReportV1") errors.push("schema: schema.const must be WorkerReportV1");
221
+ const statuses = schema.properties?.status?.enum || [];
222
+ if (JSON.stringify(statuses) !== JSON.stringify([...REPORT_STATUSES])) {
223
+ errors.push("schema: status enum must be PASS, FAIL, BLOCKED");
224
+ }
225
+ } catch (error) {
226
+ errors.push(`schema: ${error.message}`);
227
+ }
228
+ }
229
+
230
+ const dossierSchemaFile = dossierSchemaPath(root);
231
+ if (!fs.existsSync(dossierSchemaFile)) {
232
+ errors.push(`schema: missing ${dossierSchemaFile}`);
233
+ } else {
234
+ try {
235
+ const schema = parseJsonFile(dossierSchemaFile, "DossierV1 schema");
236
+ if (schema.title !== "DossierV1") errors.push("schema: title must be DossierV1");
237
+ if (schema.properties?.schema?.const !== "DossierV1") errors.push("schema: schema.const must be DossierV1");
238
+ } catch (error) {
239
+ errors.push(`schema: ${error.message}`);
240
+ }
241
+ }
242
+
243
+ for (const agent of DELEGATE_AGENTS) {
244
+ const file = path.join(adaptersRoot(root), agent, "adapter.json");
245
+ if (!fs.existsSync(file)) {
246
+ errors.push(`adapter ${agent}: missing ${file}`);
247
+ continue;
248
+ }
249
+ try {
250
+ const adapter = parseJsonFile(file, `${agent} adapter`);
251
+ if (adapter.agent !== agent) {
252
+ errors.push(`adapter ${agent}: declares agent ${adapter.agent || "<missing>"}`);
253
+ }
254
+ validateDelegateConfig(agent, adapter.delegate);
255
+ } catch (error) {
256
+ errors.push(`adapter ${agent}: ${error.message}`);
257
+ }
258
+ }
259
+
260
+ return errors;
261
+ }
262
+
162
263
  function validate(root = packageRoot) {
163
264
  const names = listSkills(root);
164
265
  const allErrors = [];
165
266
  for (const name of names) {
166
267
  for (const error of validateSkill(root, name)) allErrors.push(`${name}: ${error}`);
167
268
  }
269
+ for (const error of validateRuntimeArtifacts(root)) allErrors.push(error);
168
270
  if (names.length === 0) allErrors.push("no skills found");
169
271
  if (allErrors.length > 0) throw new Error(`Validation failed:\n${allErrors.map((e) => `- ${e}`).join("\n")}`);
170
272
  return names;
@@ -206,7 +308,7 @@ const SKILL_SUMMARIES = {
206
308
  "workflow-supervisor": "coordinate open-ended agent loops and bind Codex goals when appropriate",
207
309
  "source-corpus": "rank and reconcile sources when source authority affects safe next action",
208
310
  "work-unit": "decompose broad objectives into bounded units",
209
- "dossier-builder": "create a handoff contract for one already-bounded work unit",
311
+ "dossier-builder": "create a delegation contract for one already-bounded work unit",
210
312
  "worker-roles": "separate implementer, verifier, repair, documentation, reviewer, and solo-mode responsibilities",
211
313
  "acceptance-matrix": "create formal evidence-mapped acceptance criteria",
212
314
  "loop-policy": "define retries, parallel safety, approval gates, and goal binding policy",
@@ -255,11 +357,13 @@ Installed skills:
255
357
 
256
358
  \`${target || "<custom skill directory>"}\`
257
359
 
258
- Use these skills explicitly for supervised, long-running, or handoff-heavy workflows:
360
+ Use these skills explicitly for supervised, long-running, or delegation-heavy workflows:
259
361
 
260
362
  ${skillLines.join("\n")}
261
363
 
262
- Do not use this pack for tiny direct tasks, ordinary README edits, one-off tests, or routine review unless a supervised workflow or durable handoff is explicitly needed.
364
+ Do not use this pack for tiny direct tasks, ordinary README edits, one-off tests, or routine review unless a supervised workflow or durable continuation state is explicitly needed.
365
+
366
+ In Git-backed codebases, keep workflow state local: ensure \`.workflow/\` is listed in \`.gitignore\` before creating workflow artifacts, and do not stage or publish \`.workflow/\` unless the user explicitly makes it a deliverable.
263
367
  `;
264
368
  }
265
369
 
@@ -270,7 +374,7 @@ function portableContextFor(root, agent, target, names) {
270
374
  "",
271
375
  "This file embeds the selected Workflow Supervisor skills for agents that cannot discover `SKILL.md` folders directly.",
272
376
  "",
273
- "Use these skills explicitly for supervised, long-running, or handoff-heavy workflows. Loading or reading a skill does not by itself create a new thread, subagent, goal, commit, PR, publication, or other side effect; those actions require the governing environment tools and the gates described in the relevant skill.",
377
+ "Use these skills explicitly for supervised, long-running, or delegation-heavy workflows. Loading or reading a skill does not by itself create a worker, thread, subagent, goal, commit, PR, publication, or other side effect; those actions require the governing environment tools and the gates described in the relevant skill.",
274
378
  "",
275
379
  `Expected skill directory: \`${target || "<custom skill directory>"}\``,
276
380
  "",
@@ -278,7 +382,9 @@ function portableContextFor(root, agent, target, names) {
278
382
  "",
279
383
  ...names.map((name) => `- \`$${name}\`: ${skillSummary(name)}.`),
280
384
  "",
281
- "Do not use this pack for tiny direct tasks, ordinary README edits, one-off tests, or routine review unless a supervised workflow or durable handoff is explicitly needed.",
385
+ "Do not use this pack for tiny direct tasks, ordinary README edits, one-off tests, or routine review unless a supervised workflow or durable continuation state is explicitly needed.",
386
+ "",
387
+ "In Git-backed codebases, keep workflow state local: ensure `.workflow/` is listed in `.gitignore` before creating workflow artifacts, and do not stage or publish `.workflow/` unless the user explicitly makes it a deliverable.",
282
388
  "",
283
389
  ];
284
390
 
@@ -299,6 +405,963 @@ function portableContextFor(root, agent, target, names) {
299
405
  return `${sections.join("\n")}\n`;
300
406
  }
301
407
 
408
+ function parseJsonArray(value, label) {
409
+ let parsed;
410
+ try {
411
+ parsed = JSON.parse(value);
412
+ } catch (error) {
413
+ throw new Error(`${label} must be a JSON array: ${error.message}`);
414
+ }
415
+ if (!Array.isArray(parsed) || parsed.length === 0 || parsed.some((item) => typeof item !== "string" || !item)) {
416
+ throw new Error(`${label} must be a non-empty JSON array of strings`);
417
+ }
418
+ return parsed;
419
+ }
420
+
421
+ function parseJsonFile(file, label) {
422
+ try {
423
+ return JSON.parse(readText(file));
424
+ } catch (error) {
425
+ throw new Error(`${label} must be valid JSON: ${error.message}`);
426
+ }
427
+ }
428
+
429
+ function unquoteScalar(value) {
430
+ const trimmed = String(value || "").trim();
431
+ if (
432
+ (trimmed.startsWith("\"") && trimmed.endsWith("\"")) ||
433
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
434
+ ) {
435
+ return trimmed.slice(1, -1);
436
+ }
437
+ return trimmed;
438
+ }
439
+
440
+ function parseInlineArray(value) {
441
+ const trimmed = value.trim();
442
+ if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return null;
443
+ const body = trimmed.slice(1, -1).trim();
444
+ if (!body) return [];
445
+ return body.split(",").map((item) => unquoteScalar(item)).filter(Boolean);
446
+ }
447
+
448
+ function parseDossierScalar(value) {
449
+ const inlineArray = parseInlineArray(value);
450
+ if (inlineArray) return inlineArray;
451
+ return unquoteScalar(value);
452
+ }
453
+
454
+ function parseSimpleYaml(text) {
455
+ const result = {};
456
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
457
+ for (let i = 0; i < lines.length; i += 1) {
458
+ const line = lines[i];
459
+ if (!line.trim() || line.trimStart().startsWith("#")) continue;
460
+ const match = line.match(/^([A-Za-z_][A-Za-z0-9_-]*):(?:\s*(.*))?$/);
461
+ if (!match) continue;
462
+ const key = match[1];
463
+ const rawValue = match[2] ?? "";
464
+
465
+ if (rawValue === "|" || rawValue === ">") {
466
+ const block = [];
467
+ for (i += 1; i < lines.length; i += 1) {
468
+ const next = lines[i];
469
+ if (/^[A-Za-z_][A-Za-z0-9_-]*:/.test(next)) {
470
+ i -= 1;
471
+ break;
472
+ }
473
+ block.push(next.replace(/^ {2,}/, ""));
474
+ }
475
+ result[key] = rawValue === ">" ? block.join(" ").trim() : block.join("\n").trim();
476
+ continue;
477
+ }
478
+
479
+ if (rawValue.trim()) {
480
+ result[key] = parseDossierScalar(rawValue);
481
+ continue;
482
+ }
483
+
484
+ const items = [];
485
+ for (i += 1; i < lines.length; i += 1) {
486
+ const next = lines[i];
487
+ if (!next.trim() || next.trimStart().startsWith("#")) continue;
488
+ if (/^[A-Za-z_][A-Za-z0-9_-]*:/.test(next)) {
489
+ i -= 1;
490
+ break;
491
+ }
492
+ const item = next.match(/^\s*-\s*(.*)$/);
493
+ if (item) items.push(unquoteScalar(item[1]));
494
+ }
495
+ result[key] = items.length > 0 ? items : "";
496
+ }
497
+ return result;
498
+ }
499
+
500
+ function extractFencedBlock(text, languagePattern) {
501
+ const fence = new RegExp(`\`\`\`(?:${languagePattern})\\s*\\n([\\s\\S]*?)\\n\`\`\``, "gi");
502
+ let match;
503
+ while ((match = fence.exec(text))) {
504
+ if (/\b(schema|dossier_id|work_unit)\s*:/.test(match[1]) || match[1].trim().startsWith("{")) {
505
+ return match[1].trim();
506
+ }
507
+ }
508
+ return null;
509
+ }
510
+
511
+ function parseDossierText(text, label = "dossier") {
512
+ const trimmed = text.trim();
513
+ if (!trimmed) throw new Error(`${label} is empty`);
514
+ const fencedJson = extractFencedBlock(trimmed, "json");
515
+ const fencedYaml = extractFencedBlock(trimmed, "ya?ml");
516
+ const candidate = fencedJson || fencedYaml || trimmed;
517
+ if (candidate.trim().startsWith("{")) {
518
+ try {
519
+ return JSON.parse(candidate);
520
+ } catch (error) {
521
+ throw new Error(`${label} JSON is invalid: ${error.message}`);
522
+ }
523
+ }
524
+ const parsed = parseSimpleYaml(candidate);
525
+ if (Object.keys(parsed).length === 0) throw new Error(`${label} must be JSON, YAML, or fenced YAML`);
526
+ return parsed;
527
+ }
528
+
529
+ const DOSSIER_STRING_FIELDS = [
530
+ "workflow",
531
+ "work_unit",
532
+ "dossier_id",
533
+ "worker_name",
534
+ "worker_role",
535
+ "delegation_transport",
536
+ "start_condition",
537
+ "title",
538
+ "objective",
539
+ "worker_prompt",
540
+ "completion_report_schema",
541
+ "verification_report_schema",
542
+ ];
543
+
544
+ const DOSSIER_CORE_ARRAY_FIELDS = [
545
+ "non_goals",
546
+ "source_corpus",
547
+ "must_read",
548
+ "allowed_surfaces",
549
+ "forbidden_surfaces",
550
+ "acceptance_matrix",
551
+ "adversarial_checks",
552
+ "required_commands_or_evidence",
553
+ "supervisor_checkpoints",
554
+ "stop_gates",
555
+ ];
556
+
557
+ const DOSSIER_EXPLICIT_ARRAY_FIELDS = ["assumptions", "open_questions"];
558
+
559
+ function isPlaceholder(value, { allowNone = false } = {}) {
560
+ const normalized = String(value || "").trim().toLowerCase().replace(/[.!]+$/, "");
561
+ if (allowNone && /^(none|no open questions|no assumptions|empty)$/.test(normalized)) return false;
562
+ return (
563
+ !normalized ||
564
+ /^(tbd|todo|unknown|unclear|n\/a|na|none|null|use your judgment|you decide|whatever|later|as needed|various|misc|etc)$/.test(normalized) ||
565
+ /\b(tbd|todo|unknown|unclear|use your judgment|as needed|and so on)\b/.test(normalized)
566
+ );
567
+ }
568
+
569
+ function fieldArray(value) {
570
+ if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean);
571
+ return [];
572
+ }
573
+
574
+ function validateConcreteArray(data, field, errors, options = {}) {
575
+ const values = fieldArray(data[field]);
576
+ if (values.length === 0) {
577
+ errors.push(`${field} must be a non-empty array`);
578
+ return values;
579
+ }
580
+ values.forEach((item, index) => {
581
+ if (isPlaceholder(item, options)) errors.push(`${field}[${index}] is not concrete: ${item || "<empty>"}`);
582
+ });
583
+ return values;
584
+ }
585
+
586
+ function validateDossierData(data, { role, unitId } = {}) {
587
+ const errors = [];
588
+ const warnings = [];
589
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
590
+ return { valid: false, errors: ["dossier must be an object"], warnings };
591
+ }
592
+
593
+ if (data.schema !== "DossierV1") errors.push("schema must be DossierV1");
594
+
595
+ for (const field of DOSSIER_STRING_FIELDS) {
596
+ if (typeof data[field] !== "string" || isPlaceholder(data[field])) {
597
+ errors.push(`${field} must be a concrete non-empty string`);
598
+ }
599
+ }
600
+
601
+ for (const field of DOSSIER_CORE_ARRAY_FIELDS) {
602
+ validateConcreteArray(data, field, errors);
603
+ }
604
+
605
+ for (const field of DOSSIER_EXPLICIT_ARRAY_FIELDS) {
606
+ validateConcreteArray(data, field, errors, { allowNone: true });
607
+ }
608
+
609
+ if (data.worker_role && !WORKER_ROLES.has(data.worker_role)) {
610
+ errors.push(`worker_role must be one of: ${[...WORKER_ROLES].join(", ")}`);
611
+ }
612
+ if (role && data.worker_role && data.worker_role !== role) {
613
+ errors.push(`worker_role ${data.worker_role} does not match requested role ${role}`);
614
+ }
615
+ if (unitId && data.work_unit) {
616
+ const unitPattern = new RegExp(`(^|[^A-Za-z0-9])${unitId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^A-Za-z0-9]|$)`);
617
+ if (data.work_unit !== unitId && !unitPattern.test(data.work_unit)) {
618
+ errors.push(`work_unit ${data.work_unit} does not reference requested unit ${unitId}`);
619
+ }
620
+ }
621
+ if (data.delegation_transport && !["portable_delegate", "native_thread", "native_subagent", "same_session_phased"].includes(data.delegation_transport)) {
622
+ errors.push("delegation_transport must be portable_delegate, native_thread, native_subagent, or same_session_phased");
623
+ }
624
+ if (data.completion_report_schema && !/\bWorkerReportV1\b/.test(data.completion_report_schema)) {
625
+ errors.push("completion_report_schema must name WorkerReportV1");
626
+ }
627
+ if (data.verification_report_schema && !/\bWorkerReportV1\b/.test(data.verification_report_schema)) {
628
+ errors.push("verification_report_schema must name WorkerReportV1");
629
+ }
630
+ if (data.worker_prompt && !/\bWorkerReportV1\b/.test(data.worker_prompt)) {
631
+ errors.push("worker_prompt must require WorkerReportV1");
632
+ }
633
+ if (data.worker_prompt && data.worker_role && !data.worker_prompt.toLowerCase().includes(data.worker_role.toLowerCase())) {
634
+ errors.push("worker_prompt must name the worker role");
635
+ }
636
+
637
+ const broadSurface = /^(all|all files|everything|entire repo|whole repo|repo root|\.|\*|\*\*)$/i;
638
+ for (const [field, values] of [
639
+ ["allowed_surfaces", fieldArray(data.allowed_surfaces)],
640
+ ["forbidden_surfaces", fieldArray(data.forbidden_surfaces)],
641
+ ]) {
642
+ values.forEach((surface, index) => {
643
+ if (broadSurface.test(surface.trim())) errors.push(`${field}[${index}] is too broad: ${surface}`);
644
+ });
645
+ }
646
+
647
+ fieldArray(data.acceptance_matrix).forEach((row, index) => {
648
+ if (!/\b[A-Z]+[0-9]+\b/.test(row)) warnings.push(`acceptance_matrix[${index}] should include a stable row ID`);
649
+ });
650
+
651
+ const unresolved = fieldArray(data.open_questions).filter((item) => !/^(none|no open questions|empty)$/i.test(item));
652
+ if (unresolved.length > 0) {
653
+ errors.push("open_questions must be explicitly none before delegation; create a discovery dossier or stop as BLOCKED");
654
+ }
655
+
656
+ return { valid: errors.length === 0, errors, warnings };
657
+ }
658
+
659
+ function loadDossier(file) {
660
+ const dossierPath = path.resolve(expandHome(file));
661
+ if (!fs.existsSync(dossierPath)) throw new Error(`Missing dossier: ${dossierPath}`);
662
+ const text = readText(dossierPath);
663
+ return {
664
+ path: dossierPath,
665
+ text,
666
+ data: parseDossierText(text, dossierPath),
667
+ };
668
+ }
669
+
670
+ function validateDossierCommand(args) {
671
+ const target = args.dossier || args._[1];
672
+ if (!target) throw new Error("validate-dossier requires a dossier path");
673
+ const loaded = loadDossier(target);
674
+ const validation = validateDossierData(loaded.data, { role: args.role, unitId: args.unit });
675
+ const report = {
676
+ schema: "DossierValidationV1",
677
+ dossier: loaded.path,
678
+ valid: validation.valid,
679
+ errors: validation.errors,
680
+ warnings: validation.warnings,
681
+ };
682
+ if (!validation.valid) process.exitCode = 1;
683
+ return args.json ? JSON.stringify(report, null, 2) : validation.valid
684
+ ? `Dossier valid: ${loaded.path}`
685
+ : `Dossier invalid: ${loaded.path}\n${validation.errors.map((error) => `- ${error}`).join("\n")}`;
686
+ }
687
+
688
+ function resolveDelegateDossier(args, cwd, { role, unitId }) {
689
+ if (args["dossier-text"]) {
690
+ return { text: args["dossier-text"], data: null, guardArgs: args };
691
+ }
692
+ if (!args.dossier) {
693
+ return {
694
+ blocked: blockedReport({
695
+ role,
696
+ unitId,
697
+ reason: "invalid_dossier",
698
+ summary: "Worker delegation requires --dossier with a valid DossierV1 contract.",
699
+ adapter: null,
700
+ guard: { allowed_surface_violations: [], role_violations: [], warnings: [] },
701
+ }),
702
+ };
703
+ }
704
+
705
+ const dossierPath = path.resolve(cwd, expandHome(args.dossier));
706
+ const loaded = loadDossier(dossierPath);
707
+ const validation = validateDossierData(loaded.data, { role, unitId });
708
+ if (!validation.valid) {
709
+ return {
710
+ blocked: blockedReport({
711
+ role,
712
+ unitId,
713
+ reason: "invalid_dossier",
714
+ summary: `DossierV1 validation failed: ${validation.errors.join("; ")}`,
715
+ adapter: null,
716
+ guard: { allowed_surface_violations: [], role_violations: [], warnings: validation.warnings },
717
+ }),
718
+ };
719
+ }
720
+
721
+ return {
722
+ text: loaded.text,
723
+ data: loaded.data,
724
+ guardArgs: {
725
+ ...args,
726
+ "allowed-surfaces": args["allowed-surfaces"] || fieldArray(loaded.data.allowed_surfaces).join(","),
727
+ "forbidden-surfaces": args["forbidden-surfaces"] || fieldArray(loaded.data.forbidden_surfaces).join(","),
728
+ },
729
+ };
730
+ }
731
+
732
+ function splitCsv(value) {
733
+ if (!value) return [];
734
+ return value
735
+ .split(",")
736
+ .map((item) => item.trim())
737
+ .filter(Boolean);
738
+ }
739
+
740
+ function excerpt(value, max = 2000) {
741
+ const text = String(value || "");
742
+ return text.length <= max ? text : `${text.slice(0, max)}...`;
743
+ }
744
+
745
+ function workerReportSchemaText() {
746
+ return readText(WORKER_REPORT_SCHEMA_PATH);
747
+ }
748
+
749
+ function adapterConfigPath(agent) {
750
+ return path.join(ADAPTERS_ROOT, agent, "adapter.json");
751
+ }
752
+
753
+ function validateDelegateConfig(agent, delegate) {
754
+ const errors = [];
755
+ if (!delegate || typeof delegate !== "object" || Array.isArray(delegate)) {
756
+ errors.push("delegate must be an object");
757
+ }
758
+ if (!Array.isArray(delegate?.command) || delegate.command.length === 0) {
759
+ errors.push("delegate.command must be a non-empty array");
760
+ } else if (delegate.command.some((item) => typeof item !== "string" || !item)) {
761
+ errors.push("delegate.command must contain only non-empty strings");
762
+ }
763
+ if (delegate?.promptMode !== "arg" && delegate?.promptMode !== "stdin") {
764
+ errors.push("delegate.promptMode must be arg or stdin");
765
+ }
766
+ if (delegate?.schemaMode != null && delegate.schemaMode !== "file" && delegate.schemaMode !== "json") {
767
+ errors.push("delegate.schemaMode must be file or json when present");
768
+ }
769
+ if (delegate?.schemaMode && (typeof delegate.schemaFlag !== "string" || !delegate.schemaFlag)) {
770
+ errors.push("delegate.schemaFlag is required when delegate.schemaMode is set");
771
+ }
772
+ if (errors.length > 0) throw new Error(`Invalid delegate adapter for ${agent}: ${errors.join("; ")}`);
773
+ }
774
+
775
+ function loadAdapterConfig(agent) {
776
+ const file = adapterConfigPath(agent);
777
+ if (!fs.existsSync(file)) throw new Error(`Missing adapter config: ${file}`);
778
+ const adapter = parseJsonFile(file, `${agent} adapter`);
779
+ if (adapter.agent !== agent) {
780
+ throw new Error(`Invalid adapter config: ${file} declares agent ${adapter.agent || "<missing>"}`);
781
+ }
782
+ validateDelegateConfig(agent, adapter.delegate);
783
+ return adapter;
784
+ }
785
+
786
+ function resolveDelegateAdapter(args) {
787
+ const agent = args.agent;
788
+ if (!agent) throw new Error("--agent is required");
789
+ if (!DELEGATE_AGENTS.has(agent)) {
790
+ throw new Error(`Unsupported delegate agent: ${agent}. Supported: ${[...DELEGATE_AGENTS].join(", ")}`);
791
+ }
792
+
793
+ if (args["adapter-command"]) {
794
+ const promptMode = args["prompt-mode"] || "stdin";
795
+ if (promptMode !== "stdin" && promptMode !== "arg") throw new Error("--prompt-mode must be stdin or arg");
796
+ return {
797
+ agent,
798
+ command: parseJsonArray(args["adapter-command"], "--adapter-command"),
799
+ promptMode,
800
+ source: "override",
801
+ schemaMode: null,
802
+ schemaFlag: null,
803
+ };
804
+ }
805
+
806
+ const adapter = loadAdapterConfig(agent);
807
+ return {
808
+ agent,
809
+ command: adapter.delegate.command,
810
+ promptMode: adapter.delegate.promptMode,
811
+ schemaMode: adapter.delegate.schemaMode || null,
812
+ schemaFlag: adapter.delegate.schemaFlag || null,
813
+ source: "adapter-json",
814
+ };
815
+ }
816
+
817
+ function schemaArgsFor(adapter) {
818
+ if (!adapter.schemaMode) return [];
819
+ if (adapter.schemaMode === "file") return [adapter.schemaFlag, WORKER_REPORT_SCHEMA_PATH];
820
+ if (adapter.schemaMode === "json") return [adapter.schemaFlag, workerReportSchemaText()];
821
+ return [];
822
+ }
823
+
824
+ function runtimeCommand(adapter) {
825
+ return [...adapter.command, ...schemaArgsFor(adapter)];
826
+ }
827
+
828
+ function displayCommand(adapter) {
829
+ if (!adapter.schemaMode) return adapter.command;
830
+ const schemaDisplay = adapter.schemaMode === "file" ? WORKER_REPORT_SCHEMA_PATH : "<WorkerReportV1 schema>";
831
+ return [...adapter.command, adapter.schemaFlag, schemaDisplay];
832
+ }
833
+
834
+ function commandAvailable(command) {
835
+ if (command.includes(path.sep)) return fs.existsSync(command);
836
+ const paths = (process.env.PATH || "").split(path.delimiter).filter(Boolean);
837
+ return paths.some((dir) => fs.existsSync(path.join(dir, command)));
838
+ }
839
+
840
+ function readDossier(args, cwd) {
841
+ if (args["dossier-text"]) return args["dossier-text"];
842
+ if (!args.dossier) return "";
843
+ const dossierPath = path.resolve(cwd, expandHome(args.dossier));
844
+ if (!fs.existsSync(dossierPath)) throw new Error(`Missing dossier: ${dossierPath}`);
845
+ return readText(dossierPath);
846
+ }
847
+
848
+ function buildWorkerPrompt({ role, unitId, dossierText }) {
849
+ return [
850
+ "You are a role-scoped worker in a Workflow Supervisor loop.",
851
+ `Role: ${role}`,
852
+ `Work unit: ${unitId}`,
853
+ "",
854
+ "Rules:",
855
+ "- Use only the assigned role and dossier.",
856
+ "- Do not ask the human directly.",
857
+ "- Do not choose final disposition.",
858
+ "- Do not expand scope.",
859
+ "- If you need a human decision, return BLOCKED with blocking_question.",
860
+ "- Return exactly one WorkerReportV1 JSON object and no prose outside JSON.",
861
+ "- PASS requires concrete evidence for the material acceptance rows.",
862
+ "- Verifier must not edit files or artifacts.",
863
+ "",
864
+ "WorkerReportV1 JSON shape:",
865
+ JSON.stringify(
866
+ {
867
+ schema: "WorkerReportV1",
868
+ status: "PASS|FAIL|BLOCKED",
869
+ role,
870
+ unit_id: unitId,
871
+ summary: "",
872
+ changed_surfaces: [],
873
+ evidence: [],
874
+ checks_run: [],
875
+ skipped_checks: [],
876
+ findings: [],
877
+ blocking_question: null,
878
+ next_action: "",
879
+ adapter: null,
880
+ guard: null,
881
+ reason: null,
882
+ },
883
+ null,
884
+ 2,
885
+ ),
886
+ "",
887
+ "WorkerReportV1 JSON Schema:",
888
+ workerReportSchemaText(),
889
+ "",
890
+ "Dossier:",
891
+ dossierText || "(No dossier file was provided. If the objective or acceptance evidence is insufficient, return BLOCKED.)",
892
+ ].join("\n");
893
+ }
894
+
895
+ function gitStatusLines(cwd) {
896
+ const result = spawnSync("git", ["-C", cwd, "status", "--porcelain=v1", "--untracked-files=all"], {
897
+ encoding: "utf8",
898
+ maxBuffer: 1024 * 1024,
899
+ });
900
+ if (result.error || result.status !== 0) return null;
901
+ return result.stdout.trimEnd() ? result.stdout.trimEnd().split(/\r?\n/) : [];
902
+ }
903
+
904
+ function parseGitStatusPaths(lines) {
905
+ const paths = new Set();
906
+ for (const line of lines || []) {
907
+ const raw = line.slice(3).trim();
908
+ const renamed = raw.includes(" -> ") ? raw.split(" -> ").pop() : raw;
909
+ if (renamed) paths.add(renamed.replace(/^"|"$/g, ""));
910
+ }
911
+ return [...paths].sort();
912
+ }
913
+
914
+ function normalizeSurface(value) {
915
+ return value.replace(/\\/g, "/").replace(/^\.?\//, "").replace(/\/+$/, "");
916
+ }
917
+
918
+ function surfaceMatches(changedPath, surface) {
919
+ const changed = normalizeSurface(changedPath);
920
+ const target = normalizeSurface(surface);
921
+ return changed === target || changed.startsWith(`${target}/`);
922
+ }
923
+
924
+ function hashPath(targetPath) {
925
+ if (!fs.existsSync(targetPath)) return "MISSING";
926
+ const stat = fs.statSync(targetPath);
927
+ if (stat.isDirectory()) {
928
+ const hash = crypto.createHash("sha256");
929
+ for (const file of walkFiles(targetPath)) {
930
+ hash.update(path.relative(targetPath, file));
931
+ hash.update("\0");
932
+ hash.update(fs.readFileSync(file));
933
+ hash.update("\0");
934
+ }
935
+ return hash.digest("hex");
936
+ }
937
+ return crypto.createHash("sha256").update(fs.readFileSync(targetPath)).digest("hex");
938
+ }
939
+
940
+ function snapshotSurfaces(cwd, surfaces) {
941
+ const snapshot = new Map();
942
+ for (const surface of surfaces) {
943
+ snapshot.set(normalizeSurface(surface), hashPath(path.resolve(cwd, surface)));
944
+ }
945
+ return snapshot;
946
+ }
947
+
948
+ function changedSnapshotSurfaces(before, cwd) {
949
+ const changed = [];
950
+ for (const [surface, oldHash] of before.entries()) {
951
+ const nextHash = hashPath(path.resolve(cwd, surface));
952
+ if (nextHash !== oldHash) changed.push(surface);
953
+ }
954
+ return changed.sort();
955
+ }
956
+
957
+ function beginGuard(args, role, cwd) {
958
+ const allowedSurfaces = splitCsv(args["allowed-surfaces"]);
959
+ const forbiddenSurfaces = splitCsv(args["forbidden-surfaces"]);
960
+ const gitBefore = gitStatusLines(cwd);
961
+ const explicitSurfaces = [...allowedSurfaces, ...forbiddenSurfaces];
962
+ const guard = {
963
+ allowed_surface_violations: [],
964
+ role_violations: [],
965
+ warnings: [],
966
+ };
967
+
968
+ if (gitBefore && gitBefore.length > 0 && role !== "verifier" && !args["allow-dirty"]) {
969
+ return {
970
+ blocked: blockedReport({
971
+ role,
972
+ unitId: args.unit,
973
+ reason: "dirty_workspace",
974
+ summary: "Mutable worker delegation is blocked because the git workspace is already dirty and --allow-dirty was not set.",
975
+ adapter: null,
976
+ guard: { ...guard, warnings: ["baseline git status is not clean"] },
977
+ }),
978
+ };
979
+ }
980
+
981
+ if (!gitBefore && explicitSurfaces.length === 0) {
982
+ guard.warnings.push("surface guard degraded: not a git workspace and no explicit surfaces were provided");
983
+ }
984
+
985
+ return {
986
+ gitBefore,
987
+ allowedSurfaces,
988
+ forbiddenSurfaces,
989
+ explicitSnapshot: !gitBefore && explicitSurfaces.length > 0 ? snapshotSurfaces(cwd, explicitSurfaces) : null,
990
+ guard,
991
+ };
992
+ }
993
+
994
+ function finishGuard(start, role, cwd) {
995
+ const guard = {
996
+ allowed_surface_violations: [...(start.guard?.allowed_surface_violations || [])],
997
+ role_violations: [...(start.guard?.role_violations || [])],
998
+ warnings: [...(start.guard?.warnings || [])],
999
+ };
1000
+
1001
+ let changedPaths = [];
1002
+ if (start.gitBefore) {
1003
+ const gitAfter = gitStatusLines(cwd);
1004
+ if (!gitAfter) {
1005
+ guard.warnings.push("surface guard degraded: git status became unavailable after delegation");
1006
+ } else {
1007
+ changedPaths = parseGitStatusPaths(gitAfter);
1008
+ const beforeText = start.gitBefore.join("\n");
1009
+ const afterText = gitAfter.join("\n");
1010
+ if (role === "verifier" && beforeText !== afterText) {
1011
+ guard.role_violations.push("verifier changed the workspace");
1012
+ }
1013
+ if (start.gitBefore.length > 0) {
1014
+ guard.warnings.push("surface guard degraded: baseline was dirty, so changed paths may include pre-existing edits");
1015
+ }
1016
+ }
1017
+ } else if (start.explicitSnapshot) {
1018
+ changedPaths = changedSnapshotSurfaces(start.explicitSnapshot, cwd);
1019
+ if (role === "verifier" && changedPaths.length > 0) {
1020
+ guard.role_violations.push("verifier changed watched surfaces");
1021
+ }
1022
+ }
1023
+
1024
+ if (start.allowedSurfaces?.length > 0) {
1025
+ for (const changedPath of changedPaths) {
1026
+ if (!start.allowedSurfaces.some((surface) => surfaceMatches(changedPath, surface))) {
1027
+ guard.allowed_surface_violations.push(changedPath);
1028
+ }
1029
+ }
1030
+ }
1031
+
1032
+ for (const changedPath of changedPaths) {
1033
+ if (start.forbiddenSurfaces?.some((surface) => surfaceMatches(changedPath, surface))) {
1034
+ guard.role_violations.push(`changed forbidden surface: ${changedPath}`);
1035
+ }
1036
+ }
1037
+
1038
+ return guard;
1039
+ }
1040
+
1041
+ function ensureArray(value) {
1042
+ if (!value) return [];
1043
+ return Array.isArray(value) ? value : [value];
1044
+ }
1045
+
1046
+ function reportAdapterMeta(adapter, result = {}) {
1047
+ return {
1048
+ agent: adapter?.agent || null,
1049
+ command: adapter ? displayCommand(adapter).join(" ") : null,
1050
+ exit_code: Number.isInteger(result.status) ? result.status : null,
1051
+ timed_out: Boolean(result.signal === "SIGTERM" && result.error?.code === "ETIMEDOUT"),
1052
+ source: adapter?.source || null,
1053
+ schema_mode: adapter?.schemaMode || null,
1054
+ };
1055
+ }
1056
+
1057
+ function blockedReport({ role, unitId, reason, summary, adapter, guard, stdout, stderr }) {
1058
+ return {
1059
+ schema: "WorkerReportV1",
1060
+ status: "BLOCKED",
1061
+ role,
1062
+ unit_id: unitId,
1063
+ summary,
1064
+ changed_surfaces: [],
1065
+ evidence: [],
1066
+ checks_run: [],
1067
+ skipped_checks: [],
1068
+ findings: reason ? [{ id: reason, severity: "blocking", summary }] : [],
1069
+ blocking_question: null,
1070
+ next_action: "supervisor_review",
1071
+ adapter: adapter || null,
1072
+ guard: guard || { allowed_surface_violations: [], role_violations: [], warnings: [] },
1073
+ reason,
1074
+ stdout_excerpt: stdout ? excerpt(stdout) : undefined,
1075
+ stderr_excerpt: stderr ? excerpt(stderr) : undefined,
1076
+ };
1077
+ }
1078
+
1079
+ function normalizeReport(report, { role, unitId, adapter, guard }) {
1080
+ return {
1081
+ ...report,
1082
+ schema: "WorkerReportV1",
1083
+ status: String(report.status || "").toUpperCase(),
1084
+ role: report.role || role,
1085
+ unit_id: report.unit_id || unitId,
1086
+ summary: report.summary || "",
1087
+ changed_surfaces: ensureArray(report.changed_surfaces),
1088
+ evidence: ensureArray(report.evidence),
1089
+ checks_run: ensureArray(report.checks_run),
1090
+ skipped_checks: ensureArray(report.skipped_checks),
1091
+ findings: ensureArray(report.findings),
1092
+ blocking_question: report.blocking_question ?? null,
1093
+ next_action: report.next_action || "",
1094
+ adapter,
1095
+ guard,
1096
+ };
1097
+ }
1098
+
1099
+ function validateWorkerReport(report, { role, unitId }) {
1100
+ const errors = [];
1101
+ if (!report || typeof report !== "object" || Array.isArray(report)) errors.push("report is not an object");
1102
+ if (report?.schema !== "WorkerReportV1") errors.push("schema must be WorkerReportV1");
1103
+ if (!REPORT_STATUSES.has(report?.status)) errors.push("status must be PASS, FAIL, or BLOCKED");
1104
+ if (report?.role !== role) errors.push(`role must be ${role}`);
1105
+ if (report?.unit_id !== unitId) errors.push(`unit_id must be ${unitId}`);
1106
+ for (const field of ["changed_surfaces", "evidence", "checks_run", "skipped_checks", "findings"]) {
1107
+ if (!Array.isArray(report?.[field])) errors.push(`${field} must be an array`);
1108
+ }
1109
+ if (report?.status === "PASS" && report.evidence.length === 0) errors.push("PASS requires non-empty evidence");
1110
+ if (report?.blocking_question && report.status !== "BLOCKED") {
1111
+ errors.push("blocking_question requires BLOCKED status");
1112
+ }
1113
+ if (role === "verifier" && report?.changed_surfaces?.length > 0) errors.push("verifier must not report changed surfaces");
1114
+ return errors;
1115
+ }
1116
+
1117
+ function extractJsonObjects(text) {
1118
+ const objects = [];
1119
+ let start = -1;
1120
+ let depth = 0;
1121
+ let inString = false;
1122
+ let escaped = false;
1123
+ for (let i = 0; i < text.length; i += 1) {
1124
+ const char = text[i];
1125
+ if (inString) {
1126
+ if (escaped) {
1127
+ escaped = false;
1128
+ } else if (char === "\\") {
1129
+ escaped = true;
1130
+ } else if (char === "\"") {
1131
+ inString = false;
1132
+ }
1133
+ continue;
1134
+ }
1135
+ if (char === "\"") {
1136
+ inString = true;
1137
+ continue;
1138
+ }
1139
+ if (char === "{") {
1140
+ if (depth === 0) start = i;
1141
+ depth += 1;
1142
+ continue;
1143
+ }
1144
+ if (char === "}" && depth > 0) {
1145
+ depth -= 1;
1146
+ if (depth === 0 && start !== -1) {
1147
+ const candidate = text.slice(start, i + 1);
1148
+ try {
1149
+ objects.push(JSON.parse(candidate));
1150
+ } catch {
1151
+ // Ignore non-JSON brace groups.
1152
+ }
1153
+ start = -1;
1154
+ }
1155
+ }
1156
+ }
1157
+ return objects;
1158
+ }
1159
+
1160
+ function nestedTextValues(value, depth = 0) {
1161
+ if (depth > 4 || value == null) return [];
1162
+ if (typeof value === "string") return [value];
1163
+ if (Array.isArray(value)) return value.flatMap((item) => nestedTextValues(item, depth + 1));
1164
+ if (typeof value === "object") return Object.values(value).flatMap((item) => nestedTextValues(item, depth + 1));
1165
+ return [];
1166
+ }
1167
+
1168
+ function extractWorkerReport(stdout, stderr) {
1169
+ const objects = extractJsonObjects(`${stdout || ""}\n${stderr || ""}`);
1170
+ const direct = objects.find((item) => item?.schema === "WorkerReportV1");
1171
+ if (direct) return direct;
1172
+ for (const object of objects) {
1173
+ for (const text of nestedTextValues(object)) {
1174
+ const nested = extractJsonObjects(text).find((item) => item?.schema === "WorkerReportV1");
1175
+ if (nested) return nested;
1176
+ }
1177
+ }
1178
+ return null;
1179
+ }
1180
+
1181
+ function looksLikeAuthFailure(text) {
1182
+ return /\b(auth|authenticate|authentication|login|logged in|unauthorized|forbidden|api key|token|credential|permission denied)\b/i.test(
1183
+ text || "",
1184
+ );
1185
+ }
1186
+
1187
+ function runAdapter(adapter, prompt, cwd, timeoutMs) {
1188
+ const [command, ...baseArgs] = runtimeCommand(adapter);
1189
+ const commandArgs = adapter.promptMode === "arg" ? [...baseArgs, prompt] : baseArgs;
1190
+ return spawnSync(command, commandArgs, {
1191
+ cwd,
1192
+ input: adapter.promptMode === "stdin" ? prompt : undefined,
1193
+ encoding: "utf8",
1194
+ maxBuffer: 10 * 1024 * 1024,
1195
+ timeout: timeoutMs,
1196
+ });
1197
+ }
1198
+
1199
+ function delegate(args) {
1200
+ const role = args.role;
1201
+ const unitId = args.unit;
1202
+ if (!WORKER_ROLES.has(role)) throw new Error(`--role must be one of: ${[...WORKER_ROLES].join(", ")}`);
1203
+ if (!unitId) throw new Error("--unit is required");
1204
+
1205
+ const cwd = path.resolve(expandHome(args.cwd || process.cwd()));
1206
+ if (!fs.existsSync(cwd)) throw new Error(`Missing --cwd path: ${cwd}`);
1207
+ const dossier = resolveDelegateDossier(args, cwd, { role, unitId });
1208
+ if (dossier.blocked) return JSON.stringify(dossier.blocked, null, 2);
1209
+ const adapter = resolveDelegateAdapter(args);
1210
+ const timeoutMs = Number.parseInt(args["timeout-ms"] || "120000", 10);
1211
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("--timeout-ms must be a positive integer");
1212
+
1213
+ const guardStart = beginGuard(dossier.guardArgs, role, cwd);
1214
+ if (guardStart.blocked) return JSON.stringify(guardStart.blocked, null, 2);
1215
+
1216
+ const prompt = buildWorkerPrompt({ role, unitId, dossierText: dossier.text });
1217
+ const result = runAdapter(adapter, prompt, cwd, timeoutMs);
1218
+ const adapterMeta = reportAdapterMeta(adapter, result);
1219
+
1220
+ if (result.error?.code === "ENOENT") {
1221
+ return JSON.stringify(
1222
+ blockedReport({
1223
+ role,
1224
+ unitId,
1225
+ reason: "adapter_cli_missing",
1226
+ summary: `Adapter executable was not found: ${adapter.command[0]}`,
1227
+ adapter: adapterMeta,
1228
+ guard: guardStart.guard,
1229
+ stderr: result.error.message,
1230
+ }),
1231
+ null,
1232
+ 2,
1233
+ );
1234
+ }
1235
+
1236
+ if (result.error && result.error.code !== "ETIMEDOUT") {
1237
+ return JSON.stringify(
1238
+ blockedReport({
1239
+ role,
1240
+ unitId,
1241
+ reason: "adapter_execution_error",
1242
+ summary: result.error.message,
1243
+ adapter: adapterMeta,
1244
+ guard: guardStart.guard,
1245
+ stdout: result.stdout,
1246
+ stderr: result.stderr,
1247
+ }),
1248
+ null,
1249
+ 2,
1250
+ );
1251
+ }
1252
+
1253
+ const guard = finishGuard(guardStart, role, cwd);
1254
+ const extracted = extractWorkerReport(result.stdout, result.stderr);
1255
+ if (!extracted) {
1256
+ const combinedOutput = `${result.stdout || ""}\n${result.stderr || ""}\n${result.error?.message || ""}`;
1257
+ const reason = result.error?.code === "ETIMEDOUT"
1258
+ ? "adapter_timeout"
1259
+ : looksLikeAuthFailure(combinedOutput)
1260
+ ? "adapter_auth_unavailable"
1261
+ : "invalid_worker_report";
1262
+ return JSON.stringify(
1263
+ blockedReport({
1264
+ role,
1265
+ unitId,
1266
+ reason,
1267
+ summary: reason === "adapter_timeout"
1268
+ ? "Adapter timed out before producing a valid WorkerReportV1."
1269
+ : reason === "adapter_auth_unavailable"
1270
+ ? "Adapter appears to require authentication before it can produce WorkerReportV1."
1271
+ : "Adapter did not produce a valid WorkerReportV1 JSON object.",
1272
+ adapter: adapterMeta,
1273
+ guard,
1274
+ stdout: result.stdout,
1275
+ stderr: result.stderr || result.error?.message,
1276
+ }),
1277
+ null,
1278
+ 2,
1279
+ );
1280
+ }
1281
+
1282
+ const report = normalizeReport(extracted, { role, unitId, adapter: adapterMeta, guard });
1283
+ const validationErrors = validateWorkerReport(report, { role, unitId });
1284
+ if (result.status !== 0 && report.status === "PASS") validationErrors.push("PASS is invalid when adapter exits non-zero");
1285
+ if (guard.allowed_surface_violations.length > 0) validationErrors.push("worker changed surfaces outside allowed set");
1286
+ if (guard.role_violations.length > 0) validationErrors.push("worker violated role or forbidden-surface guard");
1287
+
1288
+ if (validationErrors.length > 0) {
1289
+ return JSON.stringify(
1290
+ blockedReport({
1291
+ role,
1292
+ unitId,
1293
+ reason: "report_validation_failed",
1294
+ summary: `Worker report rejected: ${validationErrors.join("; ")}`,
1295
+ adapter: adapterMeta,
1296
+ guard,
1297
+ stdout: result.stdout,
1298
+ stderr: result.stderr,
1299
+ }),
1300
+ null,
1301
+ 2,
1302
+ );
1303
+ }
1304
+
1305
+ return JSON.stringify(report, null, 2);
1306
+ }
1307
+
1308
+ function delegateDoctor(args) {
1309
+ if (args.agent === "all") {
1310
+ if (args["adapter-command"]) throw new Error("--adapter-command cannot be used with --agent all");
1311
+ const reports = [...DELEGATE_AGENTS].map((agent) => JSON.parse(delegateDoctor({ ...args, agent, "require-pass": false })));
1312
+ if (args["require-pass"] && reports.some((report) => report.status !== "PASS")) {
1313
+ process.exitCode = 1;
1314
+ }
1315
+ return JSON.stringify(
1316
+ reports,
1317
+ null,
1318
+ 2,
1319
+ );
1320
+ }
1321
+
1322
+ const adapter = resolveDelegateAdapter(args);
1323
+ const cwd = path.resolve(expandHome(args.cwd || process.cwd()));
1324
+ const available = commandAvailable(adapter.command[0]);
1325
+ const report = {
1326
+ agent: adapter.agent,
1327
+ command: displayCommand(adapter),
1328
+ prompt_mode: adapter.promptMode,
1329
+ source: adapter.source,
1330
+ schema_mode: adapter.schemaMode || null,
1331
+ executable_available: available,
1332
+ status: available ? "PASS" : "BLOCKED",
1333
+ note: available
1334
+ ? "Executable is present. Use --probe to run a trivial WorkerReportV1 delegation check."
1335
+ : `Executable was not found: ${adapter.command[0]}`,
1336
+ };
1337
+
1338
+ if (args.probe) {
1339
+ const probeResult = JSON.parse(
1340
+ delegate({
1341
+ ...args,
1342
+ role: "verifier",
1343
+ unit: "delegate-doctor",
1344
+ cwd,
1345
+ "allow-dirty": true,
1346
+ "dossier-text": "Delegate doctor probe. Return PASS if you can emit a valid WorkerReportV1 for this probe. Evidence may be a single item saying the adapter produced structured output.",
1347
+ }),
1348
+ );
1349
+ report.probe = {
1350
+ status: probeResult.status,
1351
+ reason: probeResult.reason || null,
1352
+ adapter: probeResult.adapter,
1353
+ guard: probeResult.guard,
1354
+ };
1355
+ report.status = probeResult.status === "PASS" ? "PASS" : "BLOCKED";
1356
+ }
1357
+
1358
+ if (args["require-pass"] && report.status !== "PASS") {
1359
+ process.exitCode = 1;
1360
+ }
1361
+
1362
+ return JSON.stringify(report, null, 2);
1363
+ }
1364
+
302
1365
  function writeManifest(target, data, dryRun) {
303
1366
  if (dryRun) return;
304
1367
  fs.mkdirSync(target, { recursive: true });
@@ -308,6 +1371,7 @@ function writeManifest(target, data, dryRun) {
308
1371
  function installOne(args, agent) {
309
1372
  const root = path.resolve(expandHome(args.root || packageRoot));
310
1373
  const scope = normalizeScope(args.scope || "user");
1374
+ const project = scope === "project" ? path.resolve(expandHome(args.project || process.cwd())) : null;
311
1375
  const target = resolveTarget(args, agent);
312
1376
  const names = selectSkills(root, args.skills || "all");
313
1377
  const dryRun = Boolean(args["dry-run"]);
@@ -321,6 +1385,7 @@ function installOne(args, agent) {
321
1385
  }
322
1386
 
323
1387
  if (!dryRun) fs.writeFileSync(path.join(target, "WORKFLOW_SKILL_PACK.md"), contextFor(agent, target, names));
1388
+ const workflowGitignore = project ? ensureWorkflowStateIgnored(project, dryRun) : null;
324
1389
  writeManifest(
325
1390
  target,
326
1391
  {
@@ -328,15 +1393,16 @@ function installOne(args, agent) {
328
1393
  version: PACKAGE_VERSION,
329
1394
  agent,
330
1395
  scope,
331
- project: scope === "project" ? path.resolve(expandHome(args.project || process.cwd())) : null,
1396
+ project,
332
1397
  target,
333
1398
  installedAt: new Date().toISOString(),
1399
+ workflowGitignore,
334
1400
  skills: installed,
335
1401
  },
336
1402
  dryRun
337
1403
  );
338
1404
 
339
- return { agent, target, skills: names, dryRun };
1405
+ return { agent, target, skills: names, dryRun, workflowGitignore };
340
1406
  }
341
1407
 
342
1408
  function install(args) {
@@ -405,6 +1471,11 @@ function printInstallResults(results, verb) {
405
1471
  const pastTense = verb === "remove" ? "Removed" : "Installed";
406
1472
  for (const result of results) {
407
1473
  console.log(`${result.dryRun ? `Would ${verb}` : pastTense} ${result.skills.length} skills for ${result.agent} at ${result.target}`);
1474
+ if (result.workflowGitignore) {
1475
+ const { file, entry, changed } = result.workflowGitignore;
1476
+ const action = result.dryRun && changed ? "Would add" : changed ? "Added" : "Already ignores";
1477
+ console.log(`${action} ${entry} in ${file}`);
1478
+ }
408
1479
  }
409
1480
  }
410
1481
 
@@ -425,6 +1496,10 @@ function main() {
425
1496
  console.log(`Validated ${names.length} skills: ${names.join(", ")}`);
426
1497
  return;
427
1498
  }
1499
+ if (command === "validate-dossier") {
1500
+ console.log(validateDossierCommand(args));
1501
+ return;
1502
+ }
428
1503
  if (command === "doctor") {
429
1504
  console.log(doctor(args));
430
1505
  return;
@@ -441,6 +1516,14 @@ function main() {
441
1516
  console.log(emitContext(args));
442
1517
  return;
443
1518
  }
1519
+ if (command === "delegate") {
1520
+ console.log(delegate(args));
1521
+ return;
1522
+ }
1523
+ if (command === "delegate-doctor") {
1524
+ console.log(delegateDoctor(args));
1525
+ return;
1526
+ }
444
1527
  throw new Error(`Unknown command: ${command}\n\n${usage()}`);
445
1528
  }
446
1529