@kontourai/flow-agents 3.4.3 → 3.5.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.5.0](https://github.com/kontourai/flow-agents/compare/v3.4.3...v3.5.0) (2026-07-10)
4
+
5
+
6
+ ### Features
7
+
8
+ * **builder:** derive selected-work evidence from acquisition ([#543](https://github.com/kontourai/flow-agents/issues/543)) ([bfe1681](https://github.com/kontourai/flow-agents/commit/bfe1681211174cb6219b0dd94d765aa9c99734bd))
9
+
3
10
  ## [3.4.3](https://github.com/kontourai/flow-agents/compare/v3.4.2...v3.4.3) (2026-07-10)
4
11
 
5
12
 
@@ -31,6 +31,7 @@ export type AssignmentClaimRecord = {
31
31
  subject_id: string;
32
32
  actor: ActorStruct;
33
33
  actor_key?: string;
34
+ work_item_ref?: string;
34
35
  claimed_at: string;
35
36
  ttl_seconds: number;
36
37
  branch: string;
@@ -116,6 +117,7 @@ export declare function performLocalClaim(artifactRoot: string, subjectId: strin
116
117
  artifactDir: string;
117
118
  reason?: string;
118
119
  actorKey?: string;
120
+ workItemRef?: string;
119
121
  }): AssignmentClaimRecord;
120
122
  /**
121
123
  * Wave 1 (#292) extraction: the durable-write body previously inlined inside releaseLocalFile's
@@ -177,6 +179,7 @@ export declare function performLocalSupersede(artifactRoot: string, subjectId: s
177
179
  artifactDir?: string;
178
180
  reason?: string;
179
181
  actorKey?: string;
182
+ workItemRef?: string;
180
183
  }): AssignmentClaimRecord;
181
184
  /**
182
185
  * Wave 1 (#291) extraction: the local-file branch of statusCommand's assignment-layer read,
@@ -4,7 +4,7 @@ import * as path from "node:path";
4
4
  import { createRequire } from "node:module";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { parseArgs, flagString } from "../lib/args.js";
7
- import { readJson, writeJson, isoNow } from "../lib/fs.js";
7
+ import { atomicWriteJson, readJson, isoNow } from "../lib/fs.js";
8
8
  const DEFAULT_LABEL_NAME = "agent:claimed";
9
9
  const CLAIM_COMMENT_MARKER_DEFAULT = "<!-- flow-agents:assignment-claim -->";
10
10
  /**
@@ -95,19 +95,59 @@ export function assignmentFilePath(artifactRoot, subjectId) {
95
95
  const sanitized = loadActorIdentityHelper().sanitizeSegment(subjectId);
96
96
  return path.join(artifactRoot, "assignment", `${sanitized}.json`);
97
97
  }
98
+ function localAssignmentDir(artifactRoot, create) {
99
+ const dir = path.join(artifactRoot, "assignment");
100
+ if (create)
101
+ fs.mkdirSync(dir, { recursive: true });
102
+ let stat;
103
+ try {
104
+ stat = fs.lstatSync(dir);
105
+ }
106
+ catch (error) {
107
+ if (!create && error.code === "ENOENT")
108
+ return null;
109
+ throw error;
110
+ }
111
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
112
+ throw new Error(`assignment directory must be a real directory, not a symlink: ${dir}`);
113
+ }
114
+ const realRoot = fs.realpathSync(artifactRoot);
115
+ if (fs.realpathSync(dir) !== path.join(realRoot, "assignment")) {
116
+ throw new Error(`assignment directory escapes the artifact root: ${dir}`);
117
+ }
118
+ return dir;
119
+ }
98
120
  export function readLocalRecord(artifactRoot, subjectId) {
99
- const file = assignmentFilePath(artifactRoot, subjectId);
100
- if (!fs.existsSync(file))
121
+ if (!localAssignmentDir(artifactRoot, false))
101
122
  return null;
123
+ const file = assignmentFilePath(artifactRoot, subjectId);
124
+ let stat;
125
+ try {
126
+ stat = fs.lstatSync(file);
127
+ }
128
+ catch (error) {
129
+ if (error.code === "ENOENT")
130
+ return null;
131
+ throw error;
132
+ }
133
+ if (stat.isSymbolicLink() || !stat.isFile()) {
134
+ throw new Error(`assignment record must be a regular file, not a symlink: ${file}`);
135
+ }
102
136
  let data;
137
+ let descriptor = null;
103
138
  try {
104
- data = readJson(file);
139
+ descriptor = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
140
+ data = JSON.parse(fs.readFileSync(descriptor, "utf8"));
105
141
  }
106
142
  catch (error) {
107
143
  // Fail loud: a corrupt claim record must never be silently treated as "no claim" — that
108
144
  // would be a fail-open path that could let a second claim silently overwrite a real one.
109
145
  throw new Error(`assignment record is corrupt, refusing to proceed: ${file}: ${error.message}`);
110
146
  }
147
+ finally {
148
+ if (descriptor !== null)
149
+ fs.closeSync(descriptor);
150
+ }
111
151
  if (typeof data !== "object" || data === null)
112
152
  throw new Error(`assignment record is not an object: ${file}`);
113
153
  const record = data;
@@ -119,7 +159,7 @@ export function writeLocalRecord(artifactRoot, subjectId, record) {
119
159
  // writeJson throws on any mkdir/writeFileSync failure; that error is intentionally allowed to
120
160
  // propagate to main()'s top-level try/catch and exit non-zero. Durable writes must fail loud,
121
161
  // never fail open (artifact-contract.md).
122
- writeJson(assignmentFilePath(artifactRoot, subjectId), record);
162
+ atomicWriteJson(artifactRoot, assignmentFilePath(artifactRoot, subjectId), record);
123
163
  }
124
164
  /**
125
165
  * Synchronous busy-sleep via Atomics.wait on a throwaway SharedArrayBuffer — Node.js (unlike
@@ -133,8 +173,7 @@ function sleepSync(ms) {
133
173
  Atomics.wait(ia, 0, 0, ms);
134
174
  }
135
175
  function subjectLockDir(artifactRoot, subjectId) {
136
- const assignmentDir = path.join(artifactRoot, "assignment");
137
- fs.mkdirSync(assignmentDir, { recursive: true });
176
+ const assignmentDir = localAssignmentDir(artifactRoot, true);
138
177
  const sanitized = loadActorIdentityHelper().sanitizeSegment(subjectId);
139
178
  return path.join(assignmentDir, `.${sanitized}.lockdir`);
140
179
  }
@@ -190,12 +229,20 @@ export function withSubjectLock(artifactRoot, subjectId, body) {
190
229
  sleepSync(20);
191
230
  }
192
231
  }
232
+ const release = () => fs.rmSync(lockDir, { recursive: true, force: true });
233
+ let result;
193
234
  try {
194
- return body();
235
+ result = body();
195
236
  }
196
- finally {
197
- fs.rmSync(lockDir, { recursive: true, force: true });
237
+ catch (error) {
238
+ release();
239
+ throw error;
240
+ }
241
+ if (result && typeof result.then === "function") {
242
+ return Promise.resolve(result).finally(release);
198
243
  }
244
+ release();
245
+ return result;
199
246
  }
200
247
  /**
201
248
  * The assignment ⋈ liveness join (contract doc's "assignment ⋈ liveness join" section, ADR 0021
@@ -403,6 +450,7 @@ export function performLocalClaim(artifactRoot, subjectId, actor, opts) {
403
450
  subject_id: subjectId,
404
451
  actor,
405
452
  ...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
453
+ ...((opts.workItemRef ?? existing?.work_item_ref) ? { work_item_ref: opts.workItemRef ?? existing?.work_item_ref } : {}),
406
454
  claimed_at: isoNow(),
407
455
  ttl_seconds: opts.ttlSeconds,
408
456
  branch: opts.branch,
@@ -574,6 +622,7 @@ export function performLocalSupersede(artifactRoot, subjectId, fromActor, toActo
574
622
  subject_id: subjectId,
575
623
  actor: toActor,
576
624
  ...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
625
+ ...((opts.workItemRef ?? existing.work_item_ref) ? { work_item_ref: opts.workItemRef ?? existing.work_item_ref } : {}),
577
626
  claimed_at: isoNow(),
578
627
  ttl_seconds: ttlSeconds,
579
628
  branch: opts.branch ?? existing.branch,
@@ -9,12 +9,13 @@ import { fileURLToPath } from "node:url";
9
9
  // ADR 0016 Abstraction A: shared FlowDefinition resolver (P-a)
10
10
  import { resolveActiveFlowStep, resolveAllFlowGateExpects, resolveFlowFilePath, resolvePhaseMap, resolveRouteBackPolicy } from "../lib/flow-resolver.js";
11
11
  import { defaultArtifactRootForRead, flowAgentsArtifactRoot } from "../lib/local-artifact-root.js";
12
+ import { ensureSafeDirectory } from "../lib/fs.js";
12
13
  import { startBuilderFlowSession, syncBuilderFlowSessionIfPresent } from "../builder-flow-runtime.js";
13
14
  // #291 Wave 1 Task 1.1 exports: ensure-session's ownership guard reuses the EXACT same
14
15
  // assignment ⋈ liveness join / claim / supersede logic #290 already ships for the
15
16
  // `assignment-provider` CLI, rather than reimplementing a second, parallel join (static ESM
16
17
  // import — same idiom already used above for ../lib/flow-resolver.js).
17
- import { computeEffectiveState, performLocalClaim, performLocalSupersede, readLocalAssignmentStatus } from "./assignment-provider.js";
18
+ import { assignmentFilePath, computeEffectiveState, performLocalClaim, performLocalSupersede, readLocalAssignmentStatus, withSubjectLock } from "./assignment-provider.js";
18
19
  export const statuses = new Set(["new", "planning", "planned", "in_progress", "blocked", "verifying", "verified", "needs_decision", "not_verified", "failed", "delivered", "accepted", "archived"]);
19
20
  export const phases = ["idea", "backlog", "pickup", "planning", "execution", "verification", "goal_fit", "evidence", "release", "learning", "done"];
20
21
  export const checkKinds = new Set(["build", "types", "lint", "test", "command", "security", "diff", "browser", "runtime", "policy", "external"]);
@@ -55,14 +56,27 @@ function workItemSlug(ref) {
55
56
  const [owner, repo] = parts;
56
57
  return slugify(`${owner}-${repo}-${id}`, "work-item");
57
58
  }
59
+ function assignmentSubjectMatchesWorkItem(slug, ref) {
60
+ if (ref === `local:${slug}`)
61
+ return true;
62
+ if (!/^[^/#]+\/[^/#]+#\d+$/.test(ref))
63
+ return false;
64
+ return workItemSlug(ref) === slug;
65
+ }
58
66
  function sessionWorkItem(p, slug, dir) {
59
67
  const providerRef = opt(p, "work-item");
68
+ const existingState = loadJson(path.join(dir, "state.json"));
69
+ const existingRef = Array.isArray(existingState.work_item_refs) && typeof existingState.work_item_refs[0] === "string"
70
+ ? existingState.work_item_refs[0]
71
+ : "";
60
72
  if (providerRef) {
73
+ if (existingRef && existingRef !== providerRef) {
74
+ die(`ensure-session refused: session ${JSON.stringify(slug)} is already bound to Work Item ${JSON.stringify(existingRef)}, not ${JSON.stringify(providerRef)}`);
75
+ }
61
76
  return { ref: providerRef };
62
77
  }
63
- const existingState = loadJson(path.join(dir, "state.json"));
64
- if (Array.isArray(existingState.work_item_refs) && typeof existingState.work_item_refs[0] === "string") {
65
- return { ref: existingState.work_item_refs[0] };
78
+ if (existingRef) {
79
+ return { ref: existingRef };
66
80
  }
67
81
  const title = opt(p, "title", slug).trim() || slug;
68
82
  const body = opt(p, "summary").trim();
@@ -1437,6 +1451,15 @@ function sessionDirFor(root, slug) {
1437
1451
  die("session directory must stay under artifact root");
1438
1452
  return dir;
1439
1453
  }
1454
+ function assertSafeSessionDirectory(root, dir) {
1455
+ if (!fs.existsSync(dir))
1456
+ return;
1457
+ const stat = fs.lstatSync(dir);
1458
+ const expected = path.join(fs.realpathSync(root), path.basename(dir));
1459
+ if (stat.isSymbolicLink() || !stat.isDirectory() || fs.realpathSync(dir) !== expected) {
1460
+ die(`session directory must be a real directory under the artifact root: ${dir}`);
1461
+ }
1462
+ }
1440
1463
  function validateAgentId(agent) {
1441
1464
  if (!agent)
1442
1465
  die("--agent-id is required");
@@ -1481,17 +1504,22 @@ function findRepoRootFromDirStrict(startDir) {
1481
1504
  * Find the repository root by walking upward from a starting directory to locate
1482
1505
  * the nearest ancestor containing a kits/ subdirectory. Mirrors flow-resolver.ts
1483
1506
  * 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.
1507
+ * internal helper. A canonical `.kontourai` path falls back to its owning project,
1508
+ * allowing installed package assets to resolve independently of ambient cwd. Other
1509
+ * layouts retain the cwd fallback used by standalone primitive sessions.
1487
1510
  *
1488
- * Do NOT use this cwd-falling-back variant for publishDelivery's repo-root
1489
- * resolution — use findRepoRootFromDirStrict there instead, so a scratch/test
1511
+ * Do NOT use this permissive variant for publishDelivery's repo-root resolution —
1512
+ * use findRepoRootFromDirStrict there instead, so a scratch/test
1490
1513
  * session dir with no repo ancestor fails closed (skips publish) rather than
1491
1514
  * silently trusting process.cwd(), which could be an unrelated real repo.
1492
1515
  */
1493
1516
  function findRepoRootFromDir(startDir) {
1494
- return findRepoRootFromDirStrict(startDir) ?? process.cwd();
1517
+ const discovered = findRepoRootFromDirStrict(startDir);
1518
+ if (discovered)
1519
+ return discovered;
1520
+ if (path.basename(startDir) === ".kontourai")
1521
+ return path.dirname(startDir);
1522
+ return process.cwd();
1495
1523
  }
1496
1524
  /**
1497
1525
  * Resolve the first step id from a FlowDefinition's steps[] list.
@@ -1770,10 +1798,10 @@ function loadJsonInputFile(file) {
1770
1798
  * mitigation class: a hostile liveness event or a hand-crafted --effective-state-json fixture must
1771
1799
  * never be able to smuggle raw control/ANSI bytes into this process's stderr/thrown message).
1772
1800
  */
1773
- function enforceEnsureSessionOwnership(p, root, slug, dir, resolution) {
1801
+ function enforceEnsureSessionOwnership(p, root, slug, dir, resolution, workItemRef) {
1774
1802
  if (p.flags.has("skip-ownership-guard")) {
1775
1803
  process.stderr.write("[ensure-session] ownership guard skipped via --skip-ownership-guard\n");
1776
- return;
1804
+ return null;
1777
1805
  }
1778
1806
  // Design Decision 4 (unchanged from resolveSessionBranch): actor-resolution ambiguity never
1779
1807
  // hard-fails session creation. Without a resolvable actor there is no safe identity to claim
@@ -1781,7 +1809,7 @@ function enforceEnsureSessionOwnership(p, root, slug, dir, resolution) {
1781
1809
  // than claiming under a synthetic/unstable identity.
1782
1810
  if (resolution.unresolved) {
1783
1811
  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");
1784
- return;
1812
+ return null;
1785
1813
  }
1786
1814
  // F5 fix (fix-plan iteration 1, LOW): match assignment-provider.ts's sanitizeDisplayField
1787
1815
  // two-tier convention (64 for id-like fields, 240 for free text) rather than asserting one
@@ -1830,6 +1858,9 @@ function enforceEnsureSessionOwnership(p, root, slug, dir, resolution) {
1830
1858
  // `assignment-provider status --self-actor <branchActorKey>` run by a DIFFERENT tool
1831
1859
  // afterward would never recognize this guard's own claim as self.
1832
1860
  const assignment = readLocalAssignmentStatus(root, slug);
1861
+ if (workItemRef && assignment.record?.work_item_ref && assignment.record.work_item_ref !== workItemRef) {
1862
+ 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)}`);
1863
+ }
1833
1864
  const events = readLivenessEvents(root);
1834
1865
  const freshList = loadLivenessReadHelper().freshHolders(events, slug, resolution.branchActorKey, nowMs);
1835
1866
  effective = computeEffectiveState(assignment, freshList, resolution.branchActorKey, nowMs);
@@ -1841,8 +1872,27 @@ function enforceEnsureSessionOwnership(p, root, slug, dir, resolution) {
1841
1872
  // neither applies, this is a documented scope boundary (today's pre-#291 baseline behavior),
1842
1873
  // never a silent hole.
1843
1874
  process.stderr.write(`[ensure-session] ownership guard not evaluated: provider "${sanitize(assignmentProviderKind)}" requires --effective-state-json\n`);
1844
- return;
1875
+ return null;
1845
1876
  }
1877
+ const selectedWorkEvidence = () => {
1878
+ // Only a claim read from the local provider is durable evidence. Precomputed provider state
1879
+ // can authorize entry, but it cannot prove that this process acquired the Work Item.
1880
+ if (!workItemRef || effectiveStateJsonFlag || assignmentProviderKind !== "local-file")
1881
+ return null;
1882
+ const assignment = readLocalAssignmentStatus(root, slug);
1883
+ const record = assignment.record;
1884
+ if (!record
1885
+ || record.status !== "claimed"
1886
+ || record.subject_id !== slug
1887
+ || record.actor_key !== resolution.branchActorKey
1888
+ || record.work_item_ref !== workItemRef) {
1889
+ return null;
1890
+ }
1891
+ return {
1892
+ assignmentFile: assignmentFilePath(root, slug),
1893
+ actorKey: resolution.branchActorKey,
1894
+ };
1895
+ };
1846
1896
  const resolveBranchForClaim = () => {
1847
1897
  const existingBranch = fs.existsSync(path.join(dir, "state.json")) ? loadJson(path.join(dir, "state.json")).branch : undefined;
1848
1898
  return existingBranch || resolveSessionBranch(p, slug);
@@ -1855,17 +1905,17 @@ function enforceEnsureSessionOwnership(p, root, slug, dir, resolution) {
1855
1905
  // effective_state computation instead of silently using a different identity.
1856
1906
  const isSelf = effective.reason === "self_is_holder" || (!!effective.holder?.actor && effective.holder.actor === resolution.branchActorKey);
1857
1907
  if (isSelf)
1858
- return; // resume own session — no refusal
1908
+ return selectedWorkEvidence();
1859
1909
  const holderActor = sanitize(effective.holder?.actor ?? "unknown");
1860
1910
  const lastAtSuffix = effective.holder?.last_at ? ` (last_at ${sanitize(effective.holder.last_at)})` : "";
1861
1911
  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.`);
1862
- return;
1912
+ return null;
1863
1913
  }
1864
1914
  case "human-held": {
1865
1915
  const assignee = effective.holder?.assignee ? sanitize(effective.holder.assignee) : "an assigned human";
1866
1916
  const idleSuffix = effective.holder?.idle_days != null ? ` (idle ${Number(effective.holder.idle_days)} day(s))` : "";
1867
1917
  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.`);
1868
- return;
1918
+ return null;
1869
1919
  }
1870
1920
  case "reclaimable": {
1871
1921
  const holderActor = sanitize(effective.holder?.actor ?? "unknown");
@@ -1899,11 +1949,12 @@ function enforceEnsureSessionOwnership(p, root, slug, dir, resolution) {
1899
1949
  // computeEffectiveState's holderActorKey (assignment-provider.ts) matches this same
1900
1950
  // branchActorKey string on the next status/guard check, cross-tool.
1901
1951
  actorKey: resolution.branchActorKey,
1952
+ ...(workItemRef ? { workItemRef } : {}),
1902
1953
  });
1903
1954
  // Render-don't-execute: emit the incumbent's branch so the skill continues it (never a new
1904
1955
  // branch). The successor re-enters the SAME artifact dir (deterministic slug) by construction.
1905
1956
  printJson({ role: "SupersedeTakeover", subject: sanitize(slug), superseded_actor: holderActor, ...(incumbentBranch ? { resumed_branch: sanitizeWide(incumbentBranch) } : {}), reason: sanitizeWide(takeoverReason) });
1906
- return;
1957
+ return selectedWorkEvidence();
1907
1958
  }
1908
1959
  case "free": {
1909
1960
  performLocalClaim(root, slug, resolution.actorStruct, {
@@ -1913,8 +1964,9 @@ function enforceEnsureSessionOwnership(p, root, slug, dir, resolution) {
1913
1964
  reason: opt(p, "reason", "ensure-session entry"),
1914
1965
  // F1 fix (fix-plan iteration 1, HIGH): see the performLocalSupersede call above.
1915
1966
  actorKey: resolution.branchActorKey,
1967
+ ...(workItemRef ? { workItemRef } : {}),
1916
1968
  });
1917
- return;
1969
+ return selectedWorkEvidence();
1918
1970
  }
1919
1971
  default:
1920
1972
  die(`ensure-session ownership guard: unrecognized effective_state ${JSON.stringify(effective.effective_state)}`);
@@ -1993,6 +2045,7 @@ function preflightEnsureSession(p) {
1993
2045
  const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
1994
2046
  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)"));
1995
2047
  const dir = sessionDirFor(root, slug);
2048
+ assertSafeSessionDirectory(root, dir);
1996
2049
  const entry = resolveEnsureSessionEntry(p, dir);
1997
2050
  if (entry.flowId === "builder.build")
1998
2051
  assertCanonicalBuilderArtifactRoot(root);
@@ -2002,6 +2055,7 @@ async function ensureSession(p) {
2002
2055
  const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
2003
2056
  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)"));
2004
2057
  const dir = sessionDirFor(root, slug);
2058
+ assertSafeSessionDirectory(root, dir);
2005
2059
  const entry = resolveEnsureSessionEntry(p, dir);
2006
2060
  if (entry.flowId === "builder.build")
2007
2061
  assertCanonicalBuilderArtifactRoot(root);
@@ -2011,8 +2065,11 @@ async function ensureSession(p) {
2011
2065
  // below (writeCurrent's per-actor dual-write) so the branch-naming actor and the
2012
2066
  // assignment-claim actor are always the same identity.
2013
2067
  const actorResolution = resolveEnsureSessionActor(p);
2014
- enforceEnsureSessionOwnership(p, root, slug, dir, actorResolution);
2015
- fs.mkdirSync(dir, { recursive: true });
2068
+ const selectionWorkItemRef = entry.flowId === "builder.build" && assignmentSubjectMatchesWorkItem(slug, workItem.ref)
2069
+ ? workItem.ref
2070
+ : undefined;
2071
+ const selectedWorkEvidence = enforceEnsureSessionOwnership(p, root, slug, dir, actorResolution, selectionWorkItemRef);
2072
+ ensureSafeDirectory(root, dir);
2016
2073
  const timestamp = opt(p, "timestamp", now());
2017
2074
  if (workItem.localRecord && !fs.existsSync(path.join(dir, "work-item.json"))) {
2018
2075
  writeJson(path.join(dir, "work-item.json"), workItem.localRecord);
@@ -2061,11 +2118,42 @@ async function ensureSession(p) {
2061
2118
  writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", entry.flowId || undefined, resumedStep || undefined, actorResolution.unresolved ? undefined : actorResolution.branchActorKey);
2062
2119
  if (entry.flowId === "builder.build") {
2063
2120
  try {
2064
- await startBuilderFlowSession({ sessionDir: dir });
2121
+ const started = await startBuilderFlowSession({ sessionDir: dir });
2122
+ if (started.run.state.current_step === "pull-work"
2123
+ && selectedWorkEvidence
2124
+ && assignmentSubjectMatchesWorkItem(slug, workItem.ref)) {
2125
+ await withSubjectLock(root, slug, async () => {
2126
+ const assignment = readLocalAssignmentStatus(root, slug).record;
2127
+ if (!assignment
2128
+ || assignment.status !== "claimed"
2129
+ || assignment.subject_id !== slug
2130
+ || assignment.actor_key !== selectedWorkEvidence.actorKey
2131
+ || assignment.work_item_ref !== workItem.ref) {
2132
+ die(`ensure-session refused to emit selected-work evidence: assignment provenance changed before gate evaluation for ${JSON.stringify(workItem.ref)}`);
2133
+ }
2134
+ const assignmentContent = fs.readFileSync(selectedWorkEvidence.assignmentFile);
2135
+ const assignmentDigest = createHash("sha256").update(assignmentContent).digest("hex");
2136
+ const projectRoot = path.dirname(path.dirname(root));
2137
+ const evidenceFile = path.relative(projectRoot, selectedWorkEvidence.assignmentFile);
2138
+ await recordGateClaim(parseArgs([
2139
+ "record-gate-claim",
2140
+ dir,
2141
+ "--actor", selectedWorkEvidence.actorKey,
2142
+ "--expectation", "selected-work",
2143
+ "--status", "pass",
2144
+ "--summary", `Work Item ${workItem.ref} is durably assigned to the active workflow actor (assignment sha256:${assignmentDigest}).`,
2145
+ "--evidence-ref-json", JSON.stringify({
2146
+ kind: "artifact",
2147
+ file: evidenceFile,
2148
+ summary: `Durable local assignment record for the selected Work Item and active actor; sha256:${assignmentDigest}.`,
2149
+ }),
2150
+ "--timestamp", timestamp,
2151
+ ]));
2152
+ });
2153
+ }
2065
2154
  }
2066
2155
  catch (error) {
2067
- const retry = `flow-agents builder-run start --session-dir .kontourai/flow-agents/${slug}`;
2068
- process.stderr.write(`[ensure-session] canonical Builder Flow run did not start; the sidecar remains retryable. Run: ${retry}\n`);
2156
+ 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");
2069
2157
  throw error;
2070
2158
  }
2071
2159
  }
@@ -392,8 +392,8 @@ export function resolvePhaseMap(flowId, repoRoot) {
392
392
  }
393
393
  /**
394
394
  * Find the repository root from a starting directory by walking upward to locate
395
- * the nearest ancestor that contains a `kits/` subdirectory. If none is found,
396
- * falls back to `process.cwd()` so the default "run from repo root" case still works.
395
+ * the nearest ancestor that contains a `kits/` subdirectory. A canonical `.kontourai`
396
+ * artifact path falls back to its owning project; other layouts fall back to cwd.
397
397
  *
398
398
  * This is required because the runtime artifact directory can live anywhere (temp dirs,
399
399
  * subprojects, CI workspaces) while the kits/ directory is always at the repo root.
@@ -409,6 +409,11 @@ function findRepoRoot(startDir) {
409
409
  break; // reached filesystem root
410
410
  dir = parent;
411
411
  }
412
+ // A canonical product artifact root identifies its owning project even when that
413
+ // consumer has no source-tree kits/. resolveFlowFilePath can then use the installed
414
+ // package fallback without consulting an unrelated ambient cwd.
415
+ if (path.basename(startDir) === ".kontourai")
416
+ return path.dirname(startDir);
412
417
  // Fallback: process.cwd() covers the common "run from repo root" case
413
418
  return process.cwd();
414
419
  }
@@ -138,8 +138,10 @@ detect an incompatible future shape before parsing fields it does not understand
138
138
  {
139
139
  "schema_version": "1.0",
140
140
  "role": "AssignmentClaimRecord",
141
- "subject_id": "kontourai/flow-agents#290",
141
+ "subject_id": "kontourai-flow-agents-290",
142
142
  "actor": { "runtime": "claude-code", "session_id": "...", "host": "...", "human": null },
143
+ "actor_key": "claude-code:...:host",
144
+ "work_item_ref": "kontourai/flow-agents#290",
143
145
  "claimed_at": "2026-07-02T00:00:00Z",
144
146
  "ttl_seconds": 1800,
145
147
  "branch": "agent/<actor>/<slug>",
@@ -152,9 +154,10 @@ detect an incompatible future shape before parsing fields it does not understand
152
154
  | --- | --- | --- |
153
155
  | `schema_version` | yes | Version of this record shape, `"1.0"`. |
154
156
  | `role` | yes | Constant `"AssignmentClaimRecord"`, for readers scanning mixed content (e.g. a GitHub comment thread) for this record type. |
155
- | `subject_id` | yes | The claimed work item, in `owner/repo#id` form the same string `workItemSlug` derives the deterministic session slug from. |
157
+ | `subject_id` | yes | The provider subject key. The local-file provider uses the deterministic session slug; provider-backed records may use their native subject identifier. |
156
158
  | `actor` | yes | `{ runtime, session_id, host, human? }` — the exact struct `actor-identity.js` defines. `human` is set (non-null) only for a human assignee; its presence, not a username heuristic, gates the human-held join state. |
157
159
  | `actor_key` | no (additive, #291) | The canonical `resolveActor(env).actor` string for the claiming actor — the same flat/bare token `liveness whoami`, `liveness claim --actor`, per-actor `current.json`, and pull-work's `--self-actor` all use. When present, `computeEffectiveState` compares against THIS (not a re-serialization of `actor`) for both self-recognition and the liveness join, because `serializeActor(actor)` and `resolveActor(env).actor` diverge for an explicit-override actor (a bare token vs. a `explicit-override:<value>:<host>` triple) while agreeing for a derived actor. Absent on any pre-#291 record or fixture — `computeEffectiveState` falls back to `serializeActor(actor)` in that case, reproducing pre-#291 behavior exactly. |
160
+ | `work_item_ref` | no (additive, #541) | Exact Work Item reference acquired by Builder `ensure-session`. This preserves identity beyond the local provider's lossy filesystem slug and allows an interrupted acquisition to retry safely. Older records and ordinary assignment-provider claims omit it and cannot automatically satisfy `builder.pull-work.selected`. |
158
161
  | `claimed_at` | yes | ISO-8601 timestamp the claim was recorded. Mirrors the liveness stream's own claim-event field so a reader compares freshness with one mental model across both layers, even though the two are stored in different media. |
159
162
  | `ttl_seconds` | yes | Same field name/semantics as the liveness stream's `ttlSeconds` (default `1800`). |
160
163
  | `branch` | yes | The branch this actor is working on, per the `agent/<actor>/<slug>` convention. |
@@ -36,11 +36,18 @@ flow-agents builder-run start --session-dir .kontourai/flow-agents/<slug>
36
36
  ```
37
37
 
38
38
  `ensure-session --flow-id builder.build` starts or loads this canonical run before
39
- returning, then projects the first Flow step into the sidecar. The command remains
40
- the explicit recovery path if Flow startup fails after sidecar creation; the
41
- failure is returned to the caller and no substitute run state is invented. Runtime
42
- hooks keep projected actions advisory while the agent performs their declared
43
- skills and operations.
39
+ returning. When the local assignment provider durably assigns the exact Work Item
40
+ to the resolved workflow actor, `ensure-session` rereads that record and produces
41
+ the declared `builder.pull-work.selected` claim through the normal Surface trust
42
+ bundle path. Flow evaluates that subject-bound evidence and advances to
43
+ `design-probe`; Flow Agents does not write a transition or gate outcome directly.
44
+ Skipped ownership, unresolved actors, precomputed state, and non-local providers do
45
+ not produce selection evidence and remain at `pull-work`.
46
+
47
+ The command remains the explicit recovery path if Flow startup fails after sidecar
48
+ creation; the failure is returned to the caller and no substitute run state is
49
+ invented. Runtime hooks keep projected actions advisory while the agent performs
50
+ their declared skills and operations.
44
51
 
45
52
  Sidecars written by 3.4.2 may still contain `next_action.enforcement`. The 1.0
46
53
  schema accepts that deprecated field for artifact compatibility, but current
@@ -69,6 +69,60 @@ node "$CLI" assignment-provider status \
69
69
  [[ "$(json_query "$TMPDIR_EVAL/status-a.json" "assignment.assignee")" == "claude-code:eval-actor-a-session:eval-host" ]] && pass "status reports actor A as assignee" || fail "status reports actor A as assignee"
70
70
  [[ "$(json_query "$TMPDIR_EVAL/status-a.json" "assignment.record.claimed_at")" == "$CLAIMED_AT" ]] && pass "status claimed_at matches claim's claimed_at" || fail "status claimed_at matches claim's claimed_at"
71
71
 
72
+ # A generic same-actor refresh must preserve exact Builder provenance that an interrupted
73
+ # ensure-session recorded, even though the assignment-provider CLI does not accept that field.
74
+ PROVENANCE_SUBJECT="builder-provenance"
75
+ node "$CLI" assignment-provider claim \
76
+ --provider local-file --artifact-root "$ARTIFACT_ROOT" \
77
+ --subject-id "$PROVENANCE_SUBJECT" \
78
+ --actor-json "$ACTOR_A" \
79
+ --branch "$BRANCH_A" \
80
+ --artifact-dir "$ARTIFACT_DIR_A" \
81
+ --ttl-seconds 1800 \
82
+ > "$TMPDIR_EVAL/provenance-claim.json"
83
+ PROVENANCE_FILE="$(node - "$ARTIFACT_ROOT/assignment" "$PROVENANCE_SUBJECT" <<'NODE'
84
+ const fs = require('node:fs');
85
+ const path = require('node:path');
86
+ const [dir, subject] = process.argv.slice(2);
87
+ for (const name of fs.readdirSync(dir)) {
88
+ if (!name.endsWith('.json')) continue;
89
+ const file = path.join(dir, name);
90
+ if (JSON.parse(fs.readFileSync(file, 'utf8')).subject_id === subject) {
91
+ process.stdout.write(file);
92
+ process.exit(0);
93
+ }
94
+ }
95
+ process.exit(1);
96
+ NODE
97
+ )"
98
+ node - "$PROVENANCE_FILE" <<'NODE'
99
+ const fs = require('node:fs');
100
+ const file = process.argv[2];
101
+ const record = JSON.parse(fs.readFileSync(file, 'utf8'));
102
+ record.work_item_ref = 'kontourai/flow-agents#9001';
103
+ fs.writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`);
104
+ NODE
105
+ node "$CLI" assignment-provider claim \
106
+ --provider local-file --artifact-root "$ARTIFACT_ROOT" \
107
+ --subject-id "$PROVENANCE_SUBJECT" \
108
+ --actor-json "$ACTOR_A" \
109
+ --branch "$BRANCH_A" \
110
+ --artifact-dir "$ARTIFACT_DIR_A" \
111
+ --ttl-seconds 1800 \
112
+ > "$TMPDIR_EVAL/provenance-refresh.json"
113
+ [[ "$(json_query "$TMPDIR_EVAL/provenance-refresh.json" "record.work_item_ref")" == "$SUBJECT_ID" ]] && pass "same-actor refresh preserves exact Work Item provenance" || fail "same-actor refresh preserves exact Work Item provenance"
114
+ node "$CLI" assignment-provider supersede \
115
+ --provider local-file --artifact-root "$ARTIFACT_ROOT" \
116
+ --subject-id "$PROVENANCE_SUBJECT" \
117
+ --from-actor-json "$ACTOR_A" --to-actor-json "$ACTOR_B" \
118
+ --reason "provenance handoff" >"$TMPDIR_EVAL/provenance-supersede.json"
119
+ [[ "$(json_query "$TMPDIR_EVAL/provenance-supersede.json" "record.work_item_ref")" == "$SUBJECT_ID" ]] && pass "generic supersede preserves exact Work Item provenance" || fail "generic supersede preserves exact Work Item provenance"
120
+ node "$CLI" assignment-provider release \
121
+ --provider local-file --artifact-root "$ARTIFACT_ROOT" \
122
+ --subject-id "$PROVENANCE_SUBJECT" \
123
+ --actor-json "$ACTOR_B" \
124
+ --reason "provenance fixture cleanup" >/dev/null
125
+
72
126
  # 3. supersede A -> B updates the actor and records an audit trail entry.
73
127
  node "$CLI" assignment-provider supersede \
74
128
  --provider local-file --artifact-root "$ARTIFACT_ROOT" --subject-id "$SUBJECT_ID" \