@kontourai/flow-agents 3.8.0 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/.github/workflows/ci.yml +8 -0
  2. package/CHANGELOG.md +24 -0
  3. package/README.md +1 -1
  4. package/build/src/builder-flow-runtime.js +16 -10
  5. package/build/src/cli/assignment-provider.js +4 -18
  6. package/build/src/cli/continuation-adapter.d.ts +26 -0
  7. package/build/src/cli/continuation-adapter.js +243 -0
  8. package/build/src/cli/validate-workflow-artifacts.js +21 -1
  9. package/build/src/cli/workflow-sidecar.js +15 -20
  10. package/build/src/cli/workflow.js +99 -6
  11. package/build/src/continuation-driver.d.ts +116 -0
  12. package/build/src/continuation-driver.js +527 -0
  13. package/build/src/index.d.ts +2 -0
  14. package/build/src/index.js +1 -0
  15. package/build/src/tools/build-universal-bundles.js +52 -25
  16. package/build/src/tools/validate-source-tree.js +5 -2
  17. package/context/scripts/hooks/lib/continuation-turn-authority.js +386 -0
  18. package/context/scripts/hooks/stop-goal-fit.js +244 -73
  19. package/context/scripts/hooks/workflow-steering.js +24 -15
  20. package/context/scripts/telemetry/lib/pricing.sh +18 -6
  21. package/context/scripts/telemetry/lib/transport.sh +139 -37
  22. package/context/scripts/telemetry/lib/usage.sh +282 -32
  23. package/context/scripts/telemetry/lib/usage_model_guard.sh +42 -0
  24. package/context/scripts/telemetry/telemetry.sh +76 -1
  25. package/docs/coordination-guide.md +21 -5
  26. package/docs/fixture-ownership.md +2 -1
  27. package/docs/public-workflow-cli.md +98 -0
  28. package/docs/spec/runtime-hook-surface.md +8 -4
  29. package/docs/workflow-usage-guide.md +7 -0
  30. package/evals/acceptance/prove-capture-teeth-declared.sh +13 -0
  31. package/evals/acceptance/prove-capture-teeth.sh +13 -0
  32. package/evals/ci/run-baseline.sh +2 -0
  33. package/evals/fixtures/codex-legacy-agents/5273878130bdafc8a024a650bb5b66c9b003f1f859b5dc6e5b588cbf4ab23228.md +25 -0
  34. package/evals/fixtures/telemetry/usage-transcript-adversarial.jsonl +2 -0
  35. package/evals/fixtures/telemetry/usage-transcript-malformed-sandwich.jsonl +3 -0
  36. package/evals/fixtures/telemetry/usage-transcript-negative-tokens.jsonl +1 -0
  37. package/evals/fixtures/telemetry/usage-transcript-oversized-prefix.jsonl +201 -0
  38. package/evals/fixtures/telemetry/usage-transcript-oversized-tokens.jsonl +1 -0
  39. package/evals/fixtures/telemetry/usage-transcript-prefixed-secret-lower.jsonl +1 -0
  40. package/evals/fixtures/telemetry/usage-transcript-prefixed-secret-upper.jsonl +1 -0
  41. package/evals/fixtures/telemetry/usage-transcript-secret-model-jwt.jsonl +1 -0
  42. package/evals/fixtures/telemetry/usage-transcript-secret-model.jsonl +1 -0
  43. package/evals/fixtures/telemetry/usage-transcript-torn-trailing.jsonl +2 -0
  44. package/evals/integration/test_actor_identity.sh +63 -0
  45. package/evals/integration/test_assignment_provider_local_file.sh +34 -0
  46. package/evals/integration/test_builder_entry_enforcement.sh +5 -1
  47. package/evals/integration/test_bundle_install.sh +106 -17
  48. package/evals/integration/test_bundle_lifecycle.sh +17 -3
  49. package/evals/integration/test_captured_fail_reconciliation.sh +11 -0
  50. package/evals/integration/test_ci_actor_identity.sh +2 -2
  51. package/evals/integration/test_command_log_concurrency.sh +11 -0
  52. package/evals/integration/test_command_log_fork_classification.sh +11 -0
  53. package/evals/integration/test_command_log_integrity.sh +11 -0
  54. package/evals/integration/test_current_json_per_actor.sh +416 -21
  55. package/evals/integration/test_enforcer_expects_driven.sh +11 -0
  56. package/evals/integration/test_ensure_session_ownership_guard.sh +39 -0
  57. package/evals/integration/test_evidence_capture_hook.sh +25 -6
  58. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  59. package/evals/integration/test_flowdef_union_floor_regression.sh +11 -0
  60. package/evals/integration/test_gate_bypass_chain.sh +11 -0
  61. package/evals/integration/test_gate_lockdown.sh +10 -0
  62. package/evals/integration/test_goal_fit_ghost_session.sh +11 -0
  63. package/evals/integration/test_goal_fit_hook.sh +257 -9
  64. package/evals/integration/test_goal_fit_rederive.sh +11 -0
  65. package/evals/integration/test_install_merge.sh +141 -3
  66. package/evals/integration/test_liveness_conflict_injection.sh +39 -3
  67. package/evals/integration/test_liveness_heartbeat.sh +46 -9
  68. package/evals/integration/test_phase_map_and_gate_claim.sh +10 -1
  69. package/evals/integration/test_public_workflow_cli.sh +6 -6
  70. package/evals/integration/test_session_resume_roundtrip.sh +30 -0
  71. package/evals/integration/test_stop_hook_release.sh +111 -11
  72. package/evals/integration/test_telemetry_tool_usage.sh +394 -0
  73. package/evals/integration/test_verify_hold.sh +64 -1
  74. package/evals/integration/test_workflow_sidecar_writer.sh +10 -1
  75. package/evals/integration/test_workflow_steering_hook.sh +20 -4
  76. package/evals/run.sh +2 -0
  77. package/evals/static/test_universal_bundles.sh +123 -0
  78. package/package.json +4 -4
  79. package/packaging/README.md +21 -0
  80. package/packaging/codex-legacy-agents-fingerprints.json +33 -0
  81. package/scripts/README.md +2 -1
  82. package/scripts/audit-codex-legacy-agents.js +43 -0
  83. package/scripts/classify-codex-legacy-agents.js +71 -0
  84. package/scripts/hooks/evidence-capture.js +18 -6
  85. package/scripts/hooks/lib/actor-identity.js +62 -29
  86. package/scripts/hooks/lib/continuation-turn-authority.js +386 -0
  87. package/scripts/hooks/lib/current-pointer.js +114 -12
  88. package/scripts/hooks/lib/liveness-heartbeat.js +15 -12
  89. package/scripts/hooks/stop-goal-fit.js +244 -73
  90. package/scripts/hooks/workflow-steering.js +24 -15
  91. package/scripts/install-codex-home.sh +8 -7
  92. package/scripts/telemetry/lib/pricing.sh +18 -6
  93. package/scripts/telemetry/lib/transport.sh +71 -0
  94. package/scripts/telemetry/lib/usage.sh +282 -32
  95. package/scripts/telemetry/lib/usage_model_guard.sh +42 -0
  96. package/scripts/telemetry/telemetry.sh +76 -1
  97. package/src/builder-flow-runtime.ts +9 -3
  98. package/src/cli/assignment-provider.ts +5 -26
  99. package/src/cli/builder-flow-runtime.test.mjs +215 -2
  100. package/src/cli/continuation-adapter.ts +265 -0
  101. package/src/cli/continuation-driver.test.mjs +1303 -0
  102. package/src/cli/public-api.test.mjs +1 -0
  103. package/src/cli/validate-workflow-artifacts.ts +19 -1
  104. package/src/cli/workflow-sidecar.ts +18 -23
  105. package/src/cli/workflow.ts +113 -6
  106. package/src/continuation-driver.ts +638 -0
  107. package/src/index.ts +24 -0
  108. package/src/tools/build-universal-bundles.ts +61 -25
  109. package/src/tools/validate-source-tree.ts +5 -2
@@ -293,13 +293,30 @@ function appendExportNote(body, note) {
293
293
  function generatedAgentsSummary(agents) {
294
294
  return agents.slice().sort((a, b) => a.name.localeCompare(b.name)).map((spec) => `- \`${spec.name}\` — ${String(spec.description ?? "").trim()}`).join("\n");
295
295
  }
296
- function exportRootAgentsMd(label, agents, taskDir) {
297
- return `# Universal Agent Bundle (${label})\n\nThis bundle was generated from the canonical source in this repo. Treat the repo root as the source of truth and regenerate the bundle instead of editing exported agent files by hand.\n\n## Shared Conventions\n\n- \`skills/\`, \`context/\`, \`powers/\`, \`prompts/\`, \`scripts/\`, and \`evals/\` were copied from the canonical source.\n- Cross-session task artifacts should live under \`${taskDir}\`.\n- Kiro-only hook wiring was stripped from exported non-Kiro agents to keep the package portable.\n\n## Exported Agents\n\n${generatedAgentsSummary(agents)}\n`;
296
+ const BUNDLE_CAPABILITIES = {
297
+ "base": { label: "Base", installHint: "bash install.sh /path/to/workspace", instructionPath: "AGENTS.md", taskDir: runtimeTaskDir },
298
+ "kiro": { label: "Kiro", installHint: "bash install.sh $HOME/.flow-agents", instructionPath: "AGENTS.md", taskDir: runtimeTaskDir },
299
+ "claude-code": { label: "Claude Code", installHint: "bash install.sh /path/to/workspace", instructionPath: "CLAUDE.md", taskDir: manifest.claude_code.task_dir },
300
+ "codex": { label: "Codex", installHint: "bash install.sh /path/to/workspace", taskDir: manifest.codex.task_dir },
301
+ "opencode": { label: "opencode", installHint: "bash install.sh /path/to/workspace", instructionPath: "AGENTS.md", taskDir: manifest.opencode.task_dir },
302
+ "pi": { label: "pi", installHint: "bash install.sh /path/to/workspace", instructionPath: "AGENTS.md", taskDir: manifest.pi.task_dir },
303
+ };
304
+ function renderOperationalInstructions(capability) {
305
+ return `# Flow Agents\n\nUse the installed Flow Agents skills and runtime integrations for delivery work. Keep cross-session task artifacts under \`${capability.taskDir}\`.\n`;
298
306
  }
299
307
  const CODEX_LIVE_HOOKS_README = `\n## Running with hooks active (live)\n\n\`install.sh\` lays the bundle into a workspace, but a live Codex session needs a \`CODEX_HOME\` that has both the bundle's hooks/scripts AND your real credentials. Use the dedicated installer, which defaults to \`CODEX_HOME\` when set and otherwise \`~/.codex\`, flattens the config to the home root, and copies your auth from \`~/.codex\` when installing elsewhere:\n\n\`\`\`bash\nbash scripts/install-codex-home.sh\ncodex exec --dangerously-bypass-hook-trust -C /path/to/project \"<prompt>\"\n\`\`\`\n\nPass a positional destination to install into an explicit alternate Codex home.\n\nThe goal-fit Stop hook then enforces by default (\`FLOW_AGENTS_GOAL_FIT_MODE=block\`); set it to \`warn\` or \`off\` to override.\n`;
300
308
  function exportTargetReadme(label, installHint, extra = "") {
301
309
  return `# ${label} Bundle\n\nGenerated from the canonical source in this repository.\n\n## Install\n\n\`\`\`bash\n${installHint}\n\`\`\`\n\nThe install ships the full standalone base (skills, agents, powers) plus the\nFlow Kits. Kit depth is activated through the Kit Catalog, not at install time.\n\n## Contents\n\n- Harness-specific agents\n- Shared skills\n- Shared context, powers, prompts, scripts, and evals\n${extra}`;
302
310
  }
311
+ function renderBundleReadme(capability, agents, extra = "") {
312
+ const inventory = `\n## Exported agents\n\nThe following agent definitions were generated from the canonical agent specifications in this repository. This inventory is documentation, not agent instructions.\n\n${generatedAgentsSummary(agents)}\n`;
313
+ return exportTargetReadme(capability.label, capability.installHint, `${inventory}${extra}`);
314
+ }
315
+ function writeBundlePolicy(bundle, capability, agents, readmeExtra = "") {
316
+ writeText(path.join(bundle, "README.md"), renderBundleReadme(capability, agents, readmeExtra));
317
+ if (capability.instructionPath)
318
+ writeText(path.join(bundle, capability.instructionPath), renderOperationalInstructions(capability));
319
+ }
303
320
  function mapClaudeTools(allowedTools) {
304
321
  const ordered = [];
305
322
  for (const tool of Array.isArray(allowedTools) ? allowedTools : []) {
@@ -451,48 +468,59 @@ function copySharedContent(targetRoot, targetName, token) {
451
468
  }
452
469
  for (const dir of manifest.optional_copy_dirs ?? [])
453
470
  copyTree(path.join(root, dir), path.join(targetRoot, dir), targetName, token);
454
- writeText(path.join(targetRoot, "build/package.json"), `${JSON.stringify({ type: "module" }, null, 2)}\n`);
471
+ writeText(path.join(targetRoot, "build/package.json"), `${JSON.stringify({
472
+ name: "@kontourai/flow-agents",
473
+ version: pkgVersion,
474
+ type: "module",
475
+ }, null, 2)}\n`);
455
476
  const commonBuilt = path.join(root, "build/src/tools/common.js");
456
477
  if (fs.existsSync(commonBuilt))
457
478
  writeText(path.join(targetRoot, "scripts/common.mjs"), readText(commonBuilt));
458
479
  copyTree(path.join(root, "build/src"), path.join(targetRoot, "build/src"), targetName, token);
459
480
  }
460
- function installScript(label, defaultDestDisplay, token, destFallbackShell, mergeConfig, stampConfig) {
461
- const replaceBlock = token ? `\nexport DEST\nfind "$DEST" -type f \\( -name '*.json' -o -name '*.md' -o -name '*.sh' -o -name '*.js' -o -name '*.ts' -o -name '*.yaml' -o -name '*.yml' \\) -print0 | xargs -0 perl -0pi -e 's#${token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}#$ENV{DEST}#g'` : "";
481
+ function installScript(label, capability, defaultDestDisplay, token, destFallbackShell, mergeConfig, stampConfig) {
482
+ const replaceBlock = token ? `\nexport DEST\nfind "$DEST" \\( -path "$DEST/AGENTS.md" -o -path "$DEST/CLAUDE.md" \\) -prune -o -type f \\( -name '*.json' -o -name '*.md' -o -name '*.sh' -o -name '*.js' -o -name '*.ts' -o -name '*.yaml' -o -name '*.yml' \\) -print0 | xargs -0 perl -0pi -e 's#${token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}#$ENV{DEST}#g'` : "";
462
483
  const destFallback = destFallbackShell ? `\nif [[ -z "$DEST" ]]; then\n DEST="${destFallbackShell}"\nfi` : "";
463
484
  const destRequired = !destFallbackShell;
464
- const requiredCheck = destRequired ? `if [[ -z "$DEST" ]]; then\n usage\n exit 2\nfi\n` : "";
485
+ const requiredCheck = `${destRequired ? `if [[ -z "$DEST" ]]; then\n usage\n exit 2\nfi\n` : ""}while [[ "$DEST" != "/" && "$DEST" == */ ]]; do\n DEST="\${DEST%/}"\ndone\n`;
465
486
  const usageDest = destRequired ? "/path/to/workspace" : defaultDestDisplay;
466
487
  const mergeBlock = mergeConfig
467
488
  ? `\nif command -v node >/dev/null 2>&1; then\n node "$DEST/scripts/install-merge.js" --config "$DEST/${mergeConfig.configRelPath}" --managed-hooks "$SRC/${mergeConfig.managedConfigRelPath}" --version "${mergeConfig.version}" --install-record "$DEST/${durableInstallRecordRel}" --runtime "${mergeConfig.runtime}" || true\nfi`
468
489
  : stampConfig
469
490
  ? `\nif command -v node >/dev/null 2>&1; then\n node "$DEST/scripts/install-merge.js" --stamp-only --version "${stampConfig.version}" --install-record "$DEST/${durableInstallRecordRel}" --runtime "${stampConfig.runtime}" || true\nfi`
470
491
  : "";
471
- return `#!/usr/bin/env bash\nset -euo pipefail\n\nusage() {\n cat >&2 <<'EOF'\nusage: bash install.sh ${usageDest} [options]\n\nOptions:\n --telemetry-sink NAME local-files, local-kontour-console,\n kontour-hosted-console, user-hosted-console,\n or legacy aliases. May be repeated.\n --console-url URL Persist Console telemetry base URL.\n --console-endpoint URL Persist full Console telemetry records endpoint URL.\n --console-token-file PATH\n Read Console telemetry bearer token from a file.\n --console-tenant ID Persist Console tenant identifier.\nEOF\n}\n\nDEST=""\nDEST_SET=0\nCONSOLE_CONFIG_ARGS=()\nwhile [[ $# -gt 0 ]]; do\n case "$1" in\n --telemetry-sink|--telemetry-sinks|--console-url|--console-endpoint|--console-endpoint-url|--console-token-file|--console-tenant|--console-tenant-id)\n [[ $# -ge 2 ]] || { echo "install.sh: $1 requires a value" >&2; exit 2; }\n CONSOLE_CONFIG_ARGS+=("$1" "$2")\n shift 2\n ;;\n --help|-h)\n usage\n exit 0\n ;;\n -*)\n echo "install.sh: unknown option: $1" >&2\n usage\n exit 2\n ;;\n *)\n if [[ "$DEST_SET" -eq 1 ]]; then\n echo "install.sh: unexpected argument: $1" >&2\n usage\n exit 2\n fi\n DEST="$1"\n DEST_SET=1\n shift\n ;;\n esac\ndone${destFallback}\n${requiredCheck}SRC="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"\n\nmkdir -p "$DEST"\nrsync -a ${token ? "--delete " : ""}${mergeConfig ? `--exclude="${mergeConfig.configRelPath}" ` : ""}"$SRC"/ "$DEST"/${replaceBlock}${mergeBlock}\nif [[ \${#CONSOLE_CONFIG_ARGS[@]} -gt 0 || -n "\${FLOW_AGENTS_TELEMETRY_SINK:-}" || -n "\${FLOW_AGENTS_TELEMETRY_SINKS:-}" || -n "\${FLOW_AGENTS_CONSOLE_URL:-}" || -n "\${CONSOLE_TELEMETRY_URL:-}" || -n "\${CONSOLE_URL:-}" || -n "\${FLOW_AGENTS_CONSOLE_TOKEN_FILE:-}" || -n "\${CONSOLE_TELEMETRY_TOKEN_FILE:-}" ]]; then\n bash "$DEST/scripts/telemetry/install-console-config.sh" "$DEST/scripts/telemetry/telemetry.conf" "\${CONSOLE_CONFIG_ARGS[@]}"\nfi\necho "Installed ${label} bundle ${token ? "to" : "into"} $DEST"\n`;
492
+ // Excluding both discovery filenames protects any existing filesystem
493
+ // object, including during Kiro's --delete sync. Required guidance is then
494
+ // published with a no-overwrite hard link from a destination-local temp file.
495
+ const instructionBlock = capability.instructionPath
496
+ ? `\nif [[ ! -e "$DEST/${capability.instructionPath}" && ! -L "$DEST/${capability.instructionPath}" ]]; then\n instruction_tmp="$(mktemp "$DEST/.flow-agents-instructions.XXXXXX")"\n cp "$SRC/${capability.instructionPath}" "$instruction_tmp"\n if ! ln "$instruction_tmp" "$DEST/${capability.instructionPath}" 2>/dev/null; then\n if [[ ! -e "$DEST/${capability.instructionPath}" && ! -L "$DEST/${capability.instructionPath}" ]]; then\n rm -f "$instruction_tmp"\n echo "install.sh: could not safely install ${capability.instructionPath}" >&2\n exit 1\n fi\n fi\n rm -f "$instruction_tmp"\nfi`
497
+ : "";
498
+ return `#!/usr/bin/env bash\nset -euo pipefail\n\nusage() {\n cat >&2 <<'EOF'\nusage: bash install.sh ${usageDest} [options]\n\nOptions:\n --telemetry-sink NAME local-files, local-kontour-console,\n kontour-hosted-console, user-hosted-console,\n or legacy aliases. May be repeated.\n --console-url URL Persist Console telemetry base URL.\n --console-endpoint URL Persist full Console telemetry records endpoint URL.\n --console-token-file PATH\n Read Console telemetry bearer token from a file.\n --console-tenant ID Persist Console tenant identifier.\nEOF\n}\n\nDEST=""\nDEST_SET=0\nCONSOLE_CONFIG_ARGS=()\nwhile [[ $# -gt 0 ]]; do\n case "$1" in\n --telemetry-sink|--telemetry-sinks|--console-url|--console-endpoint|--console-endpoint-url|--console-token-file|--console-tenant|--console-tenant-id)\n [[ $# -ge 2 ]] || { echo "install.sh: $1 requires a value" >&2; exit 2; }\n CONSOLE_CONFIG_ARGS+=("$1" "$2")\n shift 2\n ;;\n --help|-h)\n usage\n exit 0\n ;;\n -*)\n echo "install.sh: unknown option: $1" >&2\n usage\n exit 2\n ;;\n *)\n if [[ "$DEST_SET" -eq 1 ]]; then\n echo "install.sh: unexpected argument: $1" >&2\n usage\n exit 2\n fi\n DEST="$1"\n DEST_SET=1\n shift\n ;;\n esac\ndone${destFallback}\n${requiredCheck}SRC="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"\n\nmkdir -p "$DEST"\nrsync -a ${token ? "--delete " : ""}--exclude='/AGENTS.md' --exclude='/CLAUDE.md' ${mergeConfig ? `--exclude="${mergeConfig.configRelPath}" ` : ""}"$SRC"/ "$DEST"/${instructionBlock}${replaceBlock}${mergeBlock}\nif [[ \${#CONSOLE_CONFIG_ARGS[@]} -gt 0 || -n "\${FLOW_AGENTS_TELEMETRY_SINK:-}" || -n "\${FLOW_AGENTS_TELEMETRY_SINKS:-}" || -n "\${FLOW_AGENTS_CONSOLE_URL:-}" || -n "\${CONSOLE_TELEMETRY_URL:-}" || -n "\${CONSOLE_URL:-}" || -n "\${FLOW_AGENTS_CONSOLE_TOKEN_FILE:-}" || -n "\${CONSOLE_TELEMETRY_TOKEN_FILE:-}" ]]; then\n bash "$DEST/scripts/telemetry/install-console-config.sh" "$DEST/scripts/telemetry/telemetry.conf" "\${CONSOLE_CONFIG_ARGS[@]}"\nfi\necho "Installed ${label} bundle ${token ? "to" : "into"} $DEST"\n`;
472
499
  }
473
500
  function buildBase(agents) {
501
+ const capability = BUNDLE_CAPABILITIES["base"];
474
502
  const bundle = path.join(dist, "base");
475
503
  resetDir(bundle);
476
504
  copySharedContent(bundle, "base", "<bundle-root>");
477
505
  writeText(path.join(bundle, runtimeTaskDir, ".gitkeep"), "");
478
- writeText(path.join(bundle, "AGENTS.md"), exportRootAgentsMd("Base", agents, runtimeTaskDir));
479
- writeText(path.join(bundle, "README.md"), exportTargetReadme("Base", "bash install.sh /path/to/workspace"));
480
- writeText(path.join(bundle, "install.sh"), installScript("Base", "/path/to/workspace", undefined, undefined, undefined, { runtime: "base", version: pkgVersion }));
506
+ writeBundlePolicy(bundle, capability, agents);
507
+ writeText(path.join(bundle, "install.sh"), installScript("Base", capability, "/path/to/workspace", undefined, undefined, undefined, { runtime: "base", version: pkgVersion }));
481
508
  fs.chmodSync(path.join(bundle, "install.sh"), 0o755);
482
509
  }
483
510
  function buildKiro(agents) {
511
+ const capability = BUNDLE_CAPABILITIES["kiro"];
484
512
  const bundle = path.join(dist, "kiro");
485
513
  const token = manifest.kiro.path_token;
486
514
  resetDir(bundle);
487
515
  copySharedContent(bundle, "kiro", token);
488
516
  for (const spec of agents)
489
517
  writeText(path.join(bundle, "agents", `${spec.name}.json`), sanitizeText(`${JSON.stringify(sanitizeAgentJson(spec), null, 2)}\n`, "kiro", token));
490
- writeText(path.join(bundle, "AGENTS.md"), exportRootAgentsMd("Kiro", agents, runtimeTaskDir));
491
- writeText(path.join(bundle, "README.md"), exportTargetReadme("Kiro", "bash install.sh $HOME/.flow-agents"));
492
- writeText(path.join(bundle, "install.sh"), installScript("Kiro", "$HOME/.flow-agents", token, '${FLOW_AGENTS_DEST:-$HOME/.flow-agents}', undefined, { runtime: "kiro", version: pkgVersion }));
518
+ writeBundlePolicy(bundle, capability, agents);
519
+ writeText(path.join(bundle, "install.sh"), installScript("Kiro", capability, "$HOME/.flow-agents", token, '${FLOW_AGENTS_DEST:-$HOME/.flow-agents}', undefined, { runtime: "kiro", version: pkgVersion }));
493
520
  fs.chmodSync(path.join(bundle, "install.sh"), 0o755);
494
521
  }
495
522
  function buildClaudeCode(agents) {
523
+ const capability = BUNDLE_CAPABILITIES["claude-code"];
496
524
  const bundle = path.join(dist, "claude-code");
497
525
  resetDir(bundle);
498
526
  copySharedContent(bundle, "claude-code", "<bundle-root>");
@@ -503,12 +531,12 @@ function buildClaudeCode(agents) {
503
531
  writeText(path.join(bundle, ".claude/skills", name, "SKILL.md"), sanitizeText(readText(src), "claude-code", "<bundle-root>"));
504
532
  }
505
533
  writeText(path.join(bundle, ".claude/settings.json"), exportClaudeSettings());
506
- writeText(path.join(bundle, "AGENTS.md"), exportRootAgentsMd("Claude Code", agents, manifest.claude_code.task_dir));
507
- writeText(path.join(bundle, "README.md"), exportTargetReadme("Claude Code", "bash install.sh /path/to/workspace"));
508
- writeText(path.join(bundle, "install.sh"), installScript("Claude Code", "/path/to/workspace", undefined, undefined, { configRelPath: ".claude/settings.json", managedConfigRelPath: ".claude/settings.json", runtime: "claude-code", version: pkgVersion }));
534
+ writeBundlePolicy(bundle, capability, agents);
535
+ writeText(path.join(bundle, "install.sh"), installScript("Claude Code", capability, "/path/to/workspace", undefined, undefined, { configRelPath: ".claude/settings.json", managedConfigRelPath: ".claude/settings.json", runtime: "claude-code", version: pkgVersion }));
509
536
  fs.chmodSync(path.join(bundle, "install.sh"), 0o755);
510
537
  }
511
538
  function buildCodex(agents) {
539
+ const capability = BUNDLE_CAPABILITIES["codex"];
512
540
  const bundle = path.join(dist, "codex");
513
541
  const excluded = new Set(manifest.codex.excluded_agents ?? []);
514
542
  const targetAgents = agents.filter((spec) => !excluded.has(spec.name));
@@ -526,9 +554,8 @@ function buildCodex(agents) {
526
554
  for (const { name, src } of collectAllSkills())
527
555
  exportSkillPackage(src, path.join(skillsRoot, name), "codex");
528
556
  validateExportedSkillPackages(skillsRoot);
529
- writeText(path.join(bundle, "AGENTS.md"), exportRootAgentsMd("Codex", targetAgents, manifest.codex.task_dir));
530
- writeText(path.join(bundle, "README.md"), exportTargetReadme("Codex", "bash install.sh /path/to/workspace", CODEX_LIVE_HOOKS_README));
531
- writeText(path.join(bundle, "install.sh"), installScript("Codex", "/path/to/workspace", undefined, undefined, { configRelPath: ".codex/hooks.json", managedConfigRelPath: ".codex/hooks.json", runtime: "codex", version: pkgVersion }));
557
+ writeBundlePolicy(bundle, capability, targetAgents, CODEX_LIVE_HOOKS_README);
558
+ writeText(path.join(bundle, "install.sh"), installScript("Codex", capability, "/path/to/workspace", undefined, undefined, { configRelPath: ".codex/hooks.json", managedConfigRelPath: ".codex/hooks.json", runtime: "codex", version: pkgVersion }));
532
559
  fs.chmodSync(path.join(bundle, "install.sh"), 0o755);
533
560
  }
534
561
  function exportOpencodeAgent(spec) {
@@ -696,6 +723,7 @@ function exportOpencodeConfig() {
696
723
  }, null, 2)}\n`;
697
724
  }
698
725
  function buildOpencode(agents) {
726
+ const capability = BUNDLE_CAPABILITIES["opencode"];
699
727
  const bundle = path.join(dist, "opencode");
700
728
  resetDir(bundle);
701
729
  copySharedContent(bundle, "opencode", "<bundle-root>");
@@ -708,9 +736,8 @@ function buildOpencode(agents) {
708
736
  }
709
737
  writeText(path.join(bundle, ".opencode/plugins/flow-agents.js"), exportOpencodePlugin());
710
738
  writeText(path.join(bundle, "opencode.json"), exportOpencodeConfig());
711
- writeText(path.join(bundle, "AGENTS.md"), exportRootAgentsMd("opencode", agents, manifest.opencode.task_dir));
712
- writeText(path.join(bundle, "README.md"), exportTargetReadme("opencode", "bash install.sh /path/to/workspace"));
713
- writeText(path.join(bundle, "install.sh"), installScript("opencode", "/path/to/workspace", undefined, undefined, { configRelPath: "opencode.json", managedConfigRelPath: "opencode.json", runtime: "opencode", version: pkgVersion }));
739
+ writeBundlePolicy(bundle, capability, agents);
740
+ writeText(path.join(bundle, "install.sh"), installScript("opencode", capability, "/path/to/workspace", undefined, undefined, { configRelPath: "opencode.json", managedConfigRelPath: "opencode.json", runtime: "opencode", version: pkgVersion }));
714
741
  fs.chmodSync(path.join(bundle, "install.sh"), 0o755);
715
742
  }
716
743
  function exportPiExtension() {
@@ -809,6 +836,7 @@ export default function (pi: ExtensionAPI) {
809
836
  `;
810
837
  }
811
838
  function buildPi(agents) {
839
+ const capability = BUNDLE_CAPABILITIES["pi"];
812
840
  const bundle = path.join(dist, "pi");
813
841
  resetDir(bundle);
814
842
  copySharedContent(bundle, "pi", "<bundle-root>");
@@ -819,9 +847,8 @@ function buildPi(agents) {
819
847
  writeText(path.join(bundle, ".pi/skills", name, "SKILL.md"), sanitizeText(readText(src), "pi", "<bundle-root>"));
820
848
  }
821
849
  writeText(path.join(bundle, ".pi/extensions/flow-agents.ts"), exportPiExtension());
822
- writeText(path.join(bundle, "AGENTS.md"), exportRootAgentsMd("pi", agents, manifest.pi.task_dir));
823
- writeText(path.join(bundle, "README.md"), exportTargetReadme("pi", "bash install.sh /path/to/workspace"));
824
- writeText(path.join(bundle, "install.sh"), installScript("pi", "/path/to/workspace", undefined, undefined, undefined, { runtime: "pi", version: pkgVersion }));
850
+ writeBundlePolicy(bundle, capability, agents);
851
+ writeText(path.join(bundle, "install.sh"), installScript("pi", capability, "/path/to/workspace", undefined, undefined, undefined, { runtime: "pi", version: pkgVersion }));
825
852
  fs.chmodSync(path.join(bundle, "install.sh"), 0o755);
826
853
  }
827
854
  function buildCatalog(agents) {
@@ -32,6 +32,7 @@ const mirroredFiles = new Map([
32
32
  // fails `npm run validate:source` instead of silently accumulating.
33
33
  ["scripts/hooks/config-protection.js", { mirror: "context/scripts/hooks/config-protection.js", allowedDifferences: [] }],
34
34
  ["scripts/hooks/lib/config-protection-remedies.js", { mirror: "context/scripts/hooks/lib/config-protection-remedies.js", allowedDifferences: [] }],
35
+ ["scripts/hooks/lib/continuation-turn-authority.js", { mirror: "context/scripts/hooks/lib/continuation-turn-authority.js", allowedDifferences: [] }],
35
36
  ["scripts/hooks/lib/kit-catalog.js", { mirror: "context/scripts/hooks/lib/kit-catalog.js", allowedDifferences: [] }],
36
37
  ["scripts/hooks/lib/runnable-command.js", { mirror: "context/scripts/hooks/lib/runnable-command.js", allowedDifferences: [] }],
37
38
  ["scripts/hooks/stop-goal-fit.js", { mirror: "context/scripts/hooks/stop-goal-fit.js", allowedDifferences: [] }],
@@ -83,10 +84,11 @@ const hookFilePolicies = new Map([
83
84
  ["scripts/hooks/utterance-check.js", { category: "policy hook", requiredNeedles: ["Utterance Check Hook", "FLOW_AGENTS_UTTERANCE_CHECK_ENABLED"] }],
84
85
  ["scripts/hooks/workflow-steering.js", { category: "policy hook", requiredNeedles: ["Workflow Steering Hook"] }],
85
86
  ["scripts/hooks/desktop-notify.sh", { category: "local notification helper", requiredNeedles: ["desktop-notify.sh", "osascript"] }],
86
- ["scripts/hooks/lib/actor-identity.js", { category: "shared hook library", requiredNeedles: ["resolveActor", "FLOW_AGENTS_ACTOR"] }],
87
+ ["scripts/hooks/lib/actor-identity.js", { category: "shared hook library", requiredNeedles: ["resolveActor", "FLOW_AGENTS_ACTOR", "CODEX_THREAD_ID", "flow-agents:codex-thread:v1:"] }],
87
88
  ["scripts/hooks/lib/audit-transport.sh", { category: "shared hook library", requiredNeedles: ["audit_emit"] }],
88
89
  ["scripts/hooks/lib/codex-exit-code.js", { category: "shared hook library", requiredNeedles: ["extractExitCodeFromBanner", "readExitCodeFromRollout"] }],
89
90
  ["scripts/hooks/lib/config-protection-remedies.js", { category: "shared hook library", requiredNeedles: ["SANCTIONED_REMEDIES", "REMEDY_COMMAND_CANDIDATES"] }],
91
+ ["scripts/hooks/lib/continuation-turn-authority.js", { category: "shared hook library", requiredNeedles: ["issueActiveTurnAuthority", "validateActiveTurnAuthority", "validateSignedActiveTurnAssignmentAuthority"] }],
90
92
  ["scripts/hooks/lib/current-pointer.js", { category: "shared hook library", requiredNeedles: ["readCurrentPointer", "perActorCurrentFile"] }],
91
93
  ["scripts/hooks/lib/hook-flags.js", { category: "shared hook library", requiredNeedles: ["isHookEnabled"] }],
92
94
  ["scripts/hooks/lib/kit-catalog.js", { category: "shared hook library", requiredNeedles: ["readKitManifests", "workflowTriggersFor"] }],
@@ -105,6 +107,7 @@ const fixtureOwnerPolicies = new Map([
105
107
  ["evals/fixtures/backlog-provider-settings", { owners: ["evals/integration/test_effective_backlog_settings.sh"], classification: "settings precedence fixtures" }],
106
108
  ["evals/fixtures/builder-kit-workflow-state", { owners: ["evals/static/test_workflow_skills.sh"], classification: "Builder Kit workflow-state fixtures" }],
107
109
  ["evals/fixtures/console-learning-projection", { owners: ["evals/integration/test_console_learning_projection.sh"], classification: "console learning projection fixtures" }],
110
+ ["evals/fixtures/codex-legacy-agents", { owners: ["evals/integration/test_install_merge.sh"], classification: "exact historical Flow Agents-generated Codex AGENTS.md migration fixtures (#563)" }],
108
111
  ["evals/fixtures/economics", { owners: ["evals/integration/test_economics_record.sh"], classification: "per-run kit-economics record fixtures (#349): a transcript with .message.usage blocks, state.json/acceptance.json/critique.json join sources, a session.usage event, and the golden expected kontour.console.economics record" }],
109
112
  ["evals/fixtures/flow-kit-repository", { owners: ["evals/integration/test_flow_kit_repository.sh", "evals/integration/test_local_flow_kit_install.sh", "evals/integration/test_runtime_adapter_activation.sh", "evals/integration/test_activate_npx_context.sh", "evals/integration/test_flow_kit_install_git.sh", "evals/static/test_workflow_skills.sh"], classification: "Flow Kit repository contract fixtures" }],
110
113
  ["evals/fixtures/kit-conformance-levels", { owners: ["evals/integration/test_kit_conformance_levels.sh"], classification: "K-level conformance and consumer-target derivation fixtures" }],
@@ -113,7 +116,7 @@ const fixtureOwnerPolicies = new Map([
113
116
  ["evals/fixtures/pull-work-provider", { owners: ["evals/integration/test_pull_work_provider.sh"], classification: "work item provider normalization fixtures" }],
114
117
  ["evals/fixtures/pull-work-wip-shepherding", { owners: ["evals/static/test_workflow_skills.sh"], classification: "WIP shepherding state fixtures" }],
115
118
  ["evals/fixtures/surface-trust", { owners: ["evals/integration/test_workflow_sidecar_writer.sh"], classification: "Surface trust evidence fixtures" }],
116
- ["evals/fixtures/telemetry", { owners: ["evals/integration/test_usage_cost.sh", "evals/integration/test_telemetry_usage_pipeline.sh"], classification: "hermetic Stop-hook usage transcript fixture (#defect-2026-07 telemetry-usage-cost-extraction): a multi-model JSONL transcript with .message.model + .message.usage token blocks proving real token/cost/model extraction end-to-end" }],
119
+ ["evals/fixtures/telemetry", { owners: ["evals/integration/test_usage_cost.sh", "evals/integration/test_telemetry_usage_pipeline.sh", "evals/integration/test_telemetry_tool_usage.sh"], classification: "hermetic Stop-hook usage transcript fixture (#defect-2026-07 telemetry-usage-cost-extraction): a multi-model JSONL transcript with .message.model + .message.usage token blocks proving real token/cost/model extraction end-to-end; also reused by the #568 slice 1 per-tool-call usage-enrichment eval (test_telemetry_tool_usage.sh), which adds usage-transcript-oversized-prefix.jsonl to prove the bounded tail-read" }],
117
120
  ["evals/fixtures/reconcile-preflight", { owners: ["evals/integration/test_reconcile_preflight.sh"], classification: "#356 reconcile-preflight shape fixtures not already covered by trust-reconcile-exploits (un-superseded disputed critique, standalone disputed session-local claim)" }],
118
121
  ["evals/fixtures/trust-reconcile-exploits", { owners: ["evals/integration/test_trust_reconcile_negatives.sh", "evals/integration/test_reconcile_preflight.sh"], classification: "WS8 trust-reconcile anti-gaming exploit fixtures (frozen negative regressions); also reused by the #356 local reconcile-preflight eval (same shapes, no forked copies)" }],
119
122
  ["evals/fixtures/trust-reconcile-mixed-bundle", { owners: ["evals/integration/test_trust_reconcile_mixed_bundle.sh", "evals/integration/test_reconcile_preflight.sh"], classification: "WS8 trust-reconcile mixed-evidence end-to-end proof fixture; also reused by the #356 reconcile-preflight eval as its CLEAN-BUNDLE (AC4) case" }],
@@ -0,0 +1,386 @@
1
+ 'use strict';
2
+
3
+ // A transient, driver-owned capability for one adapter turn. Ed25519 proves
4
+ // that the record was issued by the still-running driver's private key; it is
5
+ // not a filesystem isolation boundary against a hostile same-UID process.
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { createHash, generateKeyPairSync, randomBytes, randomUUID, sign, verify } = require('crypto');
10
+ const { isDeepStrictEqual } = require('util');
11
+
12
+ const SCHEMA_VERSION = '2.0';
13
+ const FILE_NAME = 'active-turn.json';
14
+ const MAX_ACTIVE_TURN_BYTES = 16 * 1024;
15
+ const MAX_DRIVER_RECORD_BYTES = 1024 * 1024;
16
+ const MAX_ASSIGNMENT_RECORD_BYTES = 1024 * 1024;
17
+ const MAX_LOCK_RECORD_BYTES = 16 * 1024;
18
+ const MAX_TIMEOUT_MS = 86_400_000;
19
+
20
+ function activeTurnFile(sessionDir) {
21
+ return path.join(path.resolve(sessionDir), 'continuation-driver', FILE_NAME);
22
+ }
23
+
24
+ function issueActiveTurnAuthority(input) {
25
+ const sessionDir = path.resolve(input.sessionDir);
26
+ const parents = captureParents(sessionDir, true);
27
+ const issuedAt = input.now instanceof Date ? input.now : new Date();
28
+ const timeoutMs = input.timeoutMs;
29
+ assertPositiveInteger(timeoutMs, 'timeoutMs', 1, MAX_TIMEOUT_MS);
30
+ const assignmentActor = requiredString(input.assignmentActor, 'assignmentActor');
31
+ const assignmentActorStruct = requiredActorStruct(input.assignmentActorStruct, 'assignmentActorStruct');
32
+ const assignment = readActiveAssignment(sessionDir, parents, assignmentActor, assignmentActorStruct);
33
+ const keys = generateKeyPairSync('ed25519');
34
+ const turnSecret = randomBytes(32).toString('base64url');
35
+ const publicKey = keys.publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
36
+ const publicKeyDigest = sha256(publicKey);
37
+ const record = {
38
+ schema_version: SCHEMA_VERSION,
39
+ run_id: requiredSafeRunId(input.runId, 'runId'),
40
+ definition_id: requiredString(input.definitionId, 'definitionId'),
41
+ issued_step: requiredString(input.currentStep, 'currentStep'),
42
+ iteration: requiredInteger(input.iteration, 'iteration', 1, 100),
43
+ max_turns: requiredInteger(input.maxTurns, 'maxTurns', 1, 100),
44
+ adapter_command_identity: requiredString(input.adapterCommandIdentity, 'adapterCommandIdentity'),
45
+ assignment_actor: assignmentActor,
46
+ assignment_actor_struct: assignmentActorStruct,
47
+ assignment_record_sha256: assignment.sha256,
48
+ driver_lock: {
49
+ pid: requiredInteger(input.lock.pid, 'lock.pid', 1, 2_147_483_647),
50
+ token: requiredString(input.lock.token, 'lock.token'),
51
+ created_at: requiredTimestamp(input.lock.created_at, 'lock.created_at'),
52
+ },
53
+ turn_secret_sha256: sha256(turnSecret),
54
+ public_key_spki_b64: publicKey,
55
+ public_key_digest: publicKeyDigest,
56
+ timeout_ms: timeoutMs,
57
+ issued_at: issuedAt.toISOString(),
58
+ expires_at: new Date(issuedAt.getTime() + timeoutMs).toISOString(),
59
+ };
60
+ if (record.run_id !== path.basename(sessionDir)) throw new Error('runId must match the session directory');
61
+ record.signature_b64 = sign(null, canonicalBytes(record), keys.privateKey).toString('base64');
62
+ writeRecord(sessionDir, parents, record);
63
+ return {
64
+ runId: record.run_id,
65
+ turnSecret,
66
+ publicKeyDigest,
67
+ record,
68
+ cleanup: () => removeActiveTurnAuthority(sessionDir, record.turn_secret_sha256, parents),
69
+ };
70
+ }
71
+
72
+ function removeActiveTurnAuthority(sessionDir, turnSecretSha256, issuedParents) {
73
+ try {
74
+ const parents = captureParents(sessionDir, false);
75
+ if (issuedParents && !sameParents(issuedParents, parents)) return false;
76
+ const file = activeTurnFile(sessionDir);
77
+ const loaded = readRegularJson(file, 'continuation active turn', parents, MAX_ACTIVE_TURN_BYTES);
78
+ if (loaded.value.turn_secret_sha256 !== turnSecretSha256) return false;
79
+ assertParentsStable(parents);
80
+ const current = fileIdentity(file, 'continuation active turn');
81
+ if (!sameIdentity(current, loaded.identity)) return false;
82
+ fs.unlinkSync(file);
83
+ assertParentsStable(parents);
84
+ return true;
85
+ } catch {
86
+ // Do not unlink through a changed parent. The signed record expires.
87
+ return false;
88
+ }
89
+ }
90
+
91
+ // Validates the signed, live assignment capability without consulting Flow's
92
+ // current state. Public assignment-gated workflow commands use this narrow
93
+ // fallback after ordinary actor resolution fails; Stop adds canonical-state
94
+ // checks before treating its unfinished-gate warning as advisory.
95
+ function validateSignedActiveTurnAssignmentAuthority(input) {
96
+ try {
97
+ const runId = requiredSafeRunId(input.runId, 'runId');
98
+ const turnSecret = requiredTurnSecret(input.turnSecret);
99
+ const sessionDir = path.resolve(input.sessionDir);
100
+ if (path.basename(sessionDir) !== runId) return invalid('run does not match session');
101
+ const parents = captureParents(sessionDir, false);
102
+ const record = readRegularJson(activeTurnFile(sessionDir), 'continuation active turn', parents, MAX_ACTIVE_TURN_BYTES).value;
103
+ validateRecord(record);
104
+ if (!equalText(record.run_id, runId)) return invalid('run does not match signed authority');
105
+ if (!equalText(record.turn_secret_sha256, sha256(turnSecret))) return invalid('turn secret does not match');
106
+ if (!validSignature(record)) return invalid('authority signature does not verify');
107
+ const assignment = readActiveAssignment(sessionDir, parents, record.assignment_actor, record.assignment_actor_struct);
108
+ if (!equalText(record.assignment_record_sha256, assignment.sha256)) return invalid('active assignment record changed');
109
+ const mission = readRegularJson(path.join(sessionDir, 'continuation-driver', 'state.json'), 'continuation driver state', parents, MAX_DRIVER_RECORD_BYTES).value;
110
+ if (!mission || mission.schema_version !== '1.0'
111
+ || mission.run_id !== record.run_id
112
+ || mission.definition_id !== record.definition_id
113
+ || mission.adapter_command_identity !== record.adapter_command_identity
114
+ || mission.max_turns !== record.max_turns
115
+ || mission.turns_started !== record.iteration
116
+ || mission.active_turn_step !== record.issued_step
117
+ || mission.active_turn_public_key_digest !== record.public_key_digest
118
+ || mission.status !== 'active') return invalid('driver mission does not match');
119
+ const lockFile = path.join(sessionDir, 'continuation-driver', 'locks', `${record.driver_lock.pid}-${record.driver_lock.token}.lock`);
120
+ const lock = readRegularJson(lockFile, 'continuation driver lock', parents, MAX_LOCK_RECORD_BYTES).value;
121
+ if (!lock || lock.schema_version !== '1.0'
122
+ || lock.pid !== record.driver_lock.pid
123
+ || lock.token !== record.driver_lock.token
124
+ || lock.created_at !== record.driver_lock.created_at
125
+ || !processAlive(lock.pid)) return invalid('driver lock is not live');
126
+ const now = input.now instanceof Date ? input.now : new Date();
127
+ if (now.getTime() < Date.parse(record.issued_at) || now.getTime() > Date.parse(record.expires_at)) return invalid('authority is expired');
128
+ assertParentsStable(parents);
129
+ return { valid: true, record };
130
+ } catch (error) {
131
+ return invalid(error && error.message ? error.message : 'authority is malformed');
132
+ }
133
+ }
134
+
135
+ function validateActiveTurnAuthority(input) {
136
+ const base = validateSignedActiveTurnAssignmentAuthority(input);
137
+ if (!base.valid) return base;
138
+ try {
139
+ const canonical = input.canonicalState;
140
+ const record = base.record;
141
+ // The issued step remains bound to the driver mission, while canonical
142
+ // Flow may advance during the adapter's Stop callback.
143
+ if (!canonical || canonical.status !== 'active'
144
+ || canonical.run_id !== record.run_id
145
+ || canonical.definition_id !== record.definition_id
146
+ || typeof canonical.current_step !== 'string' || !canonical.current_step) return invalid('canonical Flow state does not match');
147
+ return base;
148
+ } catch (error) {
149
+ return invalid(error && error.message ? error.message : 'canonical Flow state is malformed');
150
+ }
151
+ }
152
+
153
+ function invalid(reason) {
154
+ return { valid: false, reason };
155
+ }
156
+
157
+ function writeRecord(sessionDir, parents, record) {
158
+ assertParentsStable(parents);
159
+ const file = activeTurnFile(sessionDir);
160
+ if (fs.existsSync(file)) fileIdentity(file, 'continuation active turn');
161
+ const temp = path.join(path.dirname(file), `.${FILE_NAME}.${process.pid}.${randomUUID()}.tmp`);
162
+ let tempIdentity = null;
163
+ try {
164
+ fs.writeFileSync(temp, `${JSON.stringify(record)}\n`, { flag: 'wx', mode: 0o600 });
165
+ tempIdentity = fileIdentity(temp, 'continuation active turn temporary file');
166
+ assertParentsStable(parents);
167
+ fs.renameSync(temp, file);
168
+ assertParentsStable(parents);
169
+ } finally {
170
+ removeOwnedTemp(temp, tempIdentity, parents);
171
+ }
172
+ }
173
+
174
+ function removeOwnedTemp(temp, expected, parents) {
175
+ if (!expected) return;
176
+ try {
177
+ assertParentsStable(parents);
178
+ if (!sameIdentity(fileIdentity(temp, 'continuation active turn temporary file'), expected)) return;
179
+ fs.unlinkSync(temp);
180
+ assertParentsStable(parents);
181
+ } catch {
182
+ // Parent or temp identity changed. Leave the bounded temporary record for
183
+ // operator cleanup rather than unlinking through a replacement path.
184
+ }
185
+ }
186
+
187
+ function captureParents(sessionDir, create) {
188
+ const session = safeDirectory(path.resolve(sessionDir), 'session directory', create);
189
+ const driver = safeDirectory(path.join(session.path, 'continuation-driver'), 'continuation driver directory', create);
190
+ const locks = safeDirectory(path.join(driver.path, 'locks'), 'continuation driver locks directory', create);
191
+ const assignment = safeDirectory(path.join(path.dirname(session.path), 'assignment'), 'workflow assignment directory', false);
192
+ return { session, driver, locks, assignment };
193
+ }
194
+
195
+ function safeDirectory(target, label, create) {
196
+ if (create) fs.mkdirSync(target, { recursive: true, mode: 0o700 });
197
+ const stat = fs.lstatSync(target);
198
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`${label} must be a non-symlink directory`);
199
+ const realpath = fs.realpathSync(target);
200
+ return { path: path.resolve(target), realpath, dev: stat.dev, ino: stat.ino };
201
+ }
202
+
203
+ function assertParentsStable(parents) {
204
+ for (const parent of [parents.session, parents.driver, parents.locks, parents.assignment]) {
205
+ const current = safeDirectory(parent.path, 'continuation authority parent', false);
206
+ if (!sameIdentity(parent, current) || current.realpath !== parent.realpath) throw new Error('continuation authority parent identity changed');
207
+ }
208
+ }
209
+
210
+ function sameParents(left, right) {
211
+ return ['session', 'driver', 'locks', 'assignment'].every((name) => sameIdentity(left[name], right[name]) && left[name].realpath === right[name].realpath);
212
+ }
213
+
214
+ function sameIdentity(left, right) {
215
+ return Boolean(left && right && left.dev === right.dev && left.ino === right.ino);
216
+ }
217
+
218
+ function readActiveAssignment(sessionDir, parents, actor, actorStruct) {
219
+ assertParentsStable(parents);
220
+ const artifactRoot = path.dirname(sessionDir);
221
+ const assignmentFile = path.join(parents.assignment.path, `${path.basename(sessionDir)}.json`);
222
+ const loaded = readRegularJson(assignmentFile, 'workflow assignment', parents, MAX_ASSIGNMENT_RECORD_BYTES);
223
+ const assignment = loaded.value;
224
+ if (!assignment || assignment.status !== 'claimed' || assignment.artifact_dir !== path.basename(sessionDir) || assignment.actor_key !== actor
225
+ || !isDeepStrictEqual(normalizeActorStruct(assignment.actor), normalizeActorStruct(actorStruct))) {
226
+ throw new Error('workflow assignment is not the active turn actor');
227
+ }
228
+ assertParentsStable(parents);
229
+ return { sha256: sha256(loaded.bytes) };
230
+ }
231
+
232
+ function readRegularJson(file, label, parents, maxBytes) {
233
+ if (parents) assertParentsStable(parents);
234
+ const expected = fileIdentity(file, label);
235
+ if (expected.size > maxBytes) throw new Error(`${label} exceeds maximum size`);
236
+ const noFollow = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
237
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | noFollow);
238
+ try {
239
+ const opened = fs.fstatSync(fd);
240
+ if (!opened.isFile() || opened.size > maxBytes || !sameIdentity(expected, opened)) throw new Error(`${label} identity changed while opening`);
241
+ const bytes = fs.readFileSync(fd);
242
+ const after = fs.fstatSync(fd);
243
+ if (!sameIdentity(expected, after)) throw new Error(`${label} identity changed while reading`);
244
+ const final = fileIdentity(file, label);
245
+ if (!sameIdentity(expected, final)) throw new Error(`${label} identity changed after reading`);
246
+ const value = JSON.parse(bytes.toString('utf8'));
247
+ if (parents) assertParentsStable(parents);
248
+ return { value, bytes, identity: expected };
249
+ } finally {
250
+ fs.closeSync(fd);
251
+ }
252
+ }
253
+
254
+ function fileIdentity(file, label) {
255
+ const stat = fs.lstatSync(file);
256
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`${label} must be a bounded non-symlink regular file`);
257
+ return { dev: stat.dev, ino: stat.ino, size: stat.size };
258
+ }
259
+
260
+ function validateRecord(record) {
261
+ if (!record || typeof record !== 'object' || Array.isArray(record) || record.schema_version !== SCHEMA_VERSION) throw new Error('authority schema is unsupported');
262
+ for (const field of ['run_id', 'definition_id', 'issued_step', 'adapter_command_identity', 'assignment_actor', 'assignment_record_sha256', 'turn_secret_sha256', 'public_key_spki_b64', 'public_key_digest', 'signature_b64', 'issued_at', 'expires_at']) requiredString(record[field], field);
263
+ requiredSafeRunId(record.run_id, 'run_id');
264
+ requiredActorStruct(record.assignment_actor_struct, 'assignment_actor_struct');
265
+ if (!/^[0-9a-f]{64}$/i.test(record.assignment_record_sha256)
266
+ || !/^[0-9a-f]{64}$/i.test(record.turn_secret_sha256)
267
+ || !/^[0-9a-f]{64}$/i.test(record.public_key_digest)) throw new Error('authority digest is malformed');
268
+ requiredInteger(record.iteration, 'iteration', 1, 100);
269
+ requiredInteger(record.max_turns, 'max_turns', 1, 100);
270
+ if (record.iteration > record.max_turns) throw new Error('authority iteration exceeds mission budget');
271
+ assertPositiveInteger(record.timeout_ms, 'timeout_ms', 1, MAX_TIMEOUT_MS);
272
+ const issued = Date.parse(requiredTimestamp(record.issued_at, 'issued_at'));
273
+ const expires = Date.parse(requiredTimestamp(record.expires_at, 'expires_at'));
274
+ if (expires !== issued + record.timeout_ms) throw new Error('authority expiry does not match timeout');
275
+ if (!record.driver_lock || typeof record.driver_lock !== 'object' || Array.isArray(record.driver_lock)) throw new Error('authority lock is malformed');
276
+ requiredInteger(record.driver_lock.pid, 'driver_lock.pid', 1, 2_147_483_647);
277
+ requiredString(record.driver_lock.token, 'driver_lock.token');
278
+ requiredTimestamp(record.driver_lock.created_at, 'driver_lock.created_at');
279
+ }
280
+
281
+ function validSignature(record) {
282
+ const unsigned = { ...record };
283
+ const signature = Buffer.from(unsigned.signature_b64, 'base64');
284
+ delete unsigned.signature_b64;
285
+ const publicKey = { key: Buffer.from(unsigned.public_key_spki_b64, 'base64'), format: 'der', type: 'spki' };
286
+ return equalText(sha256(unsigned.public_key_spki_b64), unsigned.public_key_digest)
287
+ && verify(null, canonicalBytes(unsigned), publicKey, signature);
288
+ }
289
+
290
+ function canonicalBytes(value) {
291
+ return Buffer.from(JSON.stringify(canonicalize(value)), 'utf8');
292
+ }
293
+
294
+ function canonicalize(value) {
295
+ if (Array.isArray(value)) return value.map(canonicalize);
296
+ if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]));
297
+ return value;
298
+ }
299
+
300
+ function sha256(value) {
301
+ return createHash('sha256').update(value).digest('hex');
302
+ }
303
+
304
+ function equalText(left, right) {
305
+ const a = Buffer.from(String(left));
306
+ const b = Buffer.from(String(right));
307
+ return a.length === b.length && require('crypto').timingSafeEqual(a, b);
308
+ }
309
+
310
+ function processAlive(pid) {
311
+ try {
312
+ process.kill(pid, 0);
313
+ return true;
314
+ } catch (error) {
315
+ return error && error.code === 'EPERM';
316
+ }
317
+ }
318
+
319
+ function requiredString(value, label) {
320
+ if (typeof value !== 'string' || !value.trim() || value.length > 4096 || value.includes('\0')) throw new Error(`${label} must be a bounded non-empty string`);
321
+ return value;
322
+ }
323
+
324
+ function requiredSafeRunId(value, label) {
325
+ const text = requiredString(value, label);
326
+ if (!/^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/.test(text)) throw new Error(`${label} must be a safe Flow run id`);
327
+ return text;
328
+ }
329
+
330
+ function requiredTurnSecret(value) {
331
+ const text = requiredString(value, 'turnSecret');
332
+ if (!/^[A-Za-z0-9_-]{43}$/.test(text)) throw new Error('turnSecret must be a 32-byte base64url secret');
333
+ return text;
334
+ }
335
+
336
+ function requiredActorStruct(value, label) {
337
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an actor object`);
338
+ const keys = Object.keys(value);
339
+ if (!keys.every((key) => key === 'runtime' || key === 'session_id' || key === 'host' || key === 'human')
340
+ || !['runtime', 'session_id', 'host'].every((key) => Object.prototype.hasOwnProperty.call(value, key))) throw new Error(`${label} has unsupported fields`);
341
+ for (const key of ['runtime', 'session_id', 'host']) requiredBoundedActorField(value[key], `${label}.${key}`);
342
+ if (Object.prototype.hasOwnProperty.call(value, 'human') && value.human !== null) requiredBoundedActorField(value.human, `${label}.human`);
343
+ return {
344
+ runtime: value.runtime,
345
+ session_id: value.session_id,
346
+ host: value.host,
347
+ ...(Object.prototype.hasOwnProperty.call(value, 'human') ? { human: value.human } : {}),
348
+ };
349
+ }
350
+
351
+ function normalizeActorStruct(value) {
352
+ try {
353
+ const actor = requiredActorStruct(value, 'assignment actor');
354
+ return { ...actor, human: actor.human ?? null };
355
+ } catch {
356
+ return null;
357
+ }
358
+ }
359
+
360
+ function requiredBoundedActorField(value, label) {
361
+ if (typeof value !== 'string' || !value || value.length > 256 || /[\u0000-\u001f\u007f-\u009f]/.test(value)) throw new Error(`${label} must be a bounded non-empty string`);
362
+ return value;
363
+ }
364
+
365
+ function requiredInteger(value, label, min, max) {
366
+ if (!Number.isSafeInteger(value) || value < min || value > max) throw new Error(`${label} must be an integer from ${min} through ${max}`);
367
+ return value;
368
+ }
369
+
370
+ function assertPositiveInteger(value, label, min, max) {
371
+ requiredInteger(value, label, min, max);
372
+ }
373
+
374
+ function requiredTimestamp(value, label) {
375
+ const text = requiredString(value, label);
376
+ if (!Number.isFinite(Date.parse(text))) throw new Error(`${label} must be an ISO timestamp`);
377
+ return text;
378
+ }
379
+
380
+ module.exports = {
381
+ activeTurnFile,
382
+ issueActiveTurnAuthority,
383
+ removeActiveTurnAuthority,
384
+ validateActiveTurnAuthority,
385
+ validateSignedActiveTurnAssignmentAuthority,
386
+ };