cool-workflow 0.1.86 → 0.1.88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/README.md +111 -68
  4. package/apps/architecture-review/app.json +1 -1
  5. package/apps/architecture-review-fast/app.json +1 -1
  6. package/apps/end-to-end-golden-path/app.json +1 -1
  7. package/apps/pr-review-fix-ci/app.json +1 -1
  8. package/apps/release-cut/app.json +1 -1
  9. package/apps/research-synthesis/app.json +1 -1
  10. package/dist/agent-config.js +42 -1
  11. package/dist/capability-core.js +9 -4
  12. package/dist/capability-registry.js +48 -0
  13. package/dist/cli/command-surface.js +182 -11
  14. package/dist/cli.js +2 -1
  15. package/dist/doctor.js +27 -5
  16. package/dist/drive.js +222 -16
  17. package/dist/execution-backend.js +12 -4
  18. package/dist/loop-expansion.js +60 -0
  19. package/dist/onramp.js +25 -0
  20. package/dist/operator-ux/format.js +21 -15
  21. package/dist/operator-ux.js +2 -1
  22. package/dist/orchestrator/lifecycle-operations.js +134 -3
  23. package/dist/orchestrator.js +122 -76
  24. package/dist/run-export.js +106 -2
  25. package/dist/state-node.js +13 -3
  26. package/dist/state.js +21 -0
  27. package/dist/telemetry-attestation.js +30 -6
  28. package/dist/telemetry-demo.js +29 -1
  29. package/dist/telemetry-ledger.js +6 -0
  30. package/dist/term.js +93 -0
  31. package/dist/version.js +1 -1
  32. package/dist/worker-accept/telemetry-ledger.js +12 -2
  33. package/dist/workflow-api.js +33 -0
  34. package/dist/workflow-app-framework.js +20 -0
  35. package/docs/agent-delegation-drive.7.md +28 -6
  36. package/docs/capability-topology-registry.7.md +69 -46
  37. package/docs/cli-mcp-parity.7.md +22 -2
  38. package/docs/contract-migration-tooling.7.md +8 -0
  39. package/docs/control-plane-scheduling.7.md +8 -0
  40. package/docs/durable-state-and-locking.7.md +8 -0
  41. package/docs/evidence-adoption-reasoning-chain.7.md +8 -0
  42. package/docs/execution-backends.7.md +8 -0
  43. package/docs/launch/launch-kit.md +9 -9
  44. package/docs/multi-agent-cli-mcp-surface.7.md +8 -0
  45. package/docs/multi-agent-eval-replay-harness.7.md +8 -0
  46. package/docs/multi-agent-operator-ux.7.md +8 -0
  47. package/docs/node-snapshot-diff-replay.7.md +8 -0
  48. package/docs/observability-cost-accounting.7.md +8 -0
  49. package/docs/project-index.md +26 -5
  50. package/docs/readme-v0.1.87-full.md +301 -0
  51. package/docs/real-execution-backends.7.md +8 -0
  52. package/docs/release-and-migration.7.md +8 -0
  53. package/docs/release-history.md +10 -0
  54. package/docs/release-tooling.7.md +16 -0
  55. package/docs/report-verifiable-bundle.7.md +34 -2
  56. package/docs/run-registry-control-plane.7.md +18 -0
  57. package/docs/run-retention-reclamation.7.md +8 -0
  58. package/docs/state-explosion-management.7.md +8 -0
  59. package/docs/team-collaboration.7.md +8 -0
  60. package/docs/trust-model.md +6 -4
  61. package/docs/web-desktop-workbench.7.md +8 -0
  62. package/manifest/plugin.manifest.json +1 -1
  63. package/manifest/source-context-profiles.json +1 -1
  64. package/package.json +8 -5
  65. package/scripts/agents/agent-adapter-core.js +180 -0
  66. package/scripts/agents/builtin-templates.json +5 -1
  67. package/scripts/agents/claude-p-agent.js +7 -33
  68. package/scripts/agents/codex-agent.js +134 -0
  69. package/scripts/agents/cw-attest-wrap.js +9 -1
  70. package/scripts/agents/gemini-agent.js +115 -0
  71. package/scripts/agents/opencode-agent.js +119 -0
  72. package/scripts/canonical-apps.js +4 -4
  73. package/scripts/coverage-gate.js +15 -1
  74. package/scripts/cw.js +0 -0
  75. package/scripts/dogfood-release.js +1 -1
  76. package/scripts/golden-path.js +4 -4
  77. package/scripts/parity-check.js +6 -5
  78. package/scripts/release-check.js +3 -3
  79. package/scripts/release-flow.js +49 -2
  80. package/scripts/release-gate.sh +1 -1
  81. package/tsconfig.json +3 -1
@@ -27,6 +27,8 @@ const harness_1 = require("../harness");
27
27
  const workflow_app_framework_1 = require("../workflow-app-framework");
28
28
  const workflow_api_1 = require("../workflow-api");
29
29
  const observability_1 = require("../observability");
30
+ const compare_1 = require("../compare");
31
+ const loop_expansion_1 = require("../loop-expansion");
30
32
  const dispatch_1 = require("../dispatch");
31
33
  const verifier_1 = require("../verifier");
32
34
  const trust_audit_1 = require("../trust-audit");
@@ -53,7 +55,13 @@ function plan(appRecord, options) {
53
55
  inputs[declared.name] = declared.default ?? "";
54
56
  }
55
57
  const cwd = node_path_1.default.resolve(String(inputs.cwd || inputs.repo || process.cwd()));
56
- const runId = createRunId(workflow.id);
58
+ // A caller (e.g. an inline sub-workflow task) may inject a DETERMINISTIC run id so
59
+ // the child run id is reproducible across re-runs; otherwise mint one. `runId` is
60
+ // never a declared workflow input, so strip it from inputs to keep run.inputs (and
61
+ // the digests derived from it) clean — POLA for every normal plan.
62
+ const injectedRunId = typeof options.runId === "string" && options.runId.trim() ? options.runId.trim() : undefined;
63
+ delete inputs.runId;
64
+ const runId = injectedRunId || createRunId(workflow.id);
57
65
  const runDir = node_path_1.default.join(cwd, ".cw", "runs", runId);
58
66
  const paths = (0, state_1.createRunPaths)(runDir);
59
67
  (0, state_1.ensureRunDirs)(paths);
@@ -79,7 +87,10 @@ function plan(appRecord, options) {
79
87
  status: "pending",
80
88
  taskIds: phase.tasks.map((task) => task.id),
81
89
  // parallel() DSL: the drive loop reads this to size its concurrent round.
82
- ...(phase.mode ? { mode: phase.mode } : {})
90
+ ...(phase.mode ? { mode: phase.mode } : {}),
91
+ // loop() DSL: the ORIGIN phase carries the loop spec + round 1; the expander
92
+ // appends round-2+ phases after each round (loop-expansion / maybeExpandLoop).
93
+ ...(phase.loop ? { loop: phase.loop, loopRound: 1 } : {})
83
94
  })),
84
95
  tasks,
85
96
  dispatches: [],
@@ -323,6 +334,9 @@ function recordWorkerOutput(run, workerId, resultPath, options = {}) {
323
334
  }
324
335
  run.loopStage = "observe";
325
336
  (0, dispatch_1.updatePhaseStatuses)(run);
337
+ // Bounded dynamic loops: after a round's tasks complete, evaluate the predicate
338
+ // and either append the next round or mark the loop done (no-op for non-loop runs).
339
+ maybeExpandLoop(run);
326
340
  (0, verifier_1.validateRunGates)(run);
327
341
  (0, commit_1.commitState)(run, `worker:${workerId}:result`);
328
342
  (0, report_1.writeReport)(run);
@@ -410,6 +424,120 @@ function validateInputs(workflow, inputs) {
410
424
  }
411
425
  }
412
426
  }
427
+ /** Bounded dynamic loop expansion. After a worker result is recorded: if the just-
428
+ * completed phase is the LATEST round of a loop whose origin is not yet done, evaluate
429
+ * the registered predicate over the round's recorded results and either append the
430
+ * next round (clone the round-1 template tasks into a fresh phase, materialized like
431
+ * plan() does) or mark the loop done. One deterministic `loop-control` node is recorded
432
+ * per round boundary — the replay source of truth. No-op when the run has no loop
433
+ * phases (POLA). Expands at most ONE loop boundary per call; the next accept handles
434
+ * the next. Bounded: a loop never exceeds `maxRounds` (fail-closed); an unregistered
435
+ * predicate stops the loop rather than spinning. */
436
+ function maybeExpandLoop(run) {
437
+ for (const phase of [...run.phases]) {
438
+ const originId = phase.loop ? phase.id : phase.loopOrigin;
439
+ if (!originId)
440
+ continue;
441
+ const origin = run.phases.find((p) => p.id === originId);
442
+ if (!origin || !origin.loop || origin.loopDone)
443
+ continue;
444
+ // Act only from the LATEST round phase of this loop.
445
+ const loopPhases = run.phases.filter((p) => p.id === originId || p.loopOrigin === originId);
446
+ const latest = loopPhases.reduce((a, b) => ((b.loopRound || 1) >= (a.loopRound || 1) ? b : a));
447
+ if (phase.id !== latest.id)
448
+ continue;
449
+ const roundTasks = run.tasks.filter((t) => latest.taskIds.includes(t.id));
450
+ if (roundTasks.length === 0 || !roundTasks.every((t) => t.status === "completed"))
451
+ continue;
452
+ const round = latest.loopRound || 1;
453
+ const ordered = (tasks) => tasks.slice().sort((a, b) => (0, compare_1.compareBytes)(a.id, b.id)).map((t) => t.result);
454
+ const roundResults = ordered(roundTasks);
455
+ const allLoopTasks = run.tasks.filter((t) => t.status === "completed" && loopPhases.some((p) => p.taskIds.includes(t.id)));
456
+ const allResults = ordered(allLoopTasks);
457
+ const ctx = { round, roundResults, allResults, usageTotals: (0, observability_1.deriveUsageTotals)(run).totals, inputs: run.inputs };
458
+ const until = origin.loop.until;
459
+ let decision;
460
+ if (until.kind === "budget-target") {
461
+ // Budget-aware scaling: keep spawning rounds while RECORDED (attested-only) usage
462
+ // stays under the target. Composes with the fail-closed cap (limits.tokenBudget),
463
+ // which the drive enforces before each spawn and which remains the absolute
464
+ // backstop — whichever fires first wins, and the cap can never be overshot.
465
+ const spent = ctx.usageTotals.totalTokens;
466
+ decision = { done: spent >= until.target, reason: `budget-target: ${spent}/${until.target} recorded tokens` };
467
+ }
468
+ else {
469
+ const predicate = (0, loop_expansion_1.getLoopPredicate)(until.ref);
470
+ decision = predicate
471
+ ? predicate(ctx)
472
+ : { done: true, reason: `loop predicate "${until.ref}" not registered — stopping fail-closed` };
473
+ }
474
+ const atCap = round >= origin.loop.maxRounds;
475
+ const done = decision.done || atCap;
476
+ // Record the decision under a deterministic id (the replay source of truth).
477
+ (0, state_node_1.appendRunNode)(run, (0, state_node_1.createStateNode)({
478
+ id: `${run.id}:loop-control:${originId}:r${round}`,
479
+ kind: "loop-control",
480
+ status: "completed",
481
+ loopStage: "adjust",
482
+ outputs: { round, done, atCap, reason: decision.reason },
483
+ metadata: { originPhaseId: originId, until: until.kind === "predicate" ? until.ref : `budget-target:${until.target}`, round, done, atCap, reason: decision.reason }
484
+ }));
485
+ if (done) {
486
+ origin.loopDone = true;
487
+ return;
488
+ }
489
+ // Expand: clone the ROUND-1 template tasks into a fresh phase appended right after.
490
+ const nextRound = round + 1;
491
+ const nextPhaseName = `${origin.name} (round ${nextRound})`;
492
+ const templateTasks = run.tasks.filter((t) => origin.taskIds.includes(t.id));
493
+ const newTasks = templateTasks.map((t) => ({
494
+ id: `${t.id.replace(/@r\d+$/, "")}@r${nextRound}`,
495
+ kind: t.kind,
496
+ phase: nextPhaseName,
497
+ status: "pending",
498
+ requiresEvidence: t.requiresEvidence,
499
+ prompt: t.prompt,
500
+ taskPath: "",
501
+ resultPath: "",
502
+ loopStage: "interpret",
503
+ loopRound: nextRound,
504
+ ...(t.sandboxProfileId ? { sandboxProfileId: t.sandboxProfileId } : {}),
505
+ ...(t.label ? { label: t.label } : {}),
506
+ ...(t.model ? { model: t.model } : {}),
507
+ ...(t.agentType ? { agentType: t.agentType } : {}),
508
+ ...(t.schema ? { schema: t.schema } : {})
509
+ }));
510
+ const nextPhase = {
511
+ id: `${originId}@r${nextRound}`,
512
+ name: nextPhaseName,
513
+ status: "pending",
514
+ taskIds: newTasks.map((t) => t.id),
515
+ loopOrigin: originId,
516
+ loopRound: nextRound,
517
+ ...(origin.mode ? { mode: origin.mode } : {})
518
+ };
519
+ const insertAt = run.phases.findIndex((p) => p.id === latest.id);
520
+ run.phases.splice(insertAt + 1, 0, nextPhase);
521
+ run.tasks.push(...newTasks);
522
+ // Materialize: task files + a plan-stage contract node per new task (mirrors plan()).
523
+ (0, harness_1.writeTaskFiles)(run);
524
+ const contractId = run.contracts && run.contracts[0] ? run.contracts[0].id : undefined;
525
+ const inputNodeId = `${run.id}:input`;
526
+ const pipeline = (0, pipeline_runner_1.createPipelineRunner)({ contractId, persist: false });
527
+ for (const t of newTasks) {
528
+ const result = pipeline.runPipelineStage(run, "plan", inputNodeId, {
529
+ outputNodeId: `${run.id}:task:${t.id}`,
530
+ outputStatus: "pending",
531
+ loopStage: "interpret",
532
+ artifacts: [{ id: "task", kind: "markdown", path: t.taskPath }],
533
+ metadata: { workflowId: run.workflow.id, taskId: t.id, phase: t.phase, taskKind: t.kind, requiresEvidence: t.requiresEvidence, sandboxProfileId: t.sandboxProfileId }
534
+ });
535
+ t.stateNodeId = result.outputNodeId;
536
+ }
537
+ (0, dispatch_1.updatePhaseStatuses)(run);
538
+ return;
539
+ }
540
+ }
413
541
  function flattenTasks(workflow, inputs) {
414
542
  const seen = new Set();
415
543
  const tasks = [];
@@ -437,7 +565,10 @@ function flattenTasks(workflow, inputs) {
437
565
  ...(task.label ? { label: task.label } : {}),
438
566
  ...(task.model ? { model: task.model } : {}),
439
567
  ...(task.agentType ? { agentType: task.agentType } : {}),
440
- ...(task.resultCache ? { resultCache: task.resultCache } : {})
568
+ ...(task.resultCache ? { resultCache: task.resultCache } : {}),
569
+ ...(task.subWorkflow ? { subWorkflow: task.subWorkflow } : {}),
570
+ // A loop phase's tasks are round 1 of the loop; the expander clones them.
571
+ ...(phase.loop ? { loopRound: 1 } : {})
441
572
  });
442
573
  }
443
574
  }
@@ -36,8 +36,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.CoolWorkflowRunner = void 0;
39
+ exports.KNOWN_COMMANDS = exports.CoolWorkflowRunner = void 0;
40
40
  exports.parseArgv = parseArgv;
41
+ exports.suggestCommand = suggestCommand;
42
+ exports.formatSearchResults = formatSearchResults;
43
+ exports.formatInfo = formatInfo;
41
44
  exports.formatHelp = formatHelp;
42
45
  const node_fs_1 = __importDefault(require("node:fs"));
43
46
  const node_path_1 = __importDefault(require("node:path"));
@@ -68,6 +71,7 @@ const feedbackOps = __importStar(require("./orchestrator/feedback-operations"));
68
71
  const topologyOps = __importStar(require("./orchestrator/topology-operations"));
69
72
  const lifecycleOps = __importStar(require("./orchestrator/lifecycle-operations"));
70
73
  const migrationOps = __importStar(require("./orchestrator/migration-operations"));
74
+ const term_1 = require("./term");
71
75
  // CoolWorkflowRunner — the single FACADE both surfaces (cli.ts and the MCP server)
72
76
  // call through. It is deliberately WIDE but THIN: each method either
73
77
  // (a) loads the run's durable state and delegates to a domain function in
@@ -796,10 +800,20 @@ function parseArgv(argv) {
796
800
  positionals.push(rest[restIndex]);
797
801
  break;
798
802
  }
799
- if (!token.startsWith("--")) {
803
+ if (!token.startsWith("-")) {
800
804
  positionals.push(token);
801
805
  continue;
802
806
  }
807
+ if (!token.startsWith("--")) {
808
+ // Single-dash short flag aliases: -q → question, -r → repo, -a → agent-command, -h → help, -v → version
809
+ const shortMap = { q: "question", r: "repo", a: "agent-command", h: "help", v: "version" };
810
+ const flag = token.slice(1);
811
+ // Handle combined short flags like -qr (not common but safe to ignore)
812
+ const key = shortMap[flag] || flag;
813
+ const val = rest[index + 1] && !rest[index + 1].startsWith("-") ? rest[++index] : true;
814
+ appendOption(options, key, val);
815
+ continue;
816
+ }
803
817
  const withoutPrefix = token.slice(2);
804
818
  const equalsIndex = withoutPrefix.indexOf("=");
805
819
  let key;
@@ -816,84 +830,116 @@ function parseArgv(argv) {
816
830
  }
817
831
  return { command, positionals, options };
818
832
  }
833
+ /** All known top-level CW commands. Used for "did you mean?" suggestions. */
834
+ exports.KNOWN_COMMANDS = new Set([
835
+ "help", "list", "doctor", "info", "search", "man", "init", "quickstart", "plan", "status", "next",
836
+ "dispatch", "result", "state", "commit", "report", "app", "sandbox",
837
+ "backend", "contract", "node", "feedback", "worker", "audit", "candidate",
838
+ "review", "loop", "schedule", "routine", "registry", "run", "queue",
839
+ "history", "audit-run", "multi-agent", "topology", "summary", "blackboard",
840
+ "coordinator", "metrics", "operator", "sched", "gc", "telemetry",
841
+ "migration", "demo", "workbench", "approve", "reject", "comment", "handoff",
842
+ "graph", "eval", "version", "update", "fix"
843
+ ]);
844
+ /** Levenshtein distance between two short strings. */
845
+ function levenshtein(a, b) {
846
+ const m = a.length;
847
+ const n = b.length;
848
+ let prev = Array.from({ length: n + 1 }, (_, j) => j);
849
+ let curr = new Array(n + 1);
850
+ for (let i = 1; i <= m; i++) {
851
+ curr[0] = i;
852
+ for (let j = 1; j <= n; j++) {
853
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
854
+ }
855
+ [prev, curr] = [curr, prev];
856
+ }
857
+ return prev[n];
858
+ }
859
+ /** Suggest the closest known command for a typo. Returns undefined if no match
860
+ * within half the length of the input (avoiding wild guesses on short strings). */
861
+ function suggestCommand(input) {
862
+ if (!input || input.length < 2)
863
+ return undefined;
864
+ const lower = input.toLowerCase();
865
+ let best = "";
866
+ let bestDist = Infinity;
867
+ for (const cmd of exports.KNOWN_COMMANDS) {
868
+ const dist = levenshtein(lower, cmd);
869
+ if (dist < bestDist) {
870
+ best = cmd;
871
+ bestDist = dist;
872
+ }
873
+ }
874
+ // Threshold: distance must be <= half the input length AND <= 3
875
+ if (bestDist <= 3 && bestDist <= lower.length / 2)
876
+ return best;
877
+ return undefined;
878
+ }
879
+ function formatSearchResults(keyword, results) {
880
+ if (!results.length)
881
+ return `No workflows matched "${keyword}".\n Tip: cw list for all available workflows.`;
882
+ return [
883
+ (0, term_1.bold)(`${results.length} workflow${results.length !== 1 ? "s" : ""} matching "${keyword}"`),
884
+ ...results.map((r) => ` ${r.id} — ${r.title}\n ${(0, term_1.dim)(r.summary.slice(0, 120))}${r.summary.length > 120 ? "…" : ""}`),
885
+ "",
886
+ (0, term_1.dim)("Use cw info <id> for full details.")
887
+ ].join("\n");
888
+ }
889
+ function formatInfo(appId, data) {
890
+ const app = (data.app || {});
891
+ const inputs = (Array.isArray(data.inputs) ? data.inputs : []);
892
+ const phases = (Array.isArray(data.phases) ? data.phases : []);
893
+ const lines = [(0, term_1.bold)(`cw info ${appId}`)];
894
+ if (data.title)
895
+ lines.push(` Title: ${data.title}`);
896
+ if (data.version)
897
+ lines.push(` Version: ${data.version}`);
898
+ if (data.summary)
899
+ lines.push(` Summary: ${data.summary}`);
900
+ if (data.author)
901
+ lines.push(` Author: ${typeof data.author === "object" ? data.author.name : data.author}`);
902
+ if (data.compatible !== undefined)
903
+ lines.push(` Compatible: ${data.compatible ? "yes" : "no"}`);
904
+ if (inputs.length > 0) {
905
+ lines.push(" Inputs:");
906
+ for (const input of inputs) {
907
+ const name = input.name || "";
908
+ const type = input.type || "string";
909
+ const required = input.required ? ", required" : "";
910
+ const def = input.default ? `, default: ${input.default}` : "";
911
+ const desc = input.description ? ` — ${input.description}` : "";
912
+ lines.push(` - ${name} (${type}${required}${def})${desc}`);
913
+ }
914
+ }
915
+ if (Array.isArray(data.sandboxProfiles) && data.sandboxProfiles.length > 0) {
916
+ lines.push(` Sandbox: ${data.sandboxProfiles.join(", ")}`);
917
+ }
918
+ const taskCount = data.taskCount || 0;
919
+ if (phases.length > 0) {
920
+ lines.push(` Phases: ${phases.length} phase${phases.length !== 1 ? "s" : ""}, ${taskCount} task${taskCount !== 1 ? "s" : ""}`);
921
+ }
922
+ lines.push(` Run: cw quickstart ${appId} --repo . --question "..."`);
923
+ return lines.join("\n");
924
+ }
819
925
  function formatHelp() {
820
926
  return [
821
- "Cool Workflow",
927
+ (0, term_1.bold)("Cool Workflow"),
928
+ "",
929
+ " -q \"question\" [-claude|-codex|-deepseek] Ask a question, get a report",
930
+ " version Show version",
931
+ " update Update to latest release",
932
+ " doctor Check setup",
933
+ " fix Show fix commands for setup issues",
822
934
  "",
823
- "Quick start (ONE command — plan -> drive -> report):",
824
- " quickstart [architecture-review] --repo PATH --question TEXT --agent-command \"claude -p\"",
825
- " (delegates each worker to YOUR configured agent backend; --preview for a dry run)",
935
+ (0, term_1.bold)("Flags"),
936
+ " -q, --question TEXT The task or question to answer",
937
+ " -r, --repo PATH Target repository path (default: .)",
938
+ " -claude Use Claude agent",
939
+ " -codex Use Codex agent",
940
+ " -deepseek Use DeepSeek (via opencode)",
826
941
  "",
827
- "Commands:",
828
- " list",
829
- " doctor [--json] [--onramp] [--changed-from REF] (check setup and show the shortest safe next steps)",
830
- " init <workflow-id> [--title TEXT] [--output PATH]",
831
- " quickstart [app-id] [--repo PATH] [--question TEXT] [--agent-command CMD] [--check] [--once] [--preview]",
832
- " plan <workflow-id> [--repo PATH] [--question TEXT] [--invariant TEXT]",
833
- " status <run-id> [--json|--format json]",
834
- " next <run-id> [--limit N]",
835
- " graph <run-id> [--json]",
836
- " dispatch <run-id> [--limit N] [--sandbox PROFILE] [--backend node|bun|shell|container|remote|ci]",
837
- " result <run-id> <task-id> <result-file>",
838
- " state check <run-id> [--state PATH] [--write]",
839
- " commit <run-id> --verifier <node-id> [--reason TEXT]",
840
- " commit <run-id> --candidate <candidate-id> [--reason TEXT]",
841
- " commit <run-id> --selection <selection-id> [--reason TEXT]",
842
- " commit <run-id> --allow-unverified-checkpoint [--reason TEXT]",
843
- " commit summary <run-id> [--json]",
844
- " report <run-id> [--show|--summary]",
845
- " app list|show|validate|init|package",
846
- " sandbox list|show|validate",
847
- " backend list|show|probe [backend-id]",
848
- " contract show <run-id> [contract-id]",
849
- " node list|show|graph <run-id>",
850
- " feedback list|summary|show|collect|task|resolve <run-id>",
851
- " worker list|summary|show|manifest|output|fail|validate <run-id>",
852
- " audit summary <run-id>",
853
- " audit worker <run-id> <worker-id>",
854
- " audit provenance <run-id> [--worker ID|--candidate ID|--commit ID]",
855
- " audit multi-agent <run-id> [--json]",
856
- " audit policy <run-id> [--json]",
857
- " audit role <run-id> <role-id> [--json]",
858
- " audit blackboard <run-id> [--json]",
859
- " audit judge <run-id> [--json]",
860
- " audit attest <run-id> [--worker ID] [--hostEnforced true] [--env NAME]",
861
- " audit decision <run-id> <worker-id> [--path PATH|--command CMD|--network TARGET|--env NAME]",
862
- " candidate list|summary|register|score|rank|select|reject <run-id>",
863
- " eval snapshot|replay|compare|score|gate|report",
864
- " summary refresh|show <run-id> [--json]",
865
- " blackboard summary|summarize|graph|resolve <run-id>",
866
- " blackboard topic create <run-id> --id <topic-id> --title TEXT",
867
- " blackboard message post|list <run-id>",
868
- " blackboard context put <run-id>",
869
- " blackboard artifact add|list <run-id>",
870
- " blackboard snapshot <run-id>",
871
- " coordinator summary <run-id>",
872
- " coordinator decision <run-id> --kind KIND --outcome OUTCOME --reason TEXT",
873
- " multi-agent run|status|step|blackboard|score|select|summary|summarize|graph|dependencies|failures|evidence <run-id>",
874
- " multi-agent graph <run-id> --view full|compact|critical-path|failures|evidence|trust|topology|blackboard|candidate|commit-gate [--focus ID] [--depth N]",
875
- " topology list|show|validate|apply|summary|graph",
876
- " schedule create|list|due|complete|pause|resume|run-now|history|daemon|delete",
877
- " routine create|fire|list|events|delete",
878
- " registry refresh|show [--scope repo|home] [--json]",
879
- " run search|list|show|resume|archive|rerun|export|import|verify-import [run-id|archive] [--scope repo|home] [--json]",
880
- " queue add|list|drain|show [queue-id] [--repo PATH] [--priority N]",
881
- " history [--scope repo|home] [--app ID] [--status STATE] [--json]",
882
- " audit-run <app-id> [--repo PATH] [--question TEXT] [--agent-command CMD]",
883
- " metrics show|summary <run-id> [--scope repo|home] [--json]",
884
- " telemetry verify <run-id> [--pubkey PEM|PATH] [--json]",
885
- " gc plan|run|verify [run-id] [--json]",
886
- " sched plan|lease|release|complete|reclaim|reset|policy [--json]",
887
- " migration list|check|prove [target] [--json]",
888
- " operator status|report <run-id> [--json]",
889
- " review status|policy <run-id> [--json]",
890
- " approve|reject|comment <kind> <run-id> <target-id> [--reason TEXT]",
891
- " handoff <kind> <run-id> <target-id> [--to ROLE]",
892
- " loop --prompt TEXT [--interval-minutes N]",
893
- " demo tamper",
894
- " workbench view <run-id> [--json]",
895
- " workbench serve [--port N] [--scope repo|home] [--once|--json]",
896
- ""
942
+ " list|search|info|init|plan|status|next|dispatch|result|state|commit|report|app|sandbox|backend|contract|node|feedback|worker|audit|candidate|review|loop|schedule|routine|registry|run|queue|history|quickstart|audit-run|multi-agent|topology|summary|blackboard|coordinator|metrics|operator|sched|gc|telemetry|migration|demo|workbench|approve|reject|comment|handoff|graph|eval|man|version|update|fix More commands"
897
943
  ].join("\n");
898
944
  }
899
945
  function appendOption(options, key, value) {
@@ -26,6 +26,7 @@ const version_1 = require("./version");
26
26
  const telemetry_ledger_1 = require("./telemetry-ledger");
27
27
  const telemetry_attestation_1 = require("./telemetry-attestation");
28
28
  const trust_audit_1 = require("./trust-audit");
29
+ const execution_backend_1 = require("./execution-backend");
29
30
  const compare_1 = require("./compare");
30
31
  /** Export a run to a portable JSON archive with run-local bytes and digests. */
31
32
  function exportRun(run, outputPath, options = {}) {
@@ -84,9 +85,21 @@ function importRun(exportPath, targetDir) {
84
85
  const archiveSha256 = sha256Bytes(node_fs_1.default.readFileSync(exportPath));
85
86
  const files = normalizeArchiveFiles(raw);
86
87
  verifyArchiveFileDigests(files, raw.integrity);
88
+ if (!raw.run || typeof raw.run !== "object") {
89
+ throw new Error("Invalid run export: missing run object");
90
+ }
91
+ // The run id from the archive becomes a directory name under the target's
92
+ // runs root; a crafted id like "../../etc" would otherwise escape it. Refuse
93
+ // any id that is not a single safe path segment, then assert containment as
94
+ // defense-in-depth (catches a symlinked runs root too) before any write.
95
+ const runId = (0, state_1.assertSafeRunId)(raw.run.id);
96
+ const runsRoot = node_path_1.default.join(targetDir, ".cw", "runs");
97
+ const runDir = node_path_1.default.join(runsRoot, runId);
98
+ if (!(0, state_1.isContainedPath)(runDir, runsRoot)) {
99
+ throw new Error(`Run id escapes the runs directory: ${JSON.stringify(raw.run.id)}`);
100
+ }
87
101
  const oldRunDir = raw.run.paths.runDir;
88
102
  const oldCwd = raw.run.cwd;
89
- const runDir = node_path_1.default.join(targetDir, ".cw", "runs", raw.run.id);
90
103
  const paths = (0, state_1.createRunPaths)(runDir);
91
104
  (0, state_1.ensureRunDirs)(paths);
92
105
  for (const file of files) {
@@ -294,6 +307,28 @@ function importManifestPath(run) {
294
307
  *
295
308
  * Key precedence is bundle > argument > environment, so the artifact verifies the
296
309
  * same on any machine; only when the bundle omits a key do the override/env apply. */
310
+ /** True when report.md embeds `expected` (the trimmed result) at the task's OWN
311
+ * section, exactly as orchestrator/report.ts renderResults emits it: a
312
+ * `### <taskId>` heading, a `Result: <path>` line, then the result body — and the
313
+ * body STARTS WITH `expected`. Anchoring to the section (not a whole-file substring)
314
+ * means a decoy copy buried elsewhere does not satisfy it; matching from the heading
315
+ * forward (rather than to the next heading) means a result body that itself contains
316
+ * `###` cannot break the bound. The `Result:` path is matched loosely since it is
317
+ * host-specific (rebased on import). */
318
+ function reportSectionEmbedsResult(reportMd, taskId, expected) {
319
+ const needle = `### ${taskId}\n`;
320
+ // Walk EVERY `### <taskId>` occurrence — not just the first — so a stray heading
321
+ // inside an earlier task's result body (which is not followed by the `Result:`
322
+ // structure) is skipped rather than mis-anchoring the check (a false positive on a
323
+ // legitimate, fully-signed bundle whose findings contain markdown headings).
324
+ for (let from = reportMd.indexOf(needle); from >= 0; from = reportMd.indexOf(needle, from + 1)) {
325
+ const after = reportMd.slice(from);
326
+ const prefix = after.match(/^### [^\n]*\n\nResult: [^\n]*\n\n/);
327
+ if (prefix && after.slice(prefix[0].length).startsWith(expected))
328
+ return true;
329
+ }
330
+ return false;
331
+ }
297
332
  function verifyReportBundle(archivePath, options = {}) {
298
333
  const inspect = inspectArchive(archivePath);
299
334
  const failedChecks = inspect.checks
@@ -312,6 +347,8 @@ function verifyReportBundle(archivePath, options = {}) {
312
347
  signaturesChecked: 0,
313
348
  signaturesReverified: 0,
314
349
  signaturesFailed: 0,
350
+ trustLevel: "unsigned",
351
+ reportFindingsVerified: false,
315
352
  failedChecks
316
353
  };
317
354
  // A bundle that is not even a supported archive cannot be restored — report the
@@ -348,6 +385,8 @@ function verifyReportBundle(archivePath, options = {}) {
348
385
  let signaturesChecked = 0;
349
386
  let signaturesReverified = 0;
350
387
  let signaturesFailed = 0;
388
+ let signaturesResultBound = 0;
389
+ let reportFindingsOk = true;
351
390
  let reportExtractedTo;
352
391
  try {
353
392
  // Restore into the throwaway tree. importRun digest-checks every file and throws
@@ -370,9 +409,48 @@ function verifyReportBundle(archivePath, options = {}) {
370
409
  signaturesChecked = sig.checked;
371
410
  signaturesReverified = sig.reverified;
372
411
  signaturesFailed = sig.failed;
412
+ signaturesResultBound = sig.resultBound.length;
373
413
  for (const check of sig.checks)
374
414
  if (!check.pass)
375
415
  failedChecks.push({ name: check.name, code: check.code });
416
+ // Report ⇄ result ⇄ signature cross-check — three links so the agent's findings
417
+ // cannot be altered undetected on a signed bundle. CRUCIALLY this is driven by
418
+ // sig.resultBound (the records whose signature actually COVERED the result
419
+ // digest), NOT the run.tasks list — run.tasks is in the archive but bound by
420
+ // nothing (not the manifest digest, the chain, or the signature), so iterating it
421
+ // would let an attacker silence the check by dropping/un-completing a task. The
422
+ // obligation comes from the chained+signed ledger record instead:
423
+ // 1. each bound record's resultDigest is anchored by the executor signature
424
+ // (coversResult re-verified above) — a 4-field signature is excluded, so an
425
+ // injected resultDigest is never trusted;
426
+ // 2. the matching completed task's RESTORED result file must hash to that signed
427
+ // digest (a missing/un-completed task or edited/empty result is a forgery);
428
+ // 3. report.md must embed that result at the task's own `### <taskId>` section.
429
+ // Editing the report breaks link 3; editing the result breaks link 2; editing both
430
+ // to one consistent lie still breaks link 2 (the signed digest does not move);
431
+ // dropping the task fails link 2 (the signed obligation remains).
432
+ const reportPath = imported.run.paths.report;
433
+ const reportMd = reportPath && node_fs_1.default.existsSync(reportPath) ? node_fs_1.default.readFileSync(reportPath, "utf8") : "";
434
+ const completedById = new Map(imported.run.tasks.filter((task) => task.status === "completed").map((task) => [task.id, task]));
435
+ for (const bound of sig.resultBound) {
436
+ const failBound = (code) => {
437
+ reportFindingsOk = false;
438
+ failedChecks.push({ name: "report-findings", code: `${code}:${bound.taskId}` });
439
+ };
440
+ const task = completedById.get(bound.taskId);
441
+ if (!task || !task.resultPath || !node_fs_1.default.existsSync(task.resultPath)) {
442
+ failBound("result-missing");
443
+ continue;
444
+ }
445
+ const resultRaw = node_fs_1.default.readFileSync(task.resultPath, "utf8");
446
+ if ((0, execution_backend_1.sha256)(resultRaw) !== bound.resultDigest) {
447
+ failBound("result-digest-mismatch");
448
+ continue;
449
+ }
450
+ if (!reportSectionEmbedsResult(reportMd, bound.taskId, resultRaw.trim())) {
451
+ failBound("report-result-mismatch");
452
+ }
453
+ }
376
454
  if (options.extractReportTo && reportContent !== undefined) {
377
455
  reportExtractedTo = node_path_1.default.resolve(options.extractReportTo);
378
456
  node_fs_1.default.writeFileSync(reportExtractedTo, reportContent);
@@ -389,6 +467,23 @@ function verifyReportBundle(archivePath, options = {}) {
389
467
  const strictShortfall = Boolean(options.strictSignatures) && signaturesChecked > 0 && !trustKey;
390
468
  if (strictShortfall)
391
469
  failedChecks.push({ name: "signatures", code: "signature-key-required" });
470
+ // Trust level + requireSignatures — closes the prior fail-open. "signed" means the
471
+ // agent's SIGNED findings are present and unaltered: at least one result-COVERING
472
+ // signature re-verified against a key, none failed, and each signed result is
473
+ // faithfully embedded in report.md (the forward cross-check held). A usage-only
474
+ // (4-field) signature, an unverifiable one (no key), or a tampered signed finding
475
+ // all yield "unsigned". requireSignatures refuses "unsigned".
476
+ //
477
+ // SCOPE (honest): "signed" attests the SIGNED findings, NOT that the report is
478
+ // exhaustively signed. CW holds no key to sign the rendered report and the ledger
479
+ // chain is self-recomputable, so the report MAY carry additional unsigned content
480
+ // (prose, or extra sections), and a determined re-chainer can OMIT a signed
481
+ // finding. Verify findings against the signed results; full report-completeness
482
+ // needs an external anchor. See report-verifiable-bundle.7.md / trust-model.md.
483
+ const trustLevel = signaturesResultBound > 0 && signaturesFailed === 0 && reportFindingsOk ? "signed" : "unsigned";
484
+ const unsignedShortfall = Boolean(options.requireSignatures) && trustLevel === "unsigned";
485
+ if (unsignedShortfall)
486
+ failedChecks.push({ name: "signatures", code: "signatures-required" });
392
487
  // Extraction was requested but could not be fulfilled (no report.md in the bundle,
393
488
  // or the write failed): fail closed rather than silently green a missing artifact —
394
489
  // otherwise `report bundle <run> --extract-report r.md && send r.md` would ship
@@ -401,7 +496,14 @@ function verifyReportBundle(archivePath, options = {}) {
401
496
  schemaVersion: 1,
402
497
  archivePath,
403
498
  runId: inspect.runId,
404
- ok: inspect.ok && telemetryVerified && trustAuditVerified && signaturesFailed === 0 && !strictShortfall && !extractShortfall,
499
+ ok: inspect.ok &&
500
+ telemetryVerified &&
501
+ trustAuditVerified &&
502
+ signaturesFailed === 0 &&
503
+ reportFindingsOk &&
504
+ !strictShortfall &&
505
+ !extractShortfall &&
506
+ !unsignedShortfall,
405
507
  archiveOk: inspect.ok,
406
508
  telemetryVerified,
407
509
  trustAuditVerified,
@@ -410,6 +512,8 @@ function verifyReportBundle(archivePath, options = {}) {
410
512
  signaturesChecked,
411
513
  signaturesReverified,
412
514
  signaturesFailed,
515
+ trustLevel,
516
+ reportFindingsVerified: reportFindingsOk,
413
517
  reportExtractedTo,
414
518
  failedChecks
415
519
  };
@@ -164,10 +164,20 @@ function linkStateNodes(parent, child) {
164
164
  ];
165
165
  }
166
166
  function appendRunNode(run, node) {
167
- const nodes = run.nodes || [];
167
+ // Mutate run.nodes in place rather than rebuilding the whole array on every
168
+ // append. The old code did `nodes.map(...)` / `[...nodes, node]` per call, so a
169
+ // run that appends N nodes allocated arrays of size 1..N — O(N^2) churn + GC on
170
+ // a hot path (every dispatch/result/blackboard node). In place: O(1) per append
171
+ // for the common new-node case, and the resulting array — content AND order — is
172
+ // byte-identical to before (push appends at the end, replace keeps the slot), so
173
+ // the persisted state.json is unchanged. appendRunNode is the only writer of
174
+ // run.nodes, so the array reference stays stable across a run.
175
+ const nodes = run.nodes || (run.nodes = []);
168
176
  const index = nodes.findIndex((candidate) => candidate.id === node.id);
169
- const nextNodes = index >= 0 ? nodes.map((candidate) => (candidate.id === node.id ? node : candidate)) : [...nodes, node];
170
- run.nodes = nextNodes;
177
+ if (index >= 0)
178
+ nodes[index] = node;
179
+ else
180
+ nodes.push(node);
171
181
  writeRunNode(run, node);
172
182
  return node;
173
183
  }
package/dist/state.js CHANGED
@@ -19,6 +19,7 @@ exports.realResolve = realResolve;
19
19
  exports.isContainedPath = isContainedPath;
20
20
  exports.withFileLock = withFileLock;
21
21
  exports.safeFileName = safeFileName;
22
+ exports.assertSafeRunId = assertSafeRunId;
22
23
  exports.hashArtifactFile = hashArtifactFile;
23
24
  const node_fs_1 = __importDefault(require("node:fs"));
24
25
  const node_path_1 = __importDefault(require("node:path"));
@@ -293,6 +294,26 @@ function withFileLock(targetPath, fn) {
293
294
  function safeFileName(value) {
294
295
  return String(value).replace(/[^a-zA-Z0-9_.:-]+/g, "_");
295
296
  }
297
+ /** Refuse a run id that is not a single safe path segment, so an id taken from
298
+ * an untrusted source (an imported run archive / bundle) can never escape the
299
+ * runs directory via a separator or a `..`/`.` component (path traversal). A
300
+ * real run id is `${workflowId}-${stamp}-${suffix}` (createRunId), and a
301
+ * workflow id is alnum-bounded [a-z0-9.-] (validateWorkflowId) — all within
302
+ * [A-Za-z0-9._-]. Because the charset already forbids any separator, the whole
303
+ * id is ONE path component, so the only values that could traverse are the
304
+ * components `.` and `..` themselves; an embedded `..` (e.g. a workflow id like
305
+ * `v1..2`) is a safe directory name and is allowed. A separator, an absolute
306
+ * path, or an empty value is refused, fail-closed. Returns the id on success so
307
+ * callers can use it inline. */
308
+ function assertSafeRunId(value, context = "run id") {
309
+ if (typeof value !== "string" || value.length === 0) {
310
+ throw new Error(`Invalid ${context}: expected a non-empty string`);
311
+ }
312
+ if (!/^[A-Za-z0-9._-]+$/.test(value) || value === "." || value === "..") {
313
+ throw new Error(`Unsafe ${context}: ${JSON.stringify(value)} must be a single path segment ([A-Za-z0-9._-], not '.' or '..')`);
314
+ }
315
+ return value;
316
+ }
296
317
  /** Compute and set SHA256 + sizeBytes on a StateArtifact from its file path
297
318
  * (v0.1.73). Fails silently when the file doesn't exist — does not throw. */
298
319
  function hashArtifactFile(artifact) {