@kontourai/flow-agents 2.3.0 → 3.0.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 (301) hide show
  1. package/.github/CODEOWNERS +16 -0
  2. package/.github/workflows/ci.yml +44 -0
  3. package/.github/workflows/trust-reconcile.yml +62 -4
  4. package/AGENTS.md +4 -4
  5. package/CHANGELOG.md +62 -0
  6. package/CONTEXT.md +35 -0
  7. package/README.md +9 -3
  8. package/agents/dev.json +1 -1
  9. package/agents/tool-code-reviewer.json +1 -1
  10. package/agents/tool-planner.json +3 -3
  11. package/agents/tool-verifier.json +3 -3
  12. package/build/src/cli/assignment-provider.d.ts +1 -0
  13. package/build/src/cli/assignment-provider.js +748 -0
  14. package/build/src/cli/console-learning-projection.js +3 -2
  15. package/build/src/cli/effective-assignment-provider-settings.d.ts +1 -0
  16. package/build/src/cli/effective-assignment-provider-settings.js +125 -0
  17. package/build/src/cli/init.js +104 -21
  18. package/build/src/cli/kit.js +37 -6
  19. package/build/src/cli/pull-work-provider.js +1 -1
  20. package/build/src/cli/usage-feedback.js +3 -3
  21. package/build/src/cli/validate-hook-influence.js +1 -0
  22. package/build/src/cli/validate-workflow-artifacts.js +27 -7
  23. package/build/src/cli/workflow-artifact-cleanup-audit.js +36 -2
  24. package/build/src/cli/workflow-sidecar.d.ts +14 -7
  25. package/build/src/cli/workflow-sidecar.js +938 -168
  26. package/build/src/cli.js +6 -0
  27. package/build/src/flow-kit/validate.d.ts +23 -0
  28. package/build/src/flow-kit/validate.js +67 -1
  29. package/build/src/index.d.ts +1 -1
  30. package/build/src/index.js +1 -1
  31. package/build/src/lib/flow-resolver.d.ts +6 -4
  32. package/build/src/lib/flow-resolver.js +125 -58
  33. package/build/src/lib/local-artifact-root.d.ts +14 -2
  34. package/build/src/lib/local-artifact-root.js +22 -5
  35. package/build/src/lib/workflow-learning-projection.js +2 -2
  36. package/build/src/runtime-adapters.d.ts +12 -0
  37. package/build/src/runtime-adapters.js +56 -16
  38. package/build/src/tools/build-universal-bundles.js +49 -16
  39. package/build/src/tools/generate-context-map.js +10 -8
  40. package/build/src/tools/validate-source-tree.js +24 -1
  41. package/console.telemetry.json +22 -0
  42. package/context/contracts/artifact-contract.md +40 -7
  43. package/context/contracts/assignment-provider-contract.md +239 -0
  44. package/context/contracts/builder-kit-workflow-state-contract.md +5 -2
  45. package/context/contracts/decision-registry-contract.md +265 -0
  46. package/context/contracts/delivery-contract.md +3 -1
  47. package/context/contracts/execution-contract.md +25 -0
  48. package/context/contracts/governance-adapter-contract.md +2 -0
  49. package/context/contracts/knowledge-store-contract.md +197 -0
  50. package/context/contracts/planning-contract.md +4 -1
  51. package/context/contracts/review-contract.md +7 -3
  52. package/context/contracts/sandbox-policy.md +2 -0
  53. package/context/contracts/standing-directives.md +13 -0
  54. package/context/contracts/verification-contract.md +11 -1
  55. package/context/contracts/work-item-contract.md +3 -1
  56. package/context/deferred/parallelization.md +1 -1
  57. package/context/gate-awareness.md +4 -4
  58. package/context/scripts/git-status.sh +2 -2
  59. package/context/scripts/hooks/config-protection.js +502 -2
  60. package/context/scripts/hooks/lib/config-protection-remedies.js +69 -0
  61. package/context/scripts/hooks/run-hook.js +14 -1
  62. package/context/scripts/hooks/stop-goal-fit.js +1140 -99
  63. package/context/scripts/hooks/workflow-steering.js +212 -6
  64. package/context/settings/assignment-provider-settings.json +33 -0
  65. package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
  66. package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +35 -0
  67. package/docs/adr/0019-kit-dependency-ownership.md +62 -0
  68. package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +290 -0
  69. package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +221 -0
  70. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +463 -0
  71. package/docs/agent-system-guidebook.md +3 -3
  72. package/docs/agent-usage-feedback-loop.md +6 -6
  73. package/docs/context-map.md +10 -7
  74. package/docs/decisions/decision-records.md +57 -0
  75. package/docs/decisions/index.md +17 -0
  76. package/docs/decisions/knowledge-store-provider.md +51 -0
  77. package/docs/decisions/model-routing.md +63 -0
  78. package/docs/decisions/promotion-gate.md +52 -0
  79. package/docs/decisions/standing-directives.md +66 -0
  80. package/docs/developer-architecture.md +1 -1
  81. package/docs/fixture-ownership.md +4 -0
  82. package/docs/flow-kit-repository-contract.md +5 -5
  83. package/docs/getting-started.md +12 -2
  84. package/docs/integrations/harness-install.md +2 -1
  85. package/docs/kit-authoring-guide.md +64 -5
  86. package/docs/knowledge-kit.md +5 -1
  87. package/docs/learnings/2026-07-improvement-program.md +203 -0
  88. package/docs/migrations.md +6 -5
  89. package/docs/north-star.md +2 -2
  90. package/docs/operating-layers.md +4 -3
  91. package/docs/repository-structure.md +5 -5
  92. package/docs/skills-map.md +8 -8
  93. package/docs/spec/runtime-hook-surface.md +19 -15
  94. package/docs/spikes/graph-provider-2026-07.md +155 -0
  95. package/docs/standards-register.md +7 -7
  96. package/docs/trust-anchor-adoption.md +105 -4
  97. package/docs/work-item-adapters.md +1 -1
  98. package/docs/workflow-artifact-lifecycle.md +72 -9
  99. package/docs/workflow-eval-strategy.md +3 -3
  100. package/docs/workflow-shared-contracts.md +3 -2
  101. package/docs/workflow-usage-guide.md +260 -21
  102. package/evals/acceptance/DEMO-false-completion.md +20 -11
  103. package/evals/acceptance/prove-capture-teeth-declared.sh +15 -15
  104. package/evals/acceptance/prove-capture-teeth.sh +11 -11
  105. package/evals/acceptance/test_kiro_harness.sh +1 -1
  106. package/evals/ci/antigaming-suite.sh +5 -0
  107. package/evals/ci/run-baseline.sh +71 -1
  108. package/evals/fixtures/assignment-provider/actor-a.json +6 -0
  109. package/evals/fixtures/assignment-provider/actor-b.json +6 -0
  110. package/evals/fixtures/assignment-provider/github-issue-claimed.json +27 -0
  111. package/evals/fixtures/assignment-provider/github-issue-unassigned.json +7 -0
  112. package/evals/fixtures/assignment-provider/liveness-fresh.json +9 -0
  113. package/evals/fixtures/assignment-provider/liveness-stale.json +9 -0
  114. package/evals/fixtures/flow-kit-repository/invalid-bad-dependency/flows/review.flow.json +26 -0
  115. package/evals/fixtures/flow-kit-repository/invalid-bad-dependency/kit.json +13 -0
  116. package/evals/fixtures/flow-kit-repository/valid-with-dependency/flows/review.flow.json +26 -0
  117. package/evals/fixtures/flow-kit-repository/valid-with-dependency/kit.json +13 -0
  118. package/evals/fixtures/hook-influence/cases.json +32 -0
  119. package/evals/fixtures/pull-work-provider/github-issues.json +6 -6
  120. package/evals/fixtures/trust-reconcile-exploits/fabricated-attestation.json +59 -0
  121. package/evals/fixtures/trust-reconcile-exploits/no-label-bypass.json +36 -0
  122. package/evals/fixtures/trust-reconcile-exploits/skip-assumed-bypass.json +59 -0
  123. package/evals/fixtures/trust-reconcile-exploits/status-misassertion.json +48 -0
  124. package/evals/fixtures/trust-reconcile-exploits/waived-command-check.json +44 -0
  125. package/evals/fixtures/trust-reconcile-mixed-bundle/mixed-bundle.json +159 -0
  126. package/evals/fixtures/trust-reconcile-ws3/ws3-bundle.json +920 -0
  127. package/evals/integration/test_actor_identity.sh +254 -0
  128. package/evals/integration/test_assignment_provider_github.sh +318 -0
  129. package/evals/integration/test_assignment_provider_local_file.sh +222 -0
  130. package/evals/integration/test_builder_step_producers.sh +33 -23
  131. package/evals/integration/test_bundle_install.sh +22 -16
  132. package/evals/integration/test_bundle_lifecycle.sh +11 -11
  133. package/evals/integration/test_captured_fail_reconciliation.sh +50 -50
  134. package/evals/integration/test_checkpoint_signing.sh +43 -0
  135. package/evals/integration/test_claim_lookup.sh +4 -4
  136. package/evals/integration/test_codex_hook_resolution.sh +114 -0
  137. package/evals/integration/test_command_log_concurrency.sh +8 -8
  138. package/evals/integration/test_command_log_fork_classification.sh +3 -3
  139. package/evals/integration/test_command_log_integrity.sh +16 -16
  140. package/evals/integration/test_console_learning_projection.sh +1 -1
  141. package/evals/integration/test_critique_supersession_roundtrip.sh +182 -0
  142. package/evals/integration/test_dual_emit_flow_step.sh +145 -0
  143. package/evals/integration/test_enforcer_expects_driven.sh +12 -12
  144. package/evals/integration/test_evidence_capture_hook.sh +14 -13
  145. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  146. package/evals/integration/test_flow_agents_statusline.sh +2 -2
  147. package/evals/integration/test_flow_kit_install_git.sh +52 -0
  148. package/evals/integration/test_flow_kit_repository.sh +2 -0
  149. package/evals/integration/test_flowdef_session_activation.sh +6 -6
  150. package/evals/integration/test_flowdef_union_floor_regression.sh +429 -0
  151. package/evals/integration/test_gate_bypass_chain.sh +52 -40
  152. package/evals/integration/test_gate_lockdown.sh +97 -73
  153. package/evals/integration/test_gate_review_inquiry_records.sh +3 -3
  154. package/evals/integration/test_goal_fit_escape_hatch.sh +10 -10
  155. package/evals/integration/test_goal_fit_ghost_session.sh +104 -0
  156. package/evals/integration/test_goal_fit_hook.sh +32 -32
  157. package/evals/integration/test_goal_fit_rederive.sh +9 -9
  158. package/evals/integration/test_hook_category_behaviors.sh +103 -0
  159. package/evals/integration/test_hook_influence_cases.sh +1 -0
  160. package/evals/integration/test_install_merge.sh +262 -1
  161. package/evals/integration/test_kit_identity_trust.sh +6 -6
  162. package/evals/integration/test_liveness_conflict_injection.sh +587 -0
  163. package/evals/integration/test_liveness_heartbeat.sh +677 -0
  164. package/evals/integration/test_liveness_verdict.sh +394 -0
  165. package/evals/integration/test_local_flow_kit_install.sh +15 -0
  166. package/evals/integration/test_phase_map_and_gate_claim.sh +63 -11
  167. package/evals/integration/test_promote_gate.sh +178 -0
  168. package/evals/integration/test_publish_delivery.sh +23 -6
  169. package/evals/integration/test_pull_work_assignment_join.sh +132 -0
  170. package/evals/integration/test_pull_work_liveness_preflight.sh +394 -0
  171. package/evals/integration/test_pull_work_provider.sh +2 -2
  172. package/evals/integration/test_reconcile_soundness.sh +37 -13
  173. package/evals/integration/test_resolvefirststep_security.sh +22 -0
  174. package/evals/integration/test_runtime_adapter_activation.sh +87 -18
  175. package/evals/integration/test_session_resume_roundtrip.sh +147 -0
  176. package/evals/integration/test_sidecar_field_preservation.sh +249 -0
  177. package/evals/integration/test_trust_reconcile.sh +11 -10
  178. package/evals/integration/test_trust_reconcile_manifest.sh +79 -0
  179. package/evals/integration/test_trust_reconcile_mixed_bundle.sh +96 -0
  180. package/evals/integration/test_trust_reconcile_negatives.sh +759 -0
  181. package/evals/integration/test_usage_feedback_global.sh +5 -5
  182. package/evals/integration/test_validate_artifacts_portability.sh +60 -0
  183. package/evals/integration/test_verify_cli.sh +2 -2
  184. package/evals/integration/test_veritas_governance_kit.sh +117 -0
  185. package/evals/integration/test_workflow_artifact_cleanup_audit.sh +26 -1
  186. package/evals/integration/test_workflow_artifacts.sh +33 -0
  187. package/evals/integration/test_workflow_sidecar_writer.sh +802 -74
  188. package/evals/integration/test_workflow_steering_hook.sh +175 -5
  189. package/evals/lib/codex-provider.sh +1 -1
  190. package/evals/lib/node.sh +19 -1
  191. package/evals/run.sh +32 -0
  192. package/evals/static/test_decisions.sh +150 -0
  193. package/evals/static/test_flowdef_codeowners_coverage.sh +62 -0
  194. package/evals/static/test_knowledge_providers.sh +23 -0
  195. package/evals/static/test_package.sh +19 -10
  196. package/evals/static/test_universal_bundles.sh +48 -3
  197. package/evals/static/test_workflow_skills.sh +41 -8
  198. package/kits/builder/flows/build.flow.json +3 -74
  199. package/kits/builder/flows/publish-learn.flow.json +90 -0
  200. package/kits/builder/kit.json +11 -0
  201. package/kits/builder/skills/builder-shape/SKILL.md +1 -1
  202. package/kits/builder/skills/deliver/SKILL.md +32 -11
  203. package/kits/builder/skills/design-probe/SKILL.md +4 -4
  204. package/kits/builder/skills/evidence-gate/SKILL.md +26 -1
  205. package/kits/builder/skills/execute-plan/SKILL.md +2 -2
  206. package/kits/builder/skills/fix-bug/SKILL.md +2 -0
  207. package/kits/builder/skills/gate-review/SKILL.md +6 -6
  208. package/kits/builder/skills/idea-to-backlog/SKILL.md +1 -1
  209. package/kits/builder/skills/learning-review/SKILL.md +7 -7
  210. package/kits/builder/skills/pickup-probe/SKILL.md +5 -4
  211. package/kits/builder/skills/plan-work/SKILL.md +27 -5
  212. package/kits/builder/skills/pull-work/SKILL.md +148 -5
  213. package/kits/builder/skills/release-readiness/SKILL.md +4 -4
  214. package/kits/builder/skills/review-work/SKILL.md +24 -1
  215. package/kits/builder/skills/tdd-workflow/SKILL.md +2 -0
  216. package/kits/builder/skills/verify-work/SKILL.md +8 -1
  217. package/kits/catalog.json +6 -0
  218. package/kits/knowledge/kit.json +35 -0
  219. package/kits/knowledge/providers/conformance/fixtures/git-repo/CONTEXT.md +12 -0
  220. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/old-sprocket-shape.md +13 -0
  221. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/sprocket-shape.md +14 -0
  222. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/widget-format.md +14 -0
  223. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/learnings/fixture-learning.md +7 -0
  224. package/kits/knowledge/providers/conformance/fixtures/work-item/issues.json +30 -0
  225. package/kits/knowledge/providers/conformance/suite.test.js +125 -0
  226. package/kits/knowledge/providers/git-repo/index.js +236 -0
  227. package/kits/knowledge/providers/health/health-pass.test.js +99 -0
  228. package/kits/knowledge/providers/health/index.js +153 -0
  229. package/kits/knowledge/providers/index.js +24 -0
  230. package/kits/knowledge/providers/lib/model.js +91 -0
  231. package/kits/knowledge/providers/lib/schema-validate.js +119 -0
  232. package/kits/knowledge/providers/markdown-vault/index.js +169 -0
  233. package/kits/knowledge/providers/work-item/index.js +204 -0
  234. package/kits/veritas-governance/adapter/readiness-to-trust-bundle.mjs +178 -0
  235. package/kits/veritas-governance/docs/README.md +75 -0
  236. package/kits/veritas-governance/fixtures/readiness/not-ready.readiness-report.json +4645 -0
  237. package/kits/veritas-governance/fixtures/readiness/ready.readiness-report.json +4403 -0
  238. package/kits/veritas-governance/flows/readiness-check.flow.json +35 -0
  239. package/kits/veritas-governance/kit.json +16 -0
  240. package/package.json +11 -5
  241. package/packaging/README.md +2 -2
  242. package/packaging/conformance/fixtures/config-protection--allow-read-state-json.json +20 -0
  243. package/packaging/conformance/fixtures/config-protection--block-write-state-json.json +20 -0
  244. package/packaging/conformance/fixtures/stop-goal-fit--block-bundle-disputed-claim.json +1 -1
  245. package/packaging/conformance/fixtures/stop-goal-fit--block-capture-contradicts-claimed-pass.json +1 -1
  246. package/packaging/manifest.json +9 -10
  247. package/schemas/assignment-provider-settings.schema.json +125 -0
  248. package/schemas/decision-record.schema.json +109 -0
  249. package/schemas/knowledge/edge.schema.json +54 -0
  250. package/schemas/knowledge/health-report.schema.json +45 -0
  251. package/schemas/knowledge/node.schema.json +49 -0
  252. package/schemas/knowledge/proposal.schema.json +53 -0
  253. package/schemas/workflow-evidence.schema.json +1 -1
  254. package/schemas/workflow-state.schema.json +5 -0
  255. package/scripts/README.md +8 -3
  256. package/scripts/check-content-boundary.cjs +1 -1
  257. package/scripts/check-decisions.cjs +356 -0
  258. package/scripts/ci/derive-claim-status.mjs +74 -0
  259. package/scripts/ci/trust-reconcile.js +957 -103
  260. package/scripts/git-status.sh +2 -2
  261. package/scripts/hooks/claude-telemetry-hook.js +37 -3
  262. package/scripts/hooks/codex-telemetry-hook.js +36 -2
  263. package/scripts/hooks/config-protection.js +102 -30
  264. package/scripts/hooks/evidence-capture.js +3 -3
  265. package/scripts/hooks/lib/actor-identity.js +368 -0
  266. package/scripts/hooks/lib/config-protection-remedies.js +69 -0
  267. package/scripts/hooks/lib/liveness-heartbeat.js +361 -0
  268. package/scripts/hooks/lib/liveness-policy.js +127 -0
  269. package/scripts/hooks/lib/liveness-read.js +105 -12
  270. package/scripts/hooks/lib/liveness-write.js +46 -0
  271. package/scripts/hooks/lib/local-artifact-paths.js +12 -6
  272. package/scripts/hooks/opencode-telemetry-hook.js +27 -1
  273. package/scripts/hooks/pi-telemetry-hook.js +27 -1
  274. package/scripts/hooks/stop-goal-fit.js +157 -26
  275. package/scripts/hooks/workflow-steering.js +77 -3
  276. package/scripts/install-codex-home.sh +138 -38
  277. package/scripts/statusline/flow-agents-statusline.js +2 -2
  278. package/src/cli/assignment-provider.ts +845 -0
  279. package/src/cli/console-learning-projection.ts +3 -2
  280. package/src/cli/effective-assignment-provider-settings.ts +112 -0
  281. package/src/cli/init.ts +101 -21
  282. package/src/cli/kit.ts +37 -6
  283. package/src/cli/public-api.test.mjs +58 -6
  284. package/src/cli/pull-work-provider.ts +1 -1
  285. package/src/cli/trust-bundle-policy-order.test.mjs +87 -0
  286. package/src/cli/usage-feedback.ts +3 -3
  287. package/src/cli/validate-hook-influence.ts +1 -0
  288. package/src/cli/validate-workflow-artifacts.ts +27 -7
  289. package/src/cli/veritas-readiness-adapter.test.mjs +267 -0
  290. package/src/cli/workflow-artifact-cleanup-audit.ts +35 -2
  291. package/src/cli/workflow-sidecar.ts +933 -168
  292. package/src/cli.ts +6 -0
  293. package/src/flow-kit/validate.ts +74 -1
  294. package/src/index.ts +6 -2
  295. package/src/lib/flow-resolver.ts +123 -55
  296. package/src/lib/local-artifact-root.ts +24 -5
  297. package/src/lib/workflow-learning-projection.ts +2 -2
  298. package/src/runtime-adapters.ts +61 -15
  299. package/src/tools/build-universal-bundles.ts +49 -16
  300. package/src/tools/generate-context-map.ts +10 -8
  301. package/src/tools/validate-source-tree.ts +24 -1
@@ -0,0 +1,845 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { createRequire } from "node:module";
5
+ import { fileURLToPath } from "node:url";
6
+ import { parseArgs, flagString, type ParsedArgs } from "../lib/args.js";
7
+ import { readJson, writeJson, isoNow } from "../lib/fs.js";
8
+
9
+ // ─── AssignmentProvider CLI (#290) ──────────────────────────────────────────
10
+ // context/contracts/assignment-provider-contract.md is the governing vocabulary doc for this
11
+ // module. Read it first if the shapes below are unclear — it documents the five operations, the
12
+ // assignment ⋈ liveness join table, the lazy-correction transition table, and the versioned
13
+ // claim-record format this file implements.
14
+ //
15
+ // Three distinct "claim" concepts exist in this repo (see the contract doc's terminology
16
+ // callout): this file implements the *assignment* claim only — never the ADR 0012 *liveness*
17
+ // claim (workflow-sidecar.ts `liveness claim` / `freshHolders`) or the Hachure *trust* claim
18
+ // (workflow-sidecar.ts `claim <id> <dir>` / `claimLookup`). Always qualify "claim" in prose here.
19
+ //
20
+ // GitHub mutation path is render-don't-execute (Design Decision 1): every `render-*` subcommand
21
+ // is a pure function — no I/O beyond reading its `--input-json`/`--actor-json` inputs — that
22
+ // emits the exact `gh` argv the calling skill must run verbatim via its Bash tool. This file must
23
+ // never itself shell out to `gh` (no execFileSync/spawn/exec to `gh` anywhere below).
24
+
25
+ type AnyObj = Record<string, unknown>;
26
+
27
+ type ActorStruct = {
28
+ runtime: string;
29
+ session_id: string;
30
+ host: string;
31
+ human?: string | null;
32
+ };
33
+
34
+ type AssignmentClaimRecordStatus = "claimed" | "released" | "superseded";
35
+
36
+ type AssignmentAuditEntry = {
37
+ at: string;
38
+ transition: "claim" | "release" | "supersede";
39
+ from_actor?: ActorStruct | null;
40
+ to_actor?: ActorStruct | null;
41
+ reason?: string;
42
+ };
43
+
44
+ /**
45
+ * The versioned claim-record shape from the contract doc's "Versioned claim-record format"
46
+ * section (Design Decision 2). `schema_version` is bumped only on an incompatible change, per
47
+ * artifact-contract.md's existing sidecar rule.
48
+ */
49
+ type AssignmentClaimRecord = {
50
+ schema_version: "1.0";
51
+ role: "AssignmentClaimRecord";
52
+ subject_id: string;
53
+ actor: ActorStruct;
54
+ claimed_at: string;
55
+ ttl_seconds: number;
56
+ branch: string;
57
+ artifact_dir: string;
58
+ status: AssignmentClaimRecordStatus;
59
+ audit_trail?: AssignmentAuditEntry[];
60
+ };
61
+
62
+ type FreshHolder = { actor: string; lastAt: string; ttlSeconds: number; fresh: boolean };
63
+
64
+ type EffectiveState = "held" | "reclaimable" | "human-held" | "free";
65
+
66
+ /** Provider-neutral assignment-layer read, before any liveness join (contract doc's status()). */
67
+ type AssignmentStatus = {
68
+ subject_id: string;
69
+ provider: "local-file" | "github";
70
+ assignee: string | null;
71
+ record: AssignmentClaimRecord | null;
72
+ has_claim_label?: boolean;
73
+ };
74
+
75
+ type GithubIssueDoc = {
76
+ number?: number;
77
+ assignees?: Array<{ login?: string } | string>;
78
+ labels?: Array<{ name?: string } | string>;
79
+ comments?: Array<{ id?: number; body?: string }>;
80
+ state?: string;
81
+ };
82
+
83
+ type RenderClaimInput = {
84
+ repo?: { owner?: string; name?: string };
85
+ issue_number?: number;
86
+ assignee_login?: string;
87
+ existing_assignee_login?: string;
88
+ label_name?: string;
89
+ claim_comment_marker?: string;
90
+ ttl_seconds?: number;
91
+ branch?: string;
92
+ artifact_dir?: string;
93
+ existing_comment_id?: number;
94
+ previous_record?: AssignmentClaimRecord;
95
+ reason?: string;
96
+ };
97
+
98
+ const DEFAULT_LABEL_NAME = "agent:claimed";
99
+ const CLAIM_COMMENT_MARKER_DEFAULT = "<!-- flow-agents:assignment-claim -->";
100
+
101
+ /**
102
+ * Delegate to the shared pure-CJS resolver (scripts/hooks/lib/actor-identity.js), mirroring the
103
+ * exact createRequire pattern `workflow-sidecar.ts`'s loadActorIdentityHelper() already uses for
104
+ * this module. Deliberately NO inline duplicate fallback — same rationale as that function: if
105
+ * the module fails to load, that failure must surface loudly, never silently degrade to a forked
106
+ * actor concept or a second sanitizer.
107
+ */
108
+ function loadActorIdentityHelper(): {
109
+ resolveActor: (env: NodeJS.ProcessEnv) => { actor: string; source: string };
110
+ serializeActor: (actor: Partial<ActorStruct> | undefined) => string;
111
+ isUnresolvedActor: (actor: string) => boolean;
112
+ sanitizeSegment: (value: unknown) => string;
113
+ detectRuntime: (env: NodeJS.ProcessEnv) => string;
114
+ } {
115
+ const _req = createRequire(import.meta.url);
116
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/actor-identity.js");
117
+ return _req(helperPath) as {
118
+ resolveActor: (env: NodeJS.ProcessEnv) => { actor: string; source: string };
119
+ serializeActor: (actor: Partial<ActorStruct> | undefined) => string;
120
+ isUnresolvedActor: (actor: string) => boolean;
121
+ sanitizeSegment: (value: unknown) => string;
122
+ detectRuntime: (env: NodeJS.ProcessEnv) => string;
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Delegate to the shared pure-CJS liveness reader (scripts/hooks/lib/liveness-read.js), same
128
+ * createRequire idiom as loadActorIdentityHelper() above. Used only for the join computation —
129
+ * this module never writes liveness events (that stays the ADR 0012 lifecycle's job).
130
+ */
131
+ function loadLivenessReadHelper(): {
132
+ readLivenessEvents: (streamPath: string) => AnyObj[];
133
+ freshHolders: (events: AnyObj[], slug: string, selfActor: string, nowMs: number) => FreshHolder[];
134
+ } {
135
+ const _req = createRequire(import.meta.url);
136
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-read.js");
137
+ return _req(helperPath) as {
138
+ readLivenessEvents: (streamPath: string) => AnyObj[];
139
+ freshHolders: (events: AnyObj[], slug: string, selfActor: string, nowMs: number) => FreshHolder[];
140
+ };
141
+ }
142
+
143
+ function loadJsonInput(file: string): unknown {
144
+ return file === "-" ? JSON.parse(fs.readFileSync(0, "utf8")) : readJson(file);
145
+ }
146
+
147
+ function requireFlag(args: ParsedArgs, name: string): string {
148
+ const value = flagString(args.flags, name);
149
+ if (!value) throw new Error(`--${name} is required`);
150
+ return value;
151
+ }
152
+
153
+ /**
154
+ * Build an ActorStruct from an already-loaded JSON value (used for --actor-json,
155
+ * --from-actor-json, --to-actor-json). Fails loud on a malformed/incomplete struct — a
156
+ * durable claim record must never carry a partial actor identity.
157
+ */
158
+ function actorStructFromJson(data: unknown, sourceLabel: string): ActorStruct {
159
+ if (typeof data !== "object" || data === null) throw new Error(`${sourceLabel} must contain an object`);
160
+ const struct = data as Partial<ActorStruct>;
161
+ if (!struct.runtime || !struct.session_id || !struct.host) throw new Error(`${sourceLabel} must include runtime, session_id, and host`);
162
+ return {
163
+ runtime: String(struct.runtime),
164
+ session_id: String(struct.session_id),
165
+ host: String(struct.host),
166
+ human: struct.human != null && String(struct.human).trim() !== "" ? String(struct.human) : null,
167
+ };
168
+ }
169
+
170
+ function loadActorStructFromFile(file: string): ActorStruct {
171
+ return actorStructFromJson(loadJsonInput(file), `actor JSON (${file})`);
172
+ }
173
+
174
+ /**
175
+ * Resolve the acting actor for a local-file mutation: --actor-json is the deterministic,
176
+ * fixture-friendly path (used by evals and any caller that already knows its own struct);
177
+ * when omitted, auto-derive from the live environment via the shared resolver, mirroring
178
+ * (never forking) the exact struct fields serializeActor() already defines.
179
+ */
180
+ function loadActorStruct(args: ParsedArgs): ActorStruct {
181
+ const actorJsonPath = flagString(args.flags, "actor-json");
182
+ if (actorJsonPath) return loadActorStructFromFile(actorJsonPath);
183
+ const helper = loadActorIdentityHelper();
184
+ const resolved = helper.resolveActor(process.env);
185
+ if (helper.isUnresolvedActor(resolved.actor)) throw new Error("could not resolve an actor identity (no --actor-json and no resolvable environment actor); pass --actor-json explicitly");
186
+ return { runtime: helper.detectRuntime(process.env), session_id: resolved.actor, host: os.hostname(), human: null };
187
+ }
188
+
189
+ function assignmentFilePath(artifactRoot: string, subjectId: string): string {
190
+ const sanitized = loadActorIdentityHelper().sanitizeSegment(subjectId);
191
+ return path.join(artifactRoot, "assignment", `${sanitized}.json`);
192
+ }
193
+
194
+ function readLocalRecord(artifactRoot: string, subjectId: string): AssignmentClaimRecord | null {
195
+ const file = assignmentFilePath(artifactRoot, subjectId);
196
+ if (!fs.existsSync(file)) return null;
197
+ let data: unknown;
198
+ try {
199
+ data = readJson(file);
200
+ } catch (error) {
201
+ // Fail loud: a corrupt claim record must never be silently treated as "no claim" — that
202
+ // would be a fail-open path that could let a second claim silently overwrite a real one.
203
+ throw new Error(`assignment record is corrupt, refusing to proceed: ${file}: ${(error as Error).message}`);
204
+ }
205
+ if (typeof data !== "object" || data === null) throw new Error(`assignment record is not an object: ${file}`);
206
+ const record = data as AssignmentClaimRecord;
207
+ if (record.schema_version !== "1.0") throw new Error(`${file}: unsupported schema_version ${String((record as AnyObj).schema_version)}`);
208
+ return record;
209
+ }
210
+
211
+ function writeLocalRecord(artifactRoot: string, subjectId: string, record: AssignmentClaimRecord): void {
212
+ // writeJson throws on any mkdir/writeFileSync failure; that error is intentionally allowed to
213
+ // propagate to main()'s top-level try/catch and exit non-zero. Durable writes must fail loud,
214
+ // never fail open (artifact-contract.md).
215
+ writeJson(assignmentFilePath(artifactRoot, subjectId), record);
216
+ }
217
+
218
+ /**
219
+ * Synchronous busy-sleep via Atomics.wait on a throwaway SharedArrayBuffer — Node.js (unlike
220
+ * browser engines) permits Atomics.wait on the main thread, so this gives withSubjectLock() a
221
+ * true blocking sleep without going async. Kept to a small, bounded delay (see withSubjectLock's
222
+ * spin loop) — never used outside the lock-acquire spin below.
223
+ */
224
+ function sleepSync(ms: number): void {
225
+ const sab = new SharedArrayBuffer(4);
226
+ const ia = new Int32Array(sab);
227
+ Atomics.wait(ia, 0, 0, ms);
228
+ }
229
+
230
+ function subjectLockDir(artifactRoot: string, subjectId: string): string {
231
+ const assignmentDir = path.join(artifactRoot, "assignment");
232
+ fs.mkdirSync(assignmentDir, { recursive: true });
233
+ const sanitized = loadActorIdentityHelper().sanitizeSegment(subjectId);
234
+ return path.join(assignmentDir, `.${sanitized}.lockdir`);
235
+ }
236
+
237
+ /**
238
+ * F1 fix (fix-plan iteration 1, CRITICAL): claimLocalFile/releaseLocalFile/supersedeLocalFile were
239
+ * a plain read -> compare-actor -> write with no lock, so two concurrently-launched OS processes
240
+ * could both read "no conflicting claim" before either wrote, and the second write would silently
241
+ * clobber the first with zero error and zero audit-trail entry for the loser (reproduced 29/40
242
+ * races against the built CLI). This mirrors the EXACT mechanism `withLock` already uses in
243
+ * workflow-sidecar.ts:908 for the same class of shared-state mutation — atomic `fs.mkdirSync`
244
+ * lockdir create as the mutual-exclusion primitive, EEXIST-spin with a staleness-reclaim check
245
+ * (a lock directory older than the stale threshold is presumed abandoned by a crashed process and
246
+ * is reclaimed rather than waited on forever) and a bounded deadline, `finally` rmSync release —
247
+ * as a small LOCAL helper (not a cross-import of that private function, which would pull the
248
+ * entire workflow-sidecar module in for one primitive). Deliberately synchronous (sleepSync's
249
+ * Atomics.wait spin, not setTimeout/await) so claim/release/supersede can stay sync `number`
250
+ * -returning functions and the CLI dispatcher (src/cli.ts, `number | Promise<number>`) does not
251
+ * need any ripple to async. On lock-acquire failure (any error other than a live contested lock,
252
+ * or a timeout waiting one out) this THROWS — never a silent no-op — "fail loud, never fail-open"
253
+ * (artifact-contract.md). Wrap the ENTIRE read-modify-write body (the existing-claim check AND
254
+ * the write) of all three local-file mutators in this, since all three mutate the same record
255
+ * file for a given subject.
256
+ */
257
+ function withSubjectLock<T>(artifactRoot: string, subjectId: string, body: () => T): T {
258
+ const lockDir = subjectLockDir(artifactRoot, subjectId);
259
+ const staleMs = Number(process.env.FLOW_AGENTS_ASSIGNMENT_STALE_LOCK_MS ?? 5 * 60 * 1000);
260
+ const deadline = Date.now() + 30000;
261
+ while (true) {
262
+ try {
263
+ fs.mkdirSync(lockDir);
264
+ break;
265
+ } catch (error) {
266
+ const lockError = error as NodeJS.ErrnoException;
267
+ if (lockError.code !== "EEXIST") {
268
+ throw new Error(`failed to acquire assignment lock for subject ${subjectId}: ${lockDir}: ${lockError.message || lockError.code || String(lockError)}`);
269
+ }
270
+ try {
271
+ const stat = fs.statSync(lockDir);
272
+ if (staleMs > 0 && Date.now() - stat.mtimeMs > staleMs) {
273
+ fs.rmSync(lockDir, { recursive: true, force: true });
274
+ continue;
275
+ }
276
+ } catch (statError) {
277
+ if ((statError as NodeJS.ErrnoException).code === "ENOENT") continue; // lock released between mkdir/EEXIST and stat; retry immediately
278
+ throw statError;
279
+ }
280
+ if (Date.now() > deadline) {
281
+ throw new Error(`timed out waiting for assignment lock for subject ${subjectId}: ${lockDir}`);
282
+ }
283
+ sleepSync(20);
284
+ }
285
+ }
286
+ try {
287
+ return body();
288
+ } finally {
289
+ fs.rmSync(lockDir, { recursive: true, force: true });
290
+ }
291
+ }
292
+
293
+ /**
294
+ * The assignment ⋈ liveness join (contract doc's "assignment ⋈ liveness join" section, ADR 0021
295
+ * §1). Pure function: `{ assignment, freshHoldersList, selfActor, nowMs }` -> one of five
296
+ * effective states (held/reclaimable/human-held/free — "held" covers both the plain and
297
+ * assignment-lagging rows, matching the contract table's own repeated "held" label).
298
+ *
299
+ * The human-assignee gate (AC11, Design Decision 3) reads `record.actor.human` being *present*,
300
+ * never a username heuristic — an idle human assignment is always `human-held`, regardless of
301
+ * idle duration, and is never auto-reclaimable by this function.
302
+ *
303
+ * `nowMs` (F3 fix, fix-plan iteration 1) is the SAME resolved "now" the caller already threads
304
+ * into `freshHolders()` (the `--now` override, when passed, else `Date.now()`) — passing it
305
+ * through here too means `--now` deterministically governs idle_days as well as liveness
306
+ * freshness, rather than idle_days silently reading the real wall clock regardless of `--now`.
307
+ */
308
+ function computeEffectiveState(assignment: AssignmentStatus, freshHoldersList: FreshHolder[], selfActor: string | undefined, nowMs: number): {
309
+ effective_state: EffectiveState;
310
+ reason: string;
311
+ holder?: { actor?: string; assignee?: string | null; idle_days?: number | null; last_at?: string };
312
+ } {
313
+ const record = assignment.record && assignment.record.status === "claimed" ? assignment.record : null;
314
+ const isAssigned = Boolean(assignment.assignee) || Boolean(record);
315
+
316
+ if (!isAssigned) {
317
+ if (freshHoldersList.length > 0) {
318
+ const holder = freshHoldersList[0];
319
+ return { effective_state: "held", reason: "liveness_claim_present_assignment_lagging", holder: { actor: holder.actor, last_at: holder.lastAt } };
320
+ }
321
+ return { effective_state: "free", reason: "no_assignment_no_liveness" };
322
+ }
323
+
324
+ if (record && record.actor && record.actor.human != null && String(record.actor.human).trim() !== "") {
325
+ // F3 fix (fix-plan iteration 1): idle_days is computed from the SAME resolved `now` the
326
+ // caller already threads into freshHolders() (the `--now` override, when passed), not a
327
+ // second, independent Date.now() read — so `--now` governs both liveness freshness AND
328
+ // idle-based classification deterministically, rather than only the former.
329
+ const idleMs = nowMs - Date.parse(record.claimed_at);
330
+ const idleDays = Number.isFinite(idleMs) ? Math.floor(idleMs / 86_400_000) : null;
331
+ return { effective_state: "human-held", reason: "assignee_is_human", holder: { actor: assignment.assignee ?? undefined, idle_days: idleDays } };
332
+ }
333
+
334
+ if (!record) {
335
+ // Assignee present (e.g. a raw GitHub assignee) with no parseable machine claim record: we
336
+ // cannot positively identify this as a stale agent session, so the conservative ask-first
337
+ // default treats it the same as an explicit human assignment rather than risk reclaiming a
338
+ // human's work (Design Decision 3 / ADR 0021 §6's "never auto-reclaim" non-goal).
339
+ return { effective_state: "human-held", reason: "assignee_without_claim_record", holder: { assignee: assignment.assignee } };
340
+ }
341
+
342
+ const holderActorKey = loadActorIdentityHelper().serializeActor(record.actor);
343
+ if (selfActor && holderActorKey === selfActor) return { effective_state: "held", reason: "self_is_holder", holder: { actor: holderActorKey } };
344
+
345
+ const fresh = freshHoldersList.find((holder) => holder.actor === holderActorKey);
346
+ if (fresh) return { effective_state: "held", reason: "fresh_liveness_heartbeat", holder: { actor: holderActorKey, last_at: fresh.lastAt } };
347
+ return { effective_state: "reclaimable", reason: "assignment_present_liveness_stale_or_absent", holder: { actor: holderActorKey, last_at: record.claimed_at } };
348
+ }
349
+
350
+ function namesOf(list: unknown, key: string): string[] {
351
+ if (!Array.isArray(list)) return [];
352
+ return list.map((item) => typeof item === "string" ? item : (item && typeof item === "object" ? String((item as AnyObj)[key] ?? "") : "")).filter(Boolean);
353
+ }
354
+
355
+ /**
356
+ * F2 fix (fix-plan iteration 1, HIGH): every string field on a GitHub claim record is sourced
357
+ * from a parsed, attacker-postable issue comment (any GitHub user who can comment can forge a
358
+ * claim-marker comment with a hostile fenced JSON block — commenting requires no elevated
359
+ * access, unlike the assignee/label mutations this contract otherwise gates). Mirrors
360
+ * workflow-sidecar.ts's `stripControlCharsForDisplay` (the established #287/#320 mitigation for
361
+ * exactly this class of untrusted multi-writer/attacker-postable display input): strips C0
362
+ * (0x00-0x1F), DEL (0x7F), and C1 (0x80-0x9F, which includes ANSI-CSI-adjacent bytes), then caps
363
+ * length (this repo's 64/240 convention: 64 for id-like fields, 240 for free text). Display-only
364
+ * — sanitizing the string CONTENT never changes presence/emptiness for any well-formed value, so
365
+ * it does not perturb computeEffectiveState()'s human-assignee presence gate or any equality
366
+ * check downstream.
367
+ */
368
+ function sanitizeDisplayField(value: unknown, maxLength: number): string {
369
+ const stripped = String(value ?? "").replace(/[\u0000-\u001F\u007F-\u009F]/g, "");
370
+ return stripped.length > maxLength ? stripped.slice(0, maxLength) : stripped;
371
+ }
372
+
373
+ function sanitizeActorForDisplay(actor: ActorStruct): ActorStruct {
374
+ return {
375
+ runtime: sanitizeDisplayField(actor.runtime, 64),
376
+ session_id: sanitizeDisplayField(actor.session_id, 64),
377
+ host: sanitizeDisplayField(actor.host, 64),
378
+ human: actor.human != null ? sanitizeDisplayField(actor.human, 240) : (actor.human ?? null),
379
+ };
380
+ }
381
+
382
+ function sanitizeAuditEntryForDisplay(entry: AssignmentAuditEntry): AssignmentAuditEntry {
383
+ return {
384
+ ...entry,
385
+ from_actor: entry.from_actor ? sanitizeActorForDisplay(entry.from_actor) : (entry.from_actor ?? null),
386
+ to_actor: entry.to_actor ? sanitizeActorForDisplay(entry.to_actor) : (entry.to_actor ?? null),
387
+ reason: entry.reason != null ? sanitizeDisplayField(entry.reason, 240) : entry.reason,
388
+ };
389
+ }
390
+
391
+ /**
392
+ * The single choke point (per the code review's explicit recommendation) every parsed GitHub
393
+ * claim record passes through before any string in it leaves this module in any form — both
394
+ * `status`/`list`'s JSON output (this fix) and any future consumer of `extractClaimRecord`'s
395
+ * return value inherit clean values from here, mirroring the #320 `computeConflict()` precedent
396
+ * of sanitizing once at construction rather than at each print site.
397
+ */
398
+ function sanitizeClaimRecordForDisplay(record: AssignmentClaimRecord): AssignmentClaimRecord {
399
+ return {
400
+ ...record,
401
+ subject_id: sanitizeDisplayField(record.subject_id, 64),
402
+ branch: sanitizeDisplayField(record.branch, 240),
403
+ artifact_dir: sanitizeDisplayField(record.artifact_dir, 240),
404
+ actor: sanitizeActorForDisplay(record.actor),
405
+ audit_trail: Array.isArray(record.audit_trail) ? record.audit_trail.map(sanitizeAuditEntryForDisplay) : record.audit_trail,
406
+ };
407
+ }
408
+
409
+ /**
410
+ * Locate the machine-readable claim comment among human comments (via the fixed marker) and
411
+ * extract/validate its fenced JSON block. Fails loud on an unparseable or misversioned record —
412
+ * never silently treats a corrupt comment as "no claim" (same rationale as readLocalRecord()).
413
+ * The returned record's display-surfaced string fields are sanitized (F2 fix, above) before
414
+ * return — this is the single choke point, so schema/role/status checks above still validate
415
+ * the RAW parsed shape (never weakened), and only the string fields are transformed afterward.
416
+ */
417
+ function extractClaimRecord(issue: GithubIssueDoc, marker: string): AssignmentClaimRecord | null {
418
+ const comments = Array.isArray(issue.comments) ? issue.comments : [];
419
+ for (const comment of comments) {
420
+ const body = String(comment.body ?? "");
421
+ const markerIndex = body.indexOf(marker);
422
+ if (markerIndex === -1) continue;
423
+ const fenceMatch = body.slice(markerIndex).match(/```json\s*([\s\S]*?)```/);
424
+ if (!fenceMatch) throw new Error(`claim comment (id ${comment.id ?? "?"}) has the claim marker but no fenced JSON block`);
425
+ let parsed: unknown;
426
+ try {
427
+ parsed = JSON.parse(fenceMatch[1]);
428
+ } catch (error) {
429
+ throw new Error(`claim comment (id ${comment.id ?? "?"}) fenced JSON is unparseable: ${(error as Error).message}`);
430
+ }
431
+ if (typeof parsed !== "object" || parsed === null) throw new Error(`claim comment (id ${comment.id ?? "?"}) fenced JSON is not an object`);
432
+ const record = parsed as AssignmentClaimRecord;
433
+ if (record.schema_version !== "1.0") throw new Error(`claim comment (id ${comment.id ?? "?"}) has unsupported schema_version ${String((record as AnyObj).schema_version)}`);
434
+ if (record.role !== "AssignmentClaimRecord") throw new Error(`claim comment (id ${comment.id ?? "?"}) has unexpected role ${String((record as AnyObj).role)}`);
435
+ return sanitizeClaimRecordForDisplay(record);
436
+ }
437
+ return null;
438
+ }
439
+
440
+ function githubAssignmentStatus(issue: GithubIssueDoc, labelName: string, marker: string): AssignmentStatus {
441
+ const assignees = namesOf(issue.assignees, "login");
442
+ const labels = namesOf(issue.labels, "name");
443
+ const record = extractClaimRecord(issue, marker);
444
+ return {
445
+ subject_id: record?.subject_id ?? "",
446
+ provider: "github",
447
+ assignee: assignees[0] ?? null,
448
+ record,
449
+ has_claim_label: labels.map((label) => label.toLowerCase()).includes(labelName.toLowerCase()),
450
+ };
451
+ }
452
+
453
+ function loadLivenessInputs(args: ParsedArgs): { events: AnyObj[] | null; selfActor: string | undefined } {
454
+ const eventsJsonPath = flagString(args.flags, "liveness-events-json");
455
+ const streamPath = flagString(args.flags, "liveness-stream");
456
+ const selfActor = flagString(args.flags, "self-actor");
457
+ if (eventsJsonPath) {
458
+ const data = loadJsonInput(eventsJsonPath);
459
+ if (!Array.isArray(data)) throw new Error(`--liveness-events-json must contain a JSON array: ${eventsJsonPath}`);
460
+ return { events: data as AnyObj[], selfActor };
461
+ }
462
+ if (streamPath) return { events: loadLivenessReadHelper().readLivenessEvents(streamPath), selfActor };
463
+ return { events: null, selfActor };
464
+ }
465
+
466
+ // ─── local-file: claim | release | supersede (the durable-write path; real I/O by design — no
467
+ // external mutation to defer to a skill for this provider kind, per Design Decision 1) ─────────
468
+
469
+ function claimLocalFile(argv: string[]): number {
470
+ const args = parseArgs(argv);
471
+ const provider = flagString(args.flags, "provider", "local-file");
472
+ if (provider !== "local-file") throw new Error(`claim: --provider must be local-file (use render-claim for github); got ${provider}`);
473
+ const artifactRoot = requireFlag(args, "artifact-root");
474
+ const subjectId = requireFlag(args, "subject-id");
475
+ const actor = loadActorStruct(args);
476
+ const helper = loadActorIdentityHelper();
477
+
478
+ const ttlSecondsRaw = flagString(args.flags, "ttl-seconds", "1800") ?? "1800";
479
+ const ttlSeconds = Number(ttlSecondsRaw);
480
+ if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) throw new Error(`--ttl-seconds must be a positive number; got ${ttlSecondsRaw}`);
481
+ const branch = requireFlag(args, "branch");
482
+ const artifactDir = requireFlag(args, "artifact-dir");
483
+ const reason = flagString(args.flags, "reason") ?? "claim";
484
+
485
+ // F1 fix (fix-plan iteration 1, CRITICAL): the existing-claim check AND the write must happen
486
+ // atomically with respect to any other `assignment-provider` invocation on the same subject —
487
+ // see withSubjectLock()'s doc comment for the full rationale.
488
+ return withSubjectLock(artifactRoot, subjectId, (): number => {
489
+ const existing = readLocalRecord(artifactRoot, subjectId);
490
+ if (existing && existing.status === "claimed") {
491
+ const existingActorKey = helper.serializeActor(existing.actor);
492
+ const newActorKey = helper.serializeActor(actor);
493
+ // AC7: a second claim from a different actor must never silently overwrite the first.
494
+ // Same actor re-claiming (refresh before TTL expiry) is allowed and idempotent.
495
+ if (existingActorKey !== newActorKey) {
496
+ throw new Error(`subject already claimed by a different actor: ${existingActorKey} (claimed_at ${existing.claimed_at}); refusing to overwrite — use supersede to reassign`);
497
+ }
498
+ }
499
+
500
+ const record: AssignmentClaimRecord = {
501
+ schema_version: "1.0",
502
+ role: "AssignmentClaimRecord",
503
+ subject_id: subjectId,
504
+ actor,
505
+ claimed_at: isoNow(),
506
+ ttl_seconds: ttlSeconds,
507
+ branch,
508
+ artifact_dir: artifactDir,
509
+ status: "claimed",
510
+ audit_trail: [...(existing?.audit_trail ?? []), { at: isoNow(), transition: "claim", from_actor: null, to_actor: actor, reason }],
511
+ };
512
+ writeLocalRecord(artifactRoot, subjectId, record);
513
+ console.log(JSON.stringify({ role: "AssignmentClaimResult", subject_id: subjectId, record }, null, 2));
514
+ return 0;
515
+ });
516
+ }
517
+
518
+ function releaseLocalFile(argv: string[]): number {
519
+ const args = parseArgs(argv);
520
+ const provider = flagString(args.flags, "provider", "local-file");
521
+ if (provider !== "local-file") throw new Error(`release: --provider must be local-file (use render-release for github); got ${provider}`);
522
+ const artifactRoot = requireFlag(args, "artifact-root");
523
+ const subjectId = requireFlag(args, "subject-id");
524
+ const releasedBy = flagString(args.flags, "actor-json") ? loadActorStructFromFile(requireFlag(args, "actor-json")) : null;
525
+ const reason = flagString(args.flags, "reason") ?? "released";
526
+
527
+ // F1 fix (fix-plan iteration 1, CRITICAL): release mutates the same record file claim/supersede
528
+ // do, under the same per-subject lock (see withSubjectLock()'s doc comment).
529
+ return withSubjectLock(artifactRoot, subjectId, (): number => {
530
+ const existing = readLocalRecord(artifactRoot, subjectId);
531
+ if (!existing || existing.status !== "claimed") throw new Error(`no active claim to release for subject: ${subjectId}`);
532
+ const record: AssignmentClaimRecord = {
533
+ ...existing,
534
+ status: "released",
535
+ audit_trail: [...(existing.audit_trail ?? []), { at: isoNow(), transition: "release", from_actor: existing.actor, to_actor: releasedBy, reason }],
536
+ };
537
+ writeLocalRecord(artifactRoot, subjectId, record);
538
+ console.log(JSON.stringify({ role: "AssignmentReleaseResult", subject_id: subjectId, record }, null, 2));
539
+ return 0;
540
+ });
541
+ }
542
+
543
+ function supersedeLocalFile(argv: string[]): number {
544
+ const args = parseArgs(argv);
545
+ const provider = flagString(args.flags, "provider", "local-file");
546
+ if (provider !== "local-file") throw new Error(`supersede: --provider must be local-file (use render-supersede for github); got ${provider}`);
547
+ const artifactRoot = requireFlag(args, "artifact-root");
548
+ const subjectId = requireFlag(args, "subject-id");
549
+ const helper = loadActorIdentityHelper();
550
+ const fromActor = loadActorStructFromFile(requireFlag(args, "from-actor-json"));
551
+ const toActor = loadActorStructFromFile(requireFlag(args, "to-actor-json"));
552
+ const reason = flagString(args.flags, "reason") ?? "supersede";
553
+ const ttlSecondsOverride = flagString(args.flags, "ttl-seconds");
554
+ const branchOverride = flagString(args.flags, "branch");
555
+ const artifactDirOverride = flagString(args.flags, "artifact-dir");
556
+
557
+ // F1 fix (fix-plan iteration 1, CRITICAL): supersede mutates the same record file claim/release
558
+ // do, under the same per-subject lock (see withSubjectLock()'s doc comment).
559
+ return withSubjectLock(artifactRoot, subjectId, (): number => {
560
+ const existing = readLocalRecord(artifactRoot, subjectId);
561
+ if (!existing || existing.status !== "claimed") throw new Error(`no active claim to supersede for subject: ${subjectId}`);
562
+
563
+ if (helper.serializeActor(existing.actor) !== helper.serializeActor(fromActor)) {
564
+ throw new Error(`--from-actor-json does not match the current holder (${helper.serializeActor(existing.actor)}); refusing to supersede a claim held by someone else`);
565
+ }
566
+
567
+ const ttlSecondsRaw = ttlSecondsOverride ?? String(existing.ttl_seconds);
568
+ const ttlSeconds = Number(ttlSecondsRaw);
569
+ if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) throw new Error(`--ttl-seconds must be a positive number; got ${ttlSecondsRaw}`);
570
+
571
+ const record: AssignmentClaimRecord = {
572
+ schema_version: "1.0",
573
+ role: "AssignmentClaimRecord",
574
+ subject_id: subjectId,
575
+ actor: toActor,
576
+ claimed_at: isoNow(),
577
+ ttl_seconds: ttlSeconds,
578
+ branch: branchOverride ?? existing.branch,
579
+ artifact_dir: artifactDirOverride ?? existing.artifact_dir,
580
+ status: "claimed",
581
+ audit_trail: [...(existing.audit_trail ?? []), { at: isoNow(), transition: "supersede", from_actor: fromActor, to_actor: toActor, reason }],
582
+ };
583
+ writeLocalRecord(artifactRoot, subjectId, record);
584
+ console.log(JSON.stringify({ role: "AssignmentSupersedeResult", subject_id: subjectId, record }, null, 2));
585
+ return 0;
586
+ });
587
+ }
588
+
589
+ // ─── GitHub: render-claim | render-release | render-supersede (render, don't execute — Design
590
+ // Decision 1). Pure functions: no I/O beyond reading --input-json/--actor-json. Never invoke
591
+ // `gh` (or any process) here — the calling skill runs the emitted argv verbatim. ────────────────
592
+
593
+ function requireRepo(input: RenderClaimInput): { owner: string; name: string } {
594
+ const repo = input.repo;
595
+ if (!repo || !repo.owner || !repo.name) throw new Error("input-json.repo.owner and input-json.repo.name are required");
596
+ return { owner: repo.owner, name: repo.name };
597
+ }
598
+
599
+ function requireIssueNumber(input: RenderClaimInput): number {
600
+ const issueNumber = input.issue_number;
601
+ if (!Number.isFinite(issueNumber)) throw new Error("input-json.issue_number is required");
602
+ return Number(issueNumber);
603
+ }
604
+
605
+ function renderClaimCommentBody(record: AssignmentClaimRecord, marker: string): string {
606
+ const humanNote = record.actor.human ? `Assigned to human ${record.actor.human}.` : `Claimed by an automated agent session (${record.actor.runtime}).`;
607
+ return [
608
+ marker,
609
+ `**Assignment claim** — ${humanNote}`,
610
+ "",
611
+ `- actor: \`${loadActorIdentityHelper().serializeActor(record.actor)}\``,
612
+ `- claimed_at: ${record.claimed_at}`,
613
+ `- ttl_seconds: ${record.ttl_seconds}`,
614
+ `- branch: \`${record.branch}\``,
615
+ "",
616
+ "```json",
617
+ JSON.stringify(record, null, 2),
618
+ "```",
619
+ ].join("\n");
620
+ }
621
+
622
+ function renderHandoffCommentBody(subjectId: string, input: RenderClaimInput): string {
623
+ const marker = input.claim_comment_marker ?? CLAIM_COMMENT_MARKER_DEFAULT;
624
+ const record = input.previous_record
625
+ ? {
626
+ ...input.previous_record,
627
+ status: "released" as const,
628
+ audit_trail: [...(input.previous_record.audit_trail ?? []), { at: isoNow(), transition: "release" as const, from_actor: input.previous_record.actor, to_actor: null, reason: input.reason ?? "released" }],
629
+ }
630
+ : null;
631
+ const lines = [marker, `**Assignment released** — subject \`${subjectId}\` is free.`];
632
+ if (record) lines.push("", "```json", JSON.stringify(record, null, 2), "```");
633
+ return lines.join("\n");
634
+ }
635
+
636
+ function renderClaim(argv: string[]): number {
637
+ const args = parseArgs(argv);
638
+ const provider = flagString(args.flags, "provider", "github");
639
+ if (provider !== "github") throw new Error(`render-claim: --provider must be github; got ${provider}`);
640
+ const subjectId = requireFlag(args, "subject-id");
641
+ const input = loadJsonInput(requireFlag(args, "input-json")) as RenderClaimInput;
642
+ const actor = loadActorStructFromFile(requireFlag(args, "actor-json"));
643
+ const repo = requireRepo(input);
644
+ const issueNumber = requireIssueNumber(input);
645
+ const labelName = input.label_name ?? DEFAULT_LABEL_NAME;
646
+ const marker = input.claim_comment_marker ?? CLAIM_COMMENT_MARKER_DEFAULT;
647
+ const ttlSeconds = input.ttl_seconds ?? 1800;
648
+ const branch = input.branch;
649
+ const artifactDir = input.artifact_dir;
650
+ if (!branch) throw new Error("input-json.branch is required for render-claim");
651
+ if (!artifactDir) throw new Error("input-json.artifact_dir is required for render-claim");
652
+
653
+ const record: AssignmentClaimRecord = {
654
+ schema_version: "1.0",
655
+ role: "AssignmentClaimRecord",
656
+ subject_id: subjectId,
657
+ actor,
658
+ claimed_at: isoNow(),
659
+ ttl_seconds: ttlSeconds,
660
+ branch,
661
+ artifact_dir: artifactDir,
662
+ status: "claimed",
663
+ };
664
+ const repoSlug = `${repo.owner}/${repo.name}`;
665
+ const commentBody = renderClaimCommentBody(record, marker);
666
+ const ghCommands: string[][] = [];
667
+ if (input.assignee_login) ghCommands.push(["gh", "issue", "edit", String(issueNumber), "--repo", repoSlug, "--add-assignee", input.assignee_login]);
668
+ ghCommands.push(["gh", "issue", "edit", String(issueNumber), "--repo", repoSlug, "--add-label", labelName]);
669
+ ghCommands.push(
670
+ input.existing_comment_id
671
+ ? ["gh", "api", "--method", "PATCH", `repos/${repoSlug}/issues/comments/${input.existing_comment_id}`, "-f", `body=${commentBody}`]
672
+ : ["gh", "issue", "comment", String(issueNumber), "--repo", repoSlug, "--body", commentBody],
673
+ );
674
+ console.log(JSON.stringify({ role: "AssignmentRenderResult", transition: "claim", subject_id: subjectId, gh_commands: ghCommands, claim_comment_body: commentBody, record }, null, 2));
675
+ return 0;
676
+ }
677
+
678
+ function renderRelease(argv: string[]): number {
679
+ const args = parseArgs(argv);
680
+ const provider = flagString(args.flags, "provider", "github");
681
+ if (provider !== "github") throw new Error(`render-release: --provider must be github; got ${provider}`);
682
+ const subjectId = requireFlag(args, "subject-id");
683
+ const input = loadJsonInput(requireFlag(args, "input-json")) as RenderClaimInput;
684
+ const repo = requireRepo(input);
685
+ const issueNumber = requireIssueNumber(input);
686
+ const labelName = input.label_name ?? DEFAULT_LABEL_NAME;
687
+ const repoSlug = `${repo.owner}/${repo.name}`;
688
+ const ghCommands: string[][] = [];
689
+ const assigneeLogin = input.existing_assignee_login ?? input.assignee_login;
690
+ if (assigneeLogin) ghCommands.push(["gh", "issue", "edit", String(issueNumber), "--repo", repoSlug, "--remove-assignee", assigneeLogin]);
691
+ ghCommands.push(["gh", "issue", "edit", String(issueNumber), "--repo", repoSlug, "--remove-label", labelName]);
692
+ const handoffBody = renderHandoffCommentBody(subjectId, input);
693
+ ghCommands.push(
694
+ input.existing_comment_id
695
+ ? ["gh", "api", "--method", "PATCH", `repos/${repoSlug}/issues/comments/${input.existing_comment_id}`, "-f", `body=${handoffBody}`]
696
+ : ["gh", "issue", "comment", String(issueNumber), "--repo", repoSlug, "--body", handoffBody],
697
+ );
698
+ console.log(JSON.stringify({ role: "AssignmentRenderResult", transition: "release", subject_id: subjectId, gh_commands: ghCommands, claim_comment_body: handoffBody }, null, 2));
699
+ return 0;
700
+ }
701
+
702
+ function renderSupersede(argv: string[]): number {
703
+ const args = parseArgs(argv);
704
+ const provider = flagString(args.flags, "provider", "github");
705
+ if (provider !== "github") throw new Error(`render-supersede: --provider must be github; got ${provider}`);
706
+ const subjectId = requireFlag(args, "subject-id");
707
+ const input = loadJsonInput(requireFlag(args, "input-json")) as RenderClaimInput;
708
+ const toActor = loadActorStructFromFile(requireFlag(args, "actor-json"));
709
+ const repo = requireRepo(input);
710
+ const issueNumber = requireIssueNumber(input);
711
+ const labelName = input.label_name ?? DEFAULT_LABEL_NAME;
712
+ const marker = input.claim_comment_marker ?? CLAIM_COMMENT_MARKER_DEFAULT;
713
+ const ttlSeconds = input.ttl_seconds ?? 1800;
714
+ const branch = input.branch;
715
+ const artifactDir = input.artifact_dir;
716
+ if (!branch) throw new Error("input-json.branch is required for render-supersede");
717
+ if (!artifactDir) throw new Error("input-json.artifact_dir is required for render-supersede");
718
+ // Wave 4 AC: render-supersede must edit the existing claim comment in place, never duplicate it.
719
+ if (!input.existing_comment_id) throw new Error("input-json.existing_comment_id is required for render-supersede (edits the claim comment in place; never duplicates it)");
720
+
721
+ const previousActor = input.previous_record?.actor ?? null;
722
+ const record: AssignmentClaimRecord = {
723
+ schema_version: "1.0",
724
+ role: "AssignmentClaimRecord",
725
+ subject_id: subjectId,
726
+ actor: toActor,
727
+ claimed_at: isoNow(),
728
+ ttl_seconds: ttlSeconds,
729
+ branch,
730
+ artifact_dir: artifactDir,
731
+ status: "claimed",
732
+ audit_trail: [...(input.previous_record?.audit_trail ?? []), { at: isoNow(), transition: "supersede", from_actor: previousActor, to_actor: toActor, reason: input.reason ?? "supersede" }],
733
+ };
734
+ const repoSlug = `${repo.owner}/${repo.name}`;
735
+ const commentBody = renderClaimCommentBody(record, marker);
736
+ const ghCommands: string[][] = [];
737
+ const previousAssignee = input.existing_assignee_login;
738
+ if (previousAssignee && previousAssignee !== input.assignee_login) ghCommands.push(["gh", "issue", "edit", String(issueNumber), "--repo", repoSlug, "--remove-assignee", previousAssignee]);
739
+ if (input.assignee_login) ghCommands.push(["gh", "issue", "edit", String(issueNumber), "--repo", repoSlug, "--add-assignee", input.assignee_login]);
740
+ ghCommands.push(["gh", "issue", "edit", String(issueNumber), "--repo", repoSlug, "--add-label", labelName]);
741
+ ghCommands.push(["gh", "api", "--method", "PATCH", `repos/${repoSlug}/issues/comments/${input.existing_comment_id}`, "-f", `body=${commentBody}`]);
742
+ console.log(JSON.stringify({ role: "AssignmentRenderResult", transition: "supersede", subject_id: subjectId, gh_commands: ghCommands, claim_comment_body: commentBody, record }, null, 2));
743
+ return 0;
744
+ }
745
+
746
+ // ─── status | list (both provider kinds) ────────────────────────────────────────────────────
747
+
748
+ function statusCommand(argv: string[]): number {
749
+ const args = parseArgs(argv);
750
+ const provider = requireFlag(args, "provider");
751
+ const requestedSubjectId = flagString(args.flags, "subject-id");
752
+ let assignment: AssignmentStatus;
753
+
754
+ if (provider === "local-file") {
755
+ const artifactRoot = requireFlag(args, "artifact-root");
756
+ if (!requestedSubjectId) throw new Error("--subject-id is required for status --provider local-file");
757
+ const record = readLocalRecord(artifactRoot, requestedSubjectId);
758
+ const active = record && record.status === "claimed" ? record : null;
759
+ assignment = { subject_id: requestedSubjectId, provider: "local-file", assignee: active ? loadActorIdentityHelper().serializeActor(active.actor) : null, record: active };
760
+ } else if (provider === "github") {
761
+ const issueJsonPath = requireFlag(args, "issue-json");
762
+ const issue = loadJsonInput(issueJsonPath) as GithubIssueDoc;
763
+ const labelName = flagString(args.flags, "label-name", DEFAULT_LABEL_NAME) ?? DEFAULT_LABEL_NAME;
764
+ const marker = flagString(args.flags, "claim-comment-marker", CLAIM_COMMENT_MARKER_DEFAULT) ?? CLAIM_COMMENT_MARKER_DEFAULT;
765
+ assignment = githubAssignmentStatus(issue, labelName, marker);
766
+ if (requestedSubjectId && assignment.record && assignment.record.subject_id !== requestedSubjectId) {
767
+ throw new Error(`claim record subject_id ${assignment.record.subject_id} does not match requested --subject-id ${requestedSubjectId}`);
768
+ }
769
+ if (requestedSubjectId) assignment.subject_id = requestedSubjectId;
770
+ } else {
771
+ throw new Error(`status: unsupported --provider ${provider}`);
772
+ }
773
+
774
+ const { events, selfActor } = loadLivenessInputs(args);
775
+ const nowMs = flagString(args.flags, "now") ? Date.parse(flagString(args.flags, "now") as string) : Date.now();
776
+ const freshList = events !== null ? loadLivenessReadHelper().freshHolders(events, assignment.subject_id, selfActor ?? "", nowMs) : [];
777
+ const effective = events !== null
778
+ ? computeEffectiveState(assignment, freshList, selfActor, nowMs)
779
+ : { effective_state: null, reason: "liveness input not provided (pass --liveness-events-json or --liveness-stream); effective state not computed" };
780
+ console.log(JSON.stringify({ role: "AssignmentStatus", provider, assignment, effective }, null, 2));
781
+ return 0;
782
+ }
783
+
784
+ function listCommand(argv: string[]): number {
785
+ const args = parseArgs(argv);
786
+ const provider = requireFlag(args, "provider");
787
+ const actorJsonFilter = flagString(args.flags, "actor-json");
788
+ const actorFilter = actorJsonFilter ? loadActorIdentityHelper().serializeActor(loadActorStructFromFile(actorJsonFilter)) : flagString(args.flags, "actor");
789
+ const subjectIds: string[] = [];
790
+
791
+ if (provider === "local-file") {
792
+ const artifactRoot = requireFlag(args, "artifact-root");
793
+ const dir = path.join(artifactRoot, "assignment");
794
+ const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter((name) => name.endsWith(".json")).sort() : [];
795
+ for (const name of files) {
796
+ const record = readJson(path.join(dir, name)) as AssignmentClaimRecord;
797
+ if (record.status !== "claimed") continue;
798
+ if (actorFilter && loadActorIdentityHelper().serializeActor(record.actor) !== actorFilter) continue;
799
+ subjectIds.push(record.subject_id);
800
+ }
801
+ } else if (provider === "github") {
802
+ const issuesJsonPath = requireFlag(args, "issues-json");
803
+ const doc = loadJsonInput(issuesJsonPath);
804
+ const issues = Array.isArray(doc) ? doc as GithubIssueDoc[] : ((doc as AnyObj).items as GithubIssueDoc[] ?? []);
805
+ const labelName = flagString(args.flags, "label-name", DEFAULT_LABEL_NAME) ?? DEFAULT_LABEL_NAME;
806
+ const marker = flagString(args.flags, "claim-comment-marker", CLAIM_COMMENT_MARKER_DEFAULT) ?? CLAIM_COMMENT_MARKER_DEFAULT;
807
+ for (const issue of issues) {
808
+ const assignment = githubAssignmentStatus(issue, labelName, marker);
809
+ if (!assignment.record || assignment.record.status !== "claimed") continue;
810
+ if (actorFilter && loadActorIdentityHelper().serializeActor(assignment.record.actor) !== actorFilter) continue;
811
+ subjectIds.push(assignment.record.subject_id);
812
+ }
813
+ } else {
814
+ throw new Error(`list: unsupported --provider ${provider}`);
815
+ }
816
+
817
+ console.log(JSON.stringify({ role: "AssignmentList", provider, actor: actorFilter ?? null, subject_ids: subjectIds }, null, 2));
818
+ return 0;
819
+ }
820
+
821
+ export function main(argv = process.argv.slice(2)): number {
822
+ try {
823
+ const [command, ...rest] = argv;
824
+ if (command === "claim") return claimLocalFile(rest);
825
+ if (command === "release") return releaseLocalFile(rest);
826
+ if (command === "supersede") return supersedeLocalFile(rest);
827
+ if (command === "render-claim") return renderClaim(rest);
828
+ if (command === "render-release") return renderRelease(rest);
829
+ if (command === "render-supersede") return renderSupersede(rest);
830
+ if (command === "status") return statusCommand(rest);
831
+ if (command === "list") return listCommand(rest);
832
+ console.error("usage: assignment-provider <claim|release|supersede|render-claim|render-release|render-supersede|status|list> [flags]");
833
+ return 2;
834
+ } catch (error) {
835
+ console.error(`assignment-provider: ${(error as Error).message}`);
836
+ return 1;
837
+ }
838
+ }
839
+
840
+ // Use process.exitCode (not process.exit) to allow stdout to be flushed before exit.
841
+ // Resolve real paths to handle symlinks (e.g. /tmp -> /private/tmp on macOS) so the
842
+ // entry-point guard fires correctly when the module is loaded directly as a script.
843
+ const _selfRealPath = (() => { try { return fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return fileURLToPath(import.meta.url); } })();
844
+ const _argv1RealPath = (() => { try { return fs.realpathSync(process.argv[1]); } catch { return process.argv[1]; } })();
845
+ if (_selfRealPath === _argv1RealPath) { process.exitCode = main(); }