@kontourai/flow-agents 3.4.3 → 3.6.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 (65) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/CHANGELOG.md +15 -0
  3. package/build/src/builder-flow-run-adapter.d.ts +10 -1
  4. package/build/src/builder-flow-run-adapter.js +29 -1
  5. package/build/src/builder-flow-runtime.d.ts +18 -0
  6. package/build/src/builder-flow-runtime.js +205 -13
  7. package/build/src/builder-lifecycle-authority.d.ts +35 -0
  8. package/build/src/builder-lifecycle-authority.js +219 -0
  9. package/build/src/cli/assignment-provider.d.ts +13 -0
  10. package/build/src/cli/assignment-provider.js +120 -62
  11. package/build/src/cli/builder-run.js +46 -5
  12. package/build/src/cli/workflow-artifact-cleanup-audit.js +3 -0
  13. package/build/src/cli/workflow-sidecar.d.ts +3 -0
  14. package/build/src/cli/workflow-sidecar.js +140 -30
  15. package/build/src/cli/workflow.d.ts +2 -0
  16. package/build/src/cli/workflow.js +521 -0
  17. package/build/src/cli.js +2 -0
  18. package/build/src/index.d.ts +4 -0
  19. package/build/src/index.js +2 -0
  20. package/build/src/lib/flow-resolver.js +7 -2
  21. package/build/src/lib/package-version.d.ts +2 -0
  22. package/build/src/lib/package-version.js +13 -0
  23. package/build/src/lib/pinned-cli-command.d.ts +6 -0
  24. package/build/src/lib/pinned-cli-command.js +21 -0
  25. package/context/contracts/artifact-contract.md +1 -1
  26. package/context/contracts/assignment-provider-contract.md +5 -2
  27. package/context/scripts/hooks/config-protection.js +8 -1
  28. package/context/scripts/hooks/lib/config-protection-remedies.js +3 -0
  29. package/context/scripts/hooks/stop-goal-fit.js +1 -1
  30. package/docs/context-map.md +2 -0
  31. package/docs/public-workflow-cli.md +63 -0
  32. package/docs/spec/builder-flow-runtime.md +49 -5
  33. package/docs/workflow-usage-guide.md +5 -0
  34. package/evals/ci/run-baseline.sh +2 -0
  35. package/evals/integration/test_assignment_provider_local_file.sh +54 -0
  36. package/evals/integration/test_builder_entry_enforcement.sh +241 -24
  37. package/evals/integration/test_bundle_install.sh +97 -0
  38. package/evals/integration/test_current_json_per_actor.sh +1 -0
  39. package/evals/integration/test_dual_emit_flow_step.sh +2 -0
  40. package/evals/integration/test_flowdef_session_activation.sh +6 -3
  41. package/evals/integration/test_goal_fit_escape_hatch.sh +3 -3
  42. package/evals/integration/test_phase_map_and_gate_claim.sh +4 -0
  43. package/evals/integration/test_public_workflow_cli.sh +259 -0
  44. package/evals/integration/test_workflow_sidecar_writer.sh +1 -0
  45. package/package.json +2 -2
  46. package/schemas/builder-lifecycle-authorization.schema.json +57 -0
  47. package/schemas/lifecycle-authority-keys.schema.json +25 -0
  48. package/schemas/workflow-state.schema.json +1 -1
  49. package/scripts/hooks/config-protection.js +8 -1
  50. package/scripts/hooks/lib/config-protection-remedies.js +3 -0
  51. package/scripts/hooks/stop-goal-fit.js +1 -1
  52. package/src/builder-flow-run-adapter.ts +47 -0
  53. package/src/builder-flow-runtime.ts +216 -4
  54. package/src/builder-lifecycle-authority.ts +218 -0
  55. package/src/cli/assignment-provider.ts +84 -20
  56. package/src/cli/builder-flow-runtime.test.mjs +404 -1
  57. package/src/cli/builder-run.ts +56 -5
  58. package/src/cli/workflow-artifact-cleanup-audit.ts +3 -0
  59. package/src/cli/workflow-sidecar.ts +138 -31
  60. package/src/cli/workflow.ts +471 -0
  61. package/src/cli.ts +2 -0
  62. package/src/index.ts +14 -0
  63. package/src/lib/flow-resolver.ts +6 -2
  64. package/src/lib/package-version.ts +15 -0
  65. package/src/lib/pinned-cli-command.ts +23 -0
@@ -9,20 +9,24 @@ import { fileURLToPath } from "node:url";
9
9
  // ADR 0016 Abstraction A: shared FlowDefinition resolver (P-a)
10
10
  import { resolveActiveFlowStep, resolveAllFlowGateExpects, resolveFlowFilePath, resolvePhaseMap, resolveRouteBackPolicy, type ActiveFlowStep } from "../lib/flow-resolver.js";
11
11
  import { defaultArtifactRootForRead, flowAgentsArtifactRoot } from "../lib/local-artifact-root.js";
12
+ import { ensureSafeDirectory } from "../lib/fs.js";
13
+ import { flowAgentsPackageVersion } from "../lib/package-version.js";
14
+ import { pinnedFlowAgentsCommand } from "../lib/pinned-cli-command.js";
12
15
  import { startBuilderFlowSession, syncBuilderFlowSessionIfPresent } from "../builder-flow-runtime.js";
13
16
  // #291 Wave 1 Task 1.1 exports: ensure-session's ownership guard reuses the EXACT same
14
17
  // assignment ⋈ liveness join / claim / supersede logic #290 already ships for the
15
18
  // `assignment-provider` CLI, rather than reimplementing a second, parallel join (static ESM
16
19
  // import — same idiom already used above for ../lib/flow-resolver.js).
17
- import { computeEffectiveState, performLocalClaim, performLocalSupersede, readLocalAssignmentStatus, type ActorStruct, type EffectiveState, type FreshHolder } from "./assignment-provider.js";
20
+ import { assignmentFilePath, computeEffectiveState, performLocalClaim, performLocalSupersede, readLocalAssignmentStatus, withSubjectLock, type ActorStruct, type EffectiveState, type FreshHolder } from "./assignment-provider.js";
18
21
 
19
22
  type AnyObj = Record<string, any>;
20
23
 
21
- export const statuses = new Set(["new", "planning", "planned", "in_progress", "blocked", "verifying", "verified", "needs_decision", "not_verified", "failed", "delivered", "accepted", "archived"]);
24
+ export const statuses = new Set(["new", "planning", "planned", "in_progress", "blocked", "verifying", "verified", "needs_decision", "not_verified", "failed", "delivered", "canceled", "accepted", "archived"]);
22
25
  export const phases = ["idea", "backlog", "pickup", "planning", "execution", "verification", "goal_fit", "evidence", "release", "learning", "done"];
23
26
  export const checkKinds = new Set(["build", "types", "lint", "test", "command", "security", "diff", "browser", "runtime", "policy", "external"]);
24
27
  export const checkStatuses = new Set(["pass", "fail", "not_verified", "skip"]);
25
28
  export const verdicts = new Set(["pass", "partial", "fail", "not_verified"]);
29
+ export const WORKFLOW_WRITER_CONTRACT_VERSION = "1.0";
26
30
 
27
31
  function now(): string { return new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); }
28
32
  function read(file: string): string { return fs.readFileSync(file, "utf8"); }
@@ -57,6 +61,12 @@ function workItemSlug(ref: string): string {
57
61
  return slugify(`${owner}-${repo}-${id}`, "work-item");
58
62
  }
59
63
 
64
+ function assignmentSubjectMatchesWorkItem(slug: string, ref: string): boolean {
65
+ if (ref === `local:${slug}`) return true;
66
+ if (!/^[^/#]+\/[^/#]+#\d+$/.test(ref)) return false;
67
+ return workItemSlug(ref) === slug;
68
+ }
69
+
60
70
  type SessionWorkItem = {
61
71
  ref: string;
62
72
  localRecord?: AnyObj;
@@ -64,13 +74,18 @@ type SessionWorkItem = {
64
74
 
65
75
  function sessionWorkItem(p: ReturnType<typeof parseArgs>, slug: string, dir: string): SessionWorkItem {
66
76
  const providerRef = opt(p, "work-item");
77
+ const existingState = loadJson(path.join(dir, "state.json"));
78
+ const existingRef = Array.isArray(existingState.work_item_refs) && typeof existingState.work_item_refs[0] === "string"
79
+ ? existingState.work_item_refs[0]
80
+ : "";
67
81
  if (providerRef) {
82
+ if (existingRef && existingRef !== providerRef) {
83
+ die(`ensure-session refused: session ${JSON.stringify(slug)} is already bound to Work Item ${JSON.stringify(existingRef)}, not ${JSON.stringify(providerRef)}`);
84
+ }
68
85
  return { ref: providerRef };
69
86
  }
70
-
71
- const existingState = loadJson(path.join(dir, "state.json"));
72
- if (Array.isArray(existingState.work_item_refs) && typeof existingState.work_item_refs[0] === "string") {
73
- return { ref: existingState.work_item_refs[0] };
87
+ if (existingRef) {
88
+ return { ref: existingRef };
74
89
  }
75
90
 
76
91
  const title = opt(p, "title", slug).trim() || slug;
@@ -1442,6 +1457,15 @@ function sessionDirFor(root: string, slug: string): string {
1442
1457
  return dir;
1443
1458
  }
1444
1459
 
1460
+ function assertSafeSessionDirectory(root: string, dir: string): void {
1461
+ if (!fs.existsSync(dir)) return;
1462
+ const stat = fs.lstatSync(dir);
1463
+ const expected = path.join(fs.realpathSync(root), path.basename(dir));
1464
+ if (stat.isSymbolicLink() || !stat.isDirectory() || fs.realpathSync(dir) !== expected) {
1465
+ die(`session directory must be a real directory under the artifact root: ${dir}`);
1466
+ }
1467
+ }
1468
+
1445
1469
  function validateAgentId(agent: string): string {
1446
1470
  if (!agent) die("--agent-id is required");
1447
1471
  if (path.isAbsolute(agent)) die("--agent-id must be a relative slug");
@@ -1481,17 +1505,20 @@ function findRepoRootFromDirStrict(startDir: string): string | null {
1481
1505
  * Find the repository root by walking upward from a starting directory to locate
1482
1506
  * the nearest ancestor containing a kits/ subdirectory. Mirrors flow-resolver.ts
1483
1507
  * findRepoRoot, but callable from workflow-sidecar.ts without re-importing the
1484
- * internal helper. Falls back to process.cwd() when no kits/ ancestor is found —
1485
- * appropriate for phase-map/first-step resolution (ADR 0016 Abstraction A, P-d),
1486
- * where the caller is always invoked from within a real repo checkout.
1508
+ * internal helper. A canonical `.kontourai` path falls back to its owning project,
1509
+ * allowing installed package assets to resolve independently of ambient cwd. Other
1510
+ * layouts retain the cwd fallback used by standalone primitive sessions.
1487
1511
  *
1488
- * Do NOT use this cwd-falling-back variant for publishDelivery's repo-root
1489
- * resolution — use findRepoRootFromDirStrict there instead, so a scratch/test
1512
+ * Do NOT use this permissive variant for publishDelivery's repo-root resolution —
1513
+ * use findRepoRootFromDirStrict there instead, so a scratch/test
1490
1514
  * session dir with no repo ancestor fails closed (skips publish) rather than
1491
1515
  * silently trusting process.cwd(), which could be an unrelated real repo.
1492
1516
  */
1493
1517
  function findRepoRootFromDir(startDir: string): string {
1494
- return findRepoRootFromDirStrict(startDir) ?? process.cwd();
1518
+ const discovered = findRepoRootFromDirStrict(startDir);
1519
+ if (discovered) return discovered;
1520
+ if (path.basename(startDir) === ".kontourai") return path.dirname(startDir);
1521
+ return process.cwd();
1495
1522
  }
1496
1523
 
1497
1524
  /**
@@ -1654,6 +1681,11 @@ function currentDir(root: string, actorKey?: string): string | null {
1654
1681
  }
1655
1682
  return dir;
1656
1683
  }
1684
+
1685
+ export function currentWorkflowSessionDir(root: string): string | null {
1686
+ const resolved = loadActorIdentityHelper().resolveActor(process.env);
1687
+ return currentDir(path.resolve(root), loadActorIdentityHelper().isUnresolvedActor(resolved.actor) ? undefined : resolved.actor);
1688
+ }
1657
1689
  /**
1658
1690
  * #291 Wave 2 Task 2.1 (§6): updates BOTH the legacy current.json (when IT points at `dir` — the
1659
1691
  * exact, unchanged existing check/write) AND the resolved actor's own per-actor current.json
@@ -1786,10 +1818,11 @@ function enforceEnsureSessionOwnership(
1786
1818
  slug: string,
1787
1819
  dir: string,
1788
1820
  resolution: { actorStruct: ActorStruct; actorKey: string; branchActorKey: string; unresolved: boolean },
1789
- ): void {
1821
+ workItemRef?: string,
1822
+ ): { assignmentFile: string; actorKey: string } | null {
1790
1823
  if (p.flags.has("skip-ownership-guard")) {
1791
1824
  process.stderr.write("[ensure-session] ownership guard skipped via --skip-ownership-guard\n");
1792
- return;
1825
+ return null;
1793
1826
  }
1794
1827
  // Design Decision 4 (unchanged from resolveSessionBranch): actor-resolution ambiguity never
1795
1828
  // hard-fails session creation. Without a resolvable actor there is no safe identity to claim
@@ -1797,7 +1830,7 @@ function enforceEnsureSessionOwnership(
1797
1830
  // than claiming under a synthetic/unstable identity.
1798
1831
  if (resolution.unresolved) {
1799
1832
  process.stderr.write("[ensure-session] ownership guard not evaluated: actor is unresolved (set --actor or FLOW_AGENTS_ACTOR, or run inside a supported runtime) — proceeding without a durable claim, exactly as ensure-session behaved before #291\n");
1800
- return;
1833
+ return null;
1801
1834
  }
1802
1835
 
1803
1836
  // F5 fix (fix-plan iteration 1, LOW): match assignment-provider.ts's sanitizeDisplayField
@@ -1849,6 +1882,9 @@ function enforceEnsureSessionOwnership(
1849
1882
  // `assignment-provider status --self-actor <branchActorKey>` run by a DIFFERENT tool
1850
1883
  // afterward would never recognize this guard's own claim as self.
1851
1884
  const assignment = readLocalAssignmentStatus(root, slug);
1885
+ if (workItemRef && assignment.record?.work_item_ref && assignment.record.work_item_ref !== workItemRef) {
1886
+ die(`ensure-session refused: assignment ${JSON.stringify(slug)} is already bound to Work Item ${JSON.stringify(assignment.record.work_item_ref)}, not ${JSON.stringify(workItemRef)}`);
1887
+ }
1852
1888
  const events = readLivenessEvents(root);
1853
1889
  const freshList = loadLivenessReadHelper().freshHolders(events, slug, resolution.branchActorKey, nowMs);
1854
1890
  effective = computeEffectiveState(assignment, freshList, resolution.branchActorKey, nowMs);
@@ -1859,9 +1895,28 @@ function enforceEnsureSessionOwnership(
1859
1895
  // neither applies, this is a documented scope boundary (today's pre-#291 baseline behavior),
1860
1896
  // never a silent hole.
1861
1897
  process.stderr.write(`[ensure-session] ownership guard not evaluated: provider "${sanitize(assignmentProviderKind)}" requires --effective-state-json\n`);
1862
- return;
1898
+ return null;
1863
1899
  }
1864
1900
 
1901
+ const selectedWorkEvidence = (): { assignmentFile: string; actorKey: string } | null => {
1902
+ // Only a claim read from the local provider is durable evidence. Precomputed provider state
1903
+ // can authorize entry, but it cannot prove that this process acquired the Work Item.
1904
+ if (!workItemRef || effectiveStateJsonFlag || assignmentProviderKind !== "local-file") return null;
1905
+ const assignment = readLocalAssignmentStatus(root, slug);
1906
+ const record = assignment.record;
1907
+ if (!record
1908
+ || record.status !== "claimed"
1909
+ || record.subject_id !== slug
1910
+ || record.actor_key !== resolution.branchActorKey
1911
+ || record.work_item_ref !== workItemRef) {
1912
+ return null;
1913
+ }
1914
+ return {
1915
+ assignmentFile: assignmentFilePath(root, slug),
1916
+ actorKey: resolution.branchActorKey,
1917
+ };
1918
+ };
1919
+
1865
1920
  const resolveBranchForClaim = (): string => {
1866
1921
  const existingBranch = fs.existsSync(path.join(dir, "state.json")) ? (loadJson(path.join(dir, "state.json")).branch as string | undefined) : undefined;
1867
1922
  return existingBranch || resolveSessionBranch(p, slug);
@@ -1874,17 +1929,17 @@ function enforceEnsureSessionOwnership(
1874
1929
  // ActorStruct triple), so this redundant belt-and-suspenders check agrees with the
1875
1930
  // effective_state computation instead of silently using a different identity.
1876
1931
  const isSelf = effective.reason === "self_is_holder" || (!!effective.holder?.actor && effective.holder.actor === resolution.branchActorKey);
1877
- if (isSelf) return; // resume own session — no refusal
1932
+ if (isSelf) return selectedWorkEvidence();
1878
1933
  const holderActor = sanitize(effective.holder?.actor ?? "unknown");
1879
1934
  const lastAtSuffix = effective.holder?.last_at ? ` (last_at ${sanitize(effective.holder.last_at)})` : "";
1880
1935
  die(`ensure-session refused: subject ${sanitize(slug)} is currently held by a different, still-live actor (${holderActor}${lastAtSuffix}). Pick a different work item, or confirm the holder session is truly gone before considering a takeover.`);
1881
- return;
1936
+ return null;
1882
1937
  }
1883
1938
  case "human-held": {
1884
1939
  const assignee = effective.holder?.assignee ? sanitize(effective.holder.assignee) : "an assigned human";
1885
1940
  const idleSuffix = effective.holder?.idle_days != null ? ` (idle ${Number(effective.holder.idle_days)} day(s))` : "";
1886
1941
  die(`ensure-session refused: subject ${sanitize(slug)} is assigned to ${assignee}${idleSuffix}. This guard never auto-reclaims a human assignment — confirm with the user before proceeding.`);
1887
- return;
1942
+ return null;
1888
1943
  }
1889
1944
  case "reclaimable": {
1890
1945
  const holderActor = sanitize(effective.holder?.actor ?? "unknown");
@@ -1917,11 +1972,12 @@ function enforceEnsureSessionOwnership(
1917
1972
  // computeEffectiveState's holderActorKey (assignment-provider.ts) matches this same
1918
1973
  // branchActorKey string on the next status/guard check, cross-tool.
1919
1974
  actorKey: resolution.branchActorKey,
1975
+ ...(workItemRef ? { workItemRef } : {}),
1920
1976
  });
1921
1977
  // Render-don't-execute: emit the incumbent's branch so the skill continues it (never a new
1922
1978
  // branch). The successor re-enters the SAME artifact dir (deterministic slug) by construction.
1923
1979
  printJson({ role: "SupersedeTakeover", subject: sanitize(slug), superseded_actor: holderActor, ...(incumbentBranch ? { resumed_branch: sanitizeWide(incumbentBranch) } : {}), reason: sanitizeWide(takeoverReason) });
1924
- return;
1980
+ return selectedWorkEvidence();
1925
1981
  }
1926
1982
  case "free": {
1927
1983
  performLocalClaim(root, slug, resolution.actorStruct, {
@@ -1931,8 +1987,9 @@ function enforceEnsureSessionOwnership(
1931
1987
  reason: opt(p, "reason", "ensure-session entry"),
1932
1988
  // F1 fix (fix-plan iteration 1, HIGH): see the performLocalSupersede call above.
1933
1989
  actorKey: resolution.branchActorKey,
1990
+ ...(workItemRef ? { workItemRef } : {}),
1934
1991
  });
1935
- return;
1992
+ return selectedWorkEvidence();
1936
1993
  }
1937
1994
  default:
1938
1995
  die(`ensure-session ownership guard: unrecognized effective_state ${JSON.stringify(effective.effective_state)}`);
@@ -2011,6 +2068,7 @@ function preflightEnsureSession(p: ReturnType<typeof parseArgs>): void {
2011
2068
  const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
2012
2069
  const slug = opt(p, "task-slug") || (opt(p, "work-item") ? workItemSlug(opt(p, "work-item")) : die("--task-slug is required (or pass --work-item to derive it)"));
2013
2070
  const dir = sessionDirFor(root, slug);
2071
+ assertSafeSessionDirectory(root, dir);
2014
2072
  const entry = resolveEnsureSessionEntry(p, dir);
2015
2073
  if (entry.flowId === "builder.build") assertCanonicalBuilderArtifactRoot(root);
2016
2074
  sessionWorkItem(p, slug, dir);
@@ -2020,6 +2078,7 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
2020
2078
  const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
2021
2079
  const slug = opt(p, "task-slug") || (opt(p, "work-item") ? workItemSlug(opt(p, "work-item")) : die("--task-slug is required (or pass --work-item to derive it)"));
2022
2080
  const dir = sessionDirFor(root, slug);
2081
+ assertSafeSessionDirectory(root, dir);
2023
2082
  const entry = resolveEnsureSessionEntry(p, dir);
2024
2083
  if (entry.flowId === "builder.build") assertCanonicalBuilderArtifactRoot(root);
2025
2084
  const workItem = sessionWorkItem(p, slug, dir);
@@ -2028,8 +2087,11 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
2028
2087
  // below (writeCurrent's per-actor dual-write) so the branch-naming actor and the
2029
2088
  // assignment-claim actor are always the same identity.
2030
2089
  const actorResolution = resolveEnsureSessionActor(p);
2031
- enforceEnsureSessionOwnership(p, root, slug, dir, actorResolution);
2032
- fs.mkdirSync(dir, { recursive: true });
2090
+ const selectionWorkItemRef = entry.flowId === "builder.build" && assignmentSubjectMatchesWorkItem(slug, workItem.ref)
2091
+ ? workItem.ref
2092
+ : undefined;
2093
+ const selectedWorkEvidence = enforceEnsureSessionOwnership(p, root, slug, dir, actorResolution, selectionWorkItemRef);
2094
+ ensureSafeDirectory(root, dir);
2033
2095
  const timestamp = opt(p, "timestamp", now());
2034
2096
  if (workItem.localRecord && !fs.existsSync(path.join(dir, "work-item.json"))) {
2035
2097
  writeJson(path.join(dir, "work-item.json"), workItem.localRecord);
@@ -2042,13 +2104,24 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
2042
2104
  // takeover continuity true by construction (see Design Decision 3 in the plan).
2043
2105
  const branch = resolveSessionBranch(p, slug);
2044
2106
  const initialMarkdownStatus = entry.flowId ? "new" : "planning";
2045
- md = `# ${opt(p, "title", slug)}\n\nbranch: ${branch}\nworktree: main\ncreated: ${timestamp}\nstatus: ${initialMarkdownStatus}\ntype: deliver\niteration: 1\n\n## Plan\n\n${opt(p, "summary", "")}\n\n## Definition Of Done\n\n- **User outcome:** ${opt(p, "summary", "Workflow session is durable.")}\n- **Scope:** Workflow session artifacts and sidecars.\n- **Acceptance criteria:**\n${opts(p, "criterion").map((c) => ` - [ ] ${c} - Evidence: pending.`).join("\n")}\n- **Usefulness checks:**\n - [ ] User-facing workflow is documented or discoverable\n- **Stop-short risks:** Workflow artifacts could drift.\n- **Durable docs target:** not needed\n- **Sandbox mode:** local-edit\n\n## Execution Progress\n\n- [ ] Session initialized.\n\n## Verification Report\n\nBuild: [NOT_VERIFIED] Verification has not run yet.\n\n### Acceptance Criteria\n- [NOT_VERIFIED] Verification has not run yet - Evidence: pending workflow execution and checks.\n\n### Verdict: NOT_VERIFIED\n\n## Goal Fit Gate\n\n- [ ] Original user goal restated\n\n## Final Acceptance\n\n- [ ] CI/relevant checks passed or local equivalent recorded\n`;
2107
+ const acceptanceCriteria = opts(p, "criterion");
2108
+ if (acceptanceCriteria.length === 0) acceptanceCriteria.push(`Complete the requested outcome: ${opt(p, "summary", "Workflow session is durable.")}`);
2109
+ md = `# ${opt(p, "title", slug)}\n\nbranch: ${branch}\nworktree: main\ncreated: ${timestamp}\nstatus: ${initialMarkdownStatus}\ntype: deliver\niteration: 1\n\n## Plan\n\n${opt(p, "summary", "")}\n\n## Definition Of Done\n\n- **User outcome:** ${opt(p, "summary", "Workflow session is durable.")}\n- **Scope:** Workflow session artifacts and sidecars.\n- **Acceptance criteria:**\n${acceptanceCriteria.map((c) => ` - [ ] ${c} - Evidence: pending.`).join("\n")}\n- **Usefulness checks:**\n - [ ] User-facing workflow is documented or discoverable\n- **Stop-short risks:** Workflow artifacts could drift.\n- **Durable docs target:** not needed\n- **Sandbox mode:** local-edit\n\n## Execution Progress\n\n- [ ] Session initialized.\n\n## Verification Report\n\nBuild: [NOT_VERIFIED] Verification has not run yet.\n\n### Acceptance Criteria\n- [NOT_VERIFIED] Verification has not run yet - Evidence: pending workflow execution and checks.\n\n### Verdict: NOT_VERIFIED\n\n## Goal Fit Gate\n\n- [ ] Original user goal restated\n\n## Final Acceptance\n\n- [ ] CI/relevant checks passed or local equivalent recorded\n`;
2046
2110
  fs.writeFileSync(path.join(dir, `${slug}--deliver.md`), md);
2047
2111
  }
2048
2112
  if (!fs.existsSync(path.join(dir, "state.json")) || !fs.existsSync(path.join(dir, "acceptance.json")) || !fs.existsSync(path.join(dir, "handoff.json"))) {
2049
2113
  const phaseMap = entry.flowId ? resolvePhaseMap(entry.flowId, findRepoRootFromDir(dir)) : null;
2050
2114
  const initialPhase = Object.entries(phaseMap ?? {}).find(([, step]) => step === entry.firstStep)?.[0];
2051
- const startCommand = `flow-agents builder-run start --session-dir .kontourai/flow-agents/${slug}`;
2115
+ const startCommand = pinnedFlowAgentsCommand(flowAgentsPackageVersion(), [
2116
+ "workflow", "start",
2117
+ "--flow", "builder.build",
2118
+ "--work-item", workItem.ref,
2119
+ ...(workItem.ref === `local:${slug}` ? ["--task-slug", slug] : []),
2120
+ "--artifact-root", root,
2121
+ "--title", opt(p, "title", slug),
2122
+ "--summary", opt(p, "summary", "Workflow session is durable."),
2123
+ ...opts(p, "criterion").flatMap((criterion) => ["--criterion", criterion]),
2124
+ ]);
2052
2125
  const nextAction: string | AnyObj = entry.flowId
2053
2126
  ? entry.flowId === "builder.build"
2054
2127
  ? {
@@ -2088,10 +2161,41 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
2088
2161
  writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", entry.flowId || undefined, resumedStep || undefined, actorResolution.unresolved ? undefined : actorResolution.branchActorKey);
2089
2162
  if (entry.flowId === "builder.build") {
2090
2163
  try {
2091
- await startBuilderFlowSession({ sessionDir: dir });
2164
+ const started = await startBuilderFlowSession({ sessionDir: dir });
2165
+ if (started.run.state.current_step === "pull-work"
2166
+ && selectedWorkEvidence
2167
+ && assignmentSubjectMatchesWorkItem(slug, workItem.ref)) {
2168
+ await withSubjectLock(root, slug, async () => {
2169
+ const assignment = readLocalAssignmentStatus(root, slug).record;
2170
+ if (!assignment
2171
+ || assignment.status !== "claimed"
2172
+ || assignment.subject_id !== slug
2173
+ || assignment.actor_key !== selectedWorkEvidence.actorKey
2174
+ || assignment.work_item_ref !== workItem.ref) {
2175
+ die(`ensure-session refused to emit selected-work evidence: assignment provenance changed before gate evaluation for ${JSON.stringify(workItem.ref)}`);
2176
+ }
2177
+ const assignmentContent = fs.readFileSync(selectedWorkEvidence.assignmentFile);
2178
+ const assignmentDigest = createHash("sha256").update(assignmentContent).digest("hex");
2179
+ const projectRoot = path.dirname(path.dirname(root));
2180
+ const evidenceFile = path.relative(projectRoot, selectedWorkEvidence.assignmentFile);
2181
+ await recordGateClaim(parseArgs([
2182
+ "record-gate-claim",
2183
+ dir,
2184
+ "--actor", selectedWorkEvidence.actorKey,
2185
+ "--expectation", "selected-work",
2186
+ "--status", "pass",
2187
+ "--summary", `Work Item ${workItem.ref} is durably assigned to the active workflow actor (assignment sha256:${assignmentDigest}).`,
2188
+ "--evidence-ref-json", JSON.stringify({
2189
+ kind: "artifact",
2190
+ file: evidenceFile,
2191
+ summary: `Durable local assignment record for the selected Work Item and active actor; sha256:${assignmentDigest}.`,
2192
+ }),
2193
+ "--timestamp", timestamp,
2194
+ ]));
2195
+ });
2196
+ }
2092
2197
  } catch (error) {
2093
- const retry = `flow-agents builder-run start --session-dir .kontourai/flow-agents/${slug}`;
2094
- process.stderr.write(`[ensure-session] canonical Builder Flow run did not start; the sidecar remains retryable. Run: ${retry}\n`);
2198
+ process.stderr.write("[ensure-session] canonical Builder Flow entry failed; the acquired Work Item provenance remains retryable. Re-run the same ensure-session command.\n");
2095
2199
  throw error;
2096
2200
  }
2097
2201
  }
@@ -5336,7 +5440,7 @@ function loadLivenessReadHelper(): {
5336
5440
  // init-plan claims the work-item; advance-state heartbeats (or releases on terminal),
5337
5441
  // so the workflow lifecycle itself maintains the liveness claim — no manual liveness calls.
5338
5442
  // Additive + fail-open: a liveness-emit failure never affects the workflow command.
5339
- export const LIVENESS_TERMINAL = new Set(["delivered", "accepted", "archived"]);
5443
+ export const LIVENESS_TERMINAL = new Set(["delivered", "canceled", "accepted", "archived"]);
5340
5444
  /**
5341
5445
  * Delegate to the shared pure-CJS resolver (scripts/hooks/lib/actor-identity.js), mirroring the
5342
5446
  * createRequire pattern used by readLivenessEvents() above. Deliberately NO inline duplicate
@@ -5804,8 +5908,8 @@ Available claim ids:
5804
5908
  // ─────────────────────────────────────────────────────────────────────────────
5805
5909
 
5806
5910
 
5807
- async function main(): Promise<number> {
5808
- const _rawArgv = process.argv.slice(2);
5911
+ export async function main(argv: string[] = process.argv.slice(2)): Promise<number> {
5912
+ const _rawArgv = argv;
5809
5913
  // #380: `record-check <dir> -- <command...>` — argv after the FIRST literal `--` token is the
5810
5914
  // command to execute verbatim (never option-parsed: a command like `npm test -- --watch`
5811
5915
  // legitimately contains its OWN `--`, so only the record-check dispatcher's own separator, the
@@ -5880,5 +5984,8 @@ async function main(): Promise<number> {
5880
5984
  const _selfRealPath = (() => { try { return fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return fileURLToPath(import.meta.url); } })();
5881
5985
  const _argv1RealPath = (() => { try { return fs.realpathSync(process.argv[1]); } catch { return process.argv[1]; } })();
5882
5986
  if (_selfRealPath === _argv1RealPath) {
5987
+ if (path.basename(process.argv[1] ?? "") === "flow-agents-workflow-sidecar") {
5988
+ process.stderr.write("flow-agents-workflow-sidecar is deprecated; use `flow-agents workflow` or an explicitly pinned `npx @kontourai/flow-agents@<version> workflow` command.\n");
5989
+ }
5883
5990
  main().then((code) => process.exit(code)).catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); });
5884
5991
  }