@kontourai/flow-agents 3.4.2 → 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.
Files changed (37) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/agents/dev.json +0 -5
  3. package/build/src/builder-flow-runtime.js +18 -12
  4. package/build/src/cli/assignment-provider.d.ts +3 -0
  5. package/build/src/cli/assignment-provider.js +59 -10
  6. package/build/src/cli/workflow-sidecar.js +186 -26
  7. package/build/src/lib/flow-resolver.js +7 -2
  8. package/build/src/tools/build-universal-bundles.js +0 -2
  9. package/context/contracts/assignment-provider-contract.md +5 -2
  10. package/context/scripts/hooks/workflow-steering.js +2 -40
  11. package/docs/spec/builder-flow-runtime.md +19 -7
  12. package/docs/spec/runtime-hook-surface.md +4 -4
  13. package/evals/integration/test_assignment_provider_local_file.sh +54 -0
  14. package/evals/integration/test_builder_entry_enforcement.sh +369 -22
  15. package/evals/integration/test_builder_step_producers.sh +1 -1
  16. package/evals/integration/test_bundle_install.sh +46 -3
  17. package/evals/integration/test_current_json_per_actor.sh +3 -2
  18. package/evals/integration/test_dual_emit_flow_step.sh +43 -9
  19. package/evals/integration/test_flowdef_session_activation.sh +15 -6
  20. package/evals/integration/test_goal_fit_escape_hatch.sh +3 -3
  21. package/evals/integration/test_install_merge.sh +24 -0
  22. package/evals/integration/test_phase_map_and_gate_claim.sh +14 -10
  23. package/evals/integration/test_resolvefirststep_security.sh +1 -1
  24. package/evals/integration/test_sidecar_field_preservation.sh +4 -2
  25. package/evals/integration/test_takeover_protocol.sh +1 -1
  26. package/evals/integration/test_workflow_sidecar_writer.sh +18 -6
  27. package/evals/integration/test_workflow_steering_hook.sh +0 -72
  28. package/package.json +1 -1
  29. package/schemas/workflow-state.schema.json +1 -0
  30. package/scripts/hooks/workflow-steering.js +2 -40
  31. package/scripts/install-merge.js +2 -0
  32. package/src/builder-flow-runtime.ts +23 -12
  33. package/src/cli/assignment-provider.ts +55 -11
  34. package/src/cli/builder-flow-runtime.test.mjs +32 -0
  35. package/src/cli/workflow-sidecar.ts +183 -28
  36. package/src/lib/flow-resolver.ts +6 -2
  37. package/src/tools/build-universal-bundles.ts +0 -2
@@ -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, type ParsedArgs } from "../lib/args.js";
7
- import { readJson, writeJson, isoNow } from "../lib/fs.js";
7
+ import { atomicWriteJson, readJson, isoNow } from "../lib/fs.js";
8
8
 
9
9
  // ─── AssignmentProvider CLI (#290) ──────────────────────────────────────────
10
10
  // context/contracts/assignment-provider-contract.md is the governing vocabulary doc for this
@@ -60,6 +60,7 @@ export type AssignmentClaimRecord = {
60
60
  subject_id: string;
61
61
  actor: ActorStruct;
62
62
  actor_key?: string;
63
+ work_item_ref?: string;
63
64
  claimed_at: string;
64
65
  ttl_seconds: number;
65
66
  branch: string;
@@ -218,16 +219,50 @@ export function assignmentFilePath(artifactRoot: string, subjectId: string): str
218
219
  return path.join(artifactRoot, "assignment", `${sanitized}.json`);
219
220
  }
220
221
 
222
+ function localAssignmentDir(artifactRoot: string, create: boolean): string | null {
223
+ const dir = path.join(artifactRoot, "assignment");
224
+ if (create) fs.mkdirSync(dir, { recursive: true });
225
+ let stat: fs.Stats;
226
+ try {
227
+ stat = fs.lstatSync(dir);
228
+ } catch (error) {
229
+ if (!create && (error as NodeJS.ErrnoException).code === "ENOENT") return null;
230
+ throw error;
231
+ }
232
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
233
+ throw new Error(`assignment directory must be a real directory, not a symlink: ${dir}`);
234
+ }
235
+ const realRoot = fs.realpathSync(artifactRoot);
236
+ if (fs.realpathSync(dir) !== path.join(realRoot, "assignment")) {
237
+ throw new Error(`assignment directory escapes the artifact root: ${dir}`);
238
+ }
239
+ return dir;
240
+ }
241
+
221
242
  export function readLocalRecord(artifactRoot: string, subjectId: string): AssignmentClaimRecord | null {
243
+ if (!localAssignmentDir(artifactRoot, false)) return null;
222
244
  const file = assignmentFilePath(artifactRoot, subjectId);
223
- if (!fs.existsSync(file)) return null;
245
+ let stat: fs.Stats;
246
+ try {
247
+ stat = fs.lstatSync(file);
248
+ } catch (error) {
249
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
250
+ throw error;
251
+ }
252
+ if (stat.isSymbolicLink() || !stat.isFile()) {
253
+ throw new Error(`assignment record must be a regular file, not a symlink: ${file}`);
254
+ }
224
255
  let data: unknown;
256
+ let descriptor: number | null = null;
225
257
  try {
226
- data = readJson(file);
258
+ descriptor = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
259
+ data = JSON.parse(fs.readFileSync(descriptor, "utf8"));
227
260
  } catch (error) {
228
261
  // Fail loud: a corrupt claim record must never be silently treated as "no claim" — that
229
262
  // would be a fail-open path that could let a second claim silently overwrite a real one.
230
263
  throw new Error(`assignment record is corrupt, refusing to proceed: ${file}: ${(error as Error).message}`);
264
+ } finally {
265
+ if (descriptor !== null) fs.closeSync(descriptor);
231
266
  }
232
267
  if (typeof data !== "object" || data === null) throw new Error(`assignment record is not an object: ${file}`);
233
268
  const record = data as AssignmentClaimRecord;
@@ -239,7 +274,7 @@ export function writeLocalRecord(artifactRoot: string, subjectId: string, record
239
274
  // writeJson throws on any mkdir/writeFileSync failure; that error is intentionally allowed to
240
275
  // propagate to main()'s top-level try/catch and exit non-zero. Durable writes must fail loud,
241
276
  // never fail open (artifact-contract.md).
242
- writeJson(assignmentFilePath(artifactRoot, subjectId), record);
277
+ atomicWriteJson(artifactRoot, assignmentFilePath(artifactRoot, subjectId), record);
243
278
  }
244
279
 
245
280
  /**
@@ -255,8 +290,7 @@ function sleepSync(ms: number): void {
255
290
  }
256
291
 
257
292
  function subjectLockDir(artifactRoot: string, subjectId: string): string {
258
- const assignmentDir = path.join(artifactRoot, "assignment");
259
- fs.mkdirSync(assignmentDir, { recursive: true });
293
+ const assignmentDir = localAssignmentDir(artifactRoot, true)!;
260
294
  const sanitized = loadActorIdentityHelper().sanitizeSegment(subjectId);
261
295
  return path.join(assignmentDir, `.${sanitized}.lockdir`);
262
296
  }
@@ -310,11 +344,19 @@ export function withSubjectLock<T>(artifactRoot: string, subjectId: string, body
310
344
  sleepSync(20);
311
345
  }
312
346
  }
347
+ const release = (): void => fs.rmSync(lockDir, { recursive: true, force: true });
348
+ let result: T;
313
349
  try {
314
- return body();
315
- } finally {
316
- fs.rmSync(lockDir, { recursive: true, force: true });
350
+ result = body();
351
+ } catch (error) {
352
+ release();
353
+ throw error;
354
+ }
355
+ if (result && typeof (result as { then?: unknown }).then === "function") {
356
+ return Promise.resolve(result).finally(release) as T;
317
357
  }
358
+ release();
359
+ return result;
318
360
  }
319
361
 
320
362
  /**
@@ -512,7 +554,7 @@ export function performLocalClaim(
512
554
  artifactRoot: string,
513
555
  subjectId: string,
514
556
  actor: ActorStruct,
515
- opts: { ttlSeconds: number; branch: string; artifactDir: string; reason?: string; actorKey?: string },
557
+ opts: { ttlSeconds: number; branch: string; artifactDir: string; reason?: string; actorKey?: string; workItemRef?: string },
516
558
  ): AssignmentClaimRecord {
517
559
  const helper = loadActorIdentityHelper();
518
560
  const reason = opts.reason ?? "claim";
@@ -538,6 +580,7 @@ export function performLocalClaim(
538
580
  subject_id: subjectId,
539
581
  actor,
540
582
  ...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
583
+ ...((opts.workItemRef ?? existing?.work_item_ref) ? { work_item_ref: opts.workItemRef ?? existing?.work_item_ref } : {}),
541
584
  claimed_at: isoNow(),
542
585
  ttl_seconds: opts.ttlSeconds,
543
586
  branch: opts.branch,
@@ -707,7 +750,7 @@ export function performLocalSupersede(
707
750
  subjectId: string,
708
751
  fromActor: ActorStruct,
709
752
  toActor: ActorStruct,
710
- opts: { ttlSeconds?: number; branch?: string; artifactDir?: string; reason?: string; actorKey?: string } = {},
753
+ opts: { ttlSeconds?: number; branch?: string; artifactDir?: string; reason?: string; actorKey?: string; workItemRef?: string } = {},
711
754
  ): AssignmentClaimRecord {
712
755
  const helper = loadActorIdentityHelper();
713
756
  const reason = opts.reason ?? "supersede";
@@ -732,6 +775,7 @@ export function performLocalSupersede(
732
775
  subject_id: subjectId,
733
776
  actor: toActor,
734
777
  ...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
778
+ ...((opts.workItemRef ?? existing.work_item_ref) ? { work_item_ref: opts.workItemRef ?? existing.work_item_ref } : {}),
735
779
  claimed_at: isoNow(),
736
780
  ttl_seconds: ttlSeconds,
737
781
  branch: opts.branch ?? existing.branch,
@@ -172,6 +172,38 @@ test("small-model client can start and advance from projected actions without ch
172
172
  assert.equal(duplicate.run.manifest.evidence.length, advanced.run.manifest.evidence.length);
173
173
  });
174
174
 
175
+ test("automatic start refuses a slug-bound run for another Work Item without mutation", async () => {
176
+ const session = makeSession("start-subject-mismatch");
177
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
178
+ const sidecar = readJson(path.join(session.sessionDir, "state.json"));
179
+ sidecar.work_item_refs = ["local:work-item/other"];
180
+ writeJson(path.join(session.sessionDir, "state.json"), sidecar);
181
+ const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
182
+ const beforeProjection = snapshotProjectionTargets(session);
183
+
184
+ await assert.rejects(
185
+ () => startBuilderFlowSession({ sessionDir: session.sessionDir }),
186
+ /flow_run\.state\.subject.*selected Work Item/,
187
+ );
188
+ assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
189
+ assert.deepEqual(snapshotProjectionTargets(session), beforeProjection);
190
+ });
191
+
192
+ test("automatic start refuses a sidecar changed after its immutable subject snapshot", async () => {
193
+ const session = makeSession("start-sidecar-race");
194
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
195
+ const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
196
+ const startup = startBuilderFlowSession({ sessionDir: session.sessionDir });
197
+ const changed = readJson(path.join(session.sessionDir, "state.json"));
198
+ changed.next_action = { status: "continue", summary: "concurrent change" };
199
+ writeJson(path.join(session.sessionDir, "state.json"), changed);
200
+ const beforeProjection = snapshotProjectionTargets(session);
201
+
202
+ await assert.rejects(() => startup, /state\.json.*changed during projection/);
203
+ assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
204
+ assert.deepEqual(snapshotProjectionTargets(session), beforeProjection);
205
+ });
206
+
175
207
  test("wrong workflow subject is rejected before canonical Flow mutation", async () => {
176
208
  const session = makeSession("wrong-subject");
177
209
  await startBuilderFlowSession({ sessionDir: session.sessionDir });
@@ -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, type ActiveFlowStep } from "../lib/flow-resolver.js";
11
11
  import { defaultArtifactRootForRead, flowAgentsArtifactRoot } from "../lib/local-artifact-root.js";
12
- import { syncBuilderFlowSessionIfPresent } from "../builder-flow-runtime.js";
12
+ import { ensureSafeDirectory } from "../lib/fs.js";
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, type ActorStruct, type EffectiveState, type FreshHolder } from "./assignment-provider.js";
18
+ import { assignmentFilePath, computeEffectiveState, performLocalClaim, performLocalSupersede, readLocalAssignmentStatus, withSubjectLock, type ActorStruct, type EffectiveState, type FreshHolder } from "./assignment-provider.js";
18
19
 
19
20
  type AnyObj = Record<string, any>;
20
21
 
@@ -57,6 +58,12 @@ function workItemSlug(ref: string): string {
57
58
  return slugify(`${owner}-${repo}-${id}`, "work-item");
58
59
  }
59
60
 
61
+ function assignmentSubjectMatchesWorkItem(slug: string, ref: string): boolean {
62
+ if (ref === `local:${slug}`) return true;
63
+ if (!/^[^/#]+\/[^/#]+#\d+$/.test(ref)) return false;
64
+ return workItemSlug(ref) === slug;
65
+ }
66
+
60
67
  type SessionWorkItem = {
61
68
  ref: string;
62
69
  localRecord?: AnyObj;
@@ -64,13 +71,18 @@ type SessionWorkItem = {
64
71
 
65
72
  function sessionWorkItem(p: ReturnType<typeof parseArgs>, slug: string, dir: string): SessionWorkItem {
66
73
  const providerRef = opt(p, "work-item");
74
+ const existingState = loadJson(path.join(dir, "state.json"));
75
+ const existingRef = Array.isArray(existingState.work_item_refs) && typeof existingState.work_item_refs[0] === "string"
76
+ ? existingState.work_item_refs[0]
77
+ : "";
67
78
  if (providerRef) {
79
+ if (existingRef && existingRef !== providerRef) {
80
+ die(`ensure-session refused: session ${JSON.stringify(slug)} is already bound to Work Item ${JSON.stringify(existingRef)}, not ${JSON.stringify(providerRef)}`);
81
+ }
68
82
  return { ref: providerRef };
69
83
  }
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] };
84
+ if (existingRef) {
85
+ return { ref: existingRef };
74
86
  }
75
87
 
76
88
  const title = opt(p, "title", slug).trim() || slug;
@@ -1312,8 +1324,15 @@ async function withLock<T>(dir: string, create: boolean, command: string, body:
1312
1324
  const lockDir = path.join(dir, ".workflow-sidecar.lockdir");
1313
1325
  const staleMs = Number(process.env.FLOW_AGENTS_WORKFLOW_SIDECAR_STALE_LOCK_MS ?? 5 * 60 * 1000);
1314
1326
  const deadline = Date.now() + 30000;
1327
+ let acquiredRoot: fs.Stats | null = null;
1328
+ let acquiredLock: fs.Stats | null = null;
1315
1329
  while (true) {
1316
- try { fs.mkdirSync(lockDir); break; }
1330
+ try {
1331
+ fs.mkdirSync(lockDir);
1332
+ acquiredRoot = fs.lstatSync(dir);
1333
+ acquiredLock = fs.lstatSync(lockDir);
1334
+ break;
1335
+ }
1317
1336
  catch (error) {
1318
1337
  const lockError = error as NodeJS.ErrnoException;
1319
1338
  if (lockError.code !== "EEXIST") {
@@ -1338,7 +1357,28 @@ async function withLock<T>(dir: string, create: boolean, command: string, body:
1338
1357
  if (delay) await new Promise((resolve) => setTimeout(resolve, Number(delay) * 1000));
1339
1358
  return await body();
1340
1359
  } finally {
1341
- fs.rmSync(lockDir, { recursive: true, force: true });
1360
+ try {
1361
+ const currentRoot = fs.lstatSync(dir);
1362
+ const currentLock = fs.lstatSync(lockDir);
1363
+ const sameIdentity = (left: fs.Stats, right: fs.Stats): boolean =>
1364
+ left.dev === right.dev && left.ino === right.ino;
1365
+ if (acquiredRoot
1366
+ && acquiredLock
1367
+ && !currentRoot.isSymbolicLink()
1368
+ && currentRoot.isDirectory()
1369
+ && !currentLock.isSymbolicLink()
1370
+ && currentLock.isDirectory()
1371
+ && sameIdentity(currentRoot, acquiredRoot)
1372
+ && sameIdentity(currentLock, acquiredLock)) {
1373
+ fs.rmdirSync(lockDir);
1374
+ } else {
1375
+ process.stderr.write(`[workflow-sidecar] lock cleanup skipped because root or lock identity changed: ${lockDir}\n`);
1376
+ }
1377
+ } catch (error) {
1378
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
1379
+ process.stderr.write(`[workflow-sidecar] lock cleanup skipped: ${error instanceof Error ? error.message : String(error)}\n`);
1380
+ }
1381
+ }
1342
1382
  }
1343
1383
  }
1344
1384
 
@@ -1414,6 +1454,15 @@ function sessionDirFor(root: string, slug: string): string {
1414
1454
  return dir;
1415
1455
  }
1416
1456
 
1457
+ function assertSafeSessionDirectory(root: string, dir: string): void {
1458
+ if (!fs.existsSync(dir)) return;
1459
+ const stat = fs.lstatSync(dir);
1460
+ const expected = path.join(fs.realpathSync(root), path.basename(dir));
1461
+ if (stat.isSymbolicLink() || !stat.isDirectory() || fs.realpathSync(dir) !== expected) {
1462
+ die(`session directory must be a real directory under the artifact root: ${dir}`);
1463
+ }
1464
+ }
1465
+
1417
1466
  function validateAgentId(agent: string): string {
1418
1467
  if (!agent) die("--agent-id is required");
1419
1468
  if (path.isAbsolute(agent)) die("--agent-id must be a relative slug");
@@ -1453,17 +1502,20 @@ function findRepoRootFromDirStrict(startDir: string): string | null {
1453
1502
  * Find the repository root by walking upward from a starting directory to locate
1454
1503
  * the nearest ancestor containing a kits/ subdirectory. Mirrors flow-resolver.ts
1455
1504
  * findRepoRoot, but callable from workflow-sidecar.ts without re-importing the
1456
- * internal helper. Falls back to process.cwd() when no kits/ ancestor is found —
1457
- * appropriate for phase-map/first-step resolution (ADR 0016 Abstraction A, P-d),
1458
- * where the caller is always invoked from within a real repo checkout.
1505
+ * internal helper. A canonical `.kontourai` path falls back to its owning project,
1506
+ * allowing installed package assets to resolve independently of ambient cwd. Other
1507
+ * layouts retain the cwd fallback used by standalone primitive sessions.
1459
1508
  *
1460
- * Do NOT use this cwd-falling-back variant for publishDelivery's repo-root
1461
- * resolution — use findRepoRootFromDirStrict there instead, so a scratch/test
1509
+ * Do NOT use this permissive variant for publishDelivery's repo-root resolution —
1510
+ * use findRepoRootFromDirStrict there instead, so a scratch/test
1462
1511
  * session dir with no repo ancestor fails closed (skips publish) rather than
1463
1512
  * silently trusting process.cwd(), which could be an unrelated real repo.
1464
1513
  */
1465
1514
  function findRepoRootFromDir(startDir: string): string {
1466
- return findRepoRootFromDirStrict(startDir) ?? process.cwd();
1515
+ const discovered = findRepoRootFromDirStrict(startDir);
1516
+ if (discovered) return discovered;
1517
+ if (path.basename(startDir) === ".kontourai") return path.dirname(startDir);
1518
+ return process.cwd();
1467
1519
  }
1468
1520
 
1469
1521
  /**
@@ -1758,10 +1810,11 @@ function enforceEnsureSessionOwnership(
1758
1810
  slug: string,
1759
1811
  dir: string,
1760
1812
  resolution: { actorStruct: ActorStruct; actorKey: string; branchActorKey: string; unresolved: boolean },
1761
- ): void {
1813
+ workItemRef?: string,
1814
+ ): { assignmentFile: string; actorKey: string } | null {
1762
1815
  if (p.flags.has("skip-ownership-guard")) {
1763
1816
  process.stderr.write("[ensure-session] ownership guard skipped via --skip-ownership-guard\n");
1764
- return;
1817
+ return null;
1765
1818
  }
1766
1819
  // Design Decision 4 (unchanged from resolveSessionBranch): actor-resolution ambiguity never
1767
1820
  // hard-fails session creation. Without a resolvable actor there is no safe identity to claim
@@ -1769,7 +1822,7 @@ function enforceEnsureSessionOwnership(
1769
1822
  // than claiming under a synthetic/unstable identity.
1770
1823
  if (resolution.unresolved) {
1771
1824
  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");
1772
- return;
1825
+ return null;
1773
1826
  }
1774
1827
 
1775
1828
  // F5 fix (fix-plan iteration 1, LOW): match assignment-provider.ts's sanitizeDisplayField
@@ -1821,6 +1874,9 @@ function enforceEnsureSessionOwnership(
1821
1874
  // `assignment-provider status --self-actor <branchActorKey>` run by a DIFFERENT tool
1822
1875
  // afterward would never recognize this guard's own claim as self.
1823
1876
  const assignment = readLocalAssignmentStatus(root, slug);
1877
+ if (workItemRef && assignment.record?.work_item_ref && assignment.record.work_item_ref !== workItemRef) {
1878
+ 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)}`);
1879
+ }
1824
1880
  const events = readLivenessEvents(root);
1825
1881
  const freshList = loadLivenessReadHelper().freshHolders(events, slug, resolution.branchActorKey, nowMs);
1826
1882
  effective = computeEffectiveState(assignment, freshList, resolution.branchActorKey, nowMs);
@@ -1831,9 +1887,28 @@ function enforceEnsureSessionOwnership(
1831
1887
  // neither applies, this is a documented scope boundary (today's pre-#291 baseline behavior),
1832
1888
  // never a silent hole.
1833
1889
  process.stderr.write(`[ensure-session] ownership guard not evaluated: provider "${sanitize(assignmentProviderKind)}" requires --effective-state-json\n`);
1834
- return;
1890
+ return null;
1835
1891
  }
1836
1892
 
1893
+ const selectedWorkEvidence = (): { assignmentFile: string; actorKey: string } | null => {
1894
+ // Only a claim read from the local provider is durable evidence. Precomputed provider state
1895
+ // can authorize entry, but it cannot prove that this process acquired the Work Item.
1896
+ if (!workItemRef || effectiveStateJsonFlag || assignmentProviderKind !== "local-file") return null;
1897
+ const assignment = readLocalAssignmentStatus(root, slug);
1898
+ const record = assignment.record;
1899
+ if (!record
1900
+ || record.status !== "claimed"
1901
+ || record.subject_id !== slug
1902
+ || record.actor_key !== resolution.branchActorKey
1903
+ || record.work_item_ref !== workItemRef) {
1904
+ return null;
1905
+ }
1906
+ return {
1907
+ assignmentFile: assignmentFilePath(root, slug),
1908
+ actorKey: resolution.branchActorKey,
1909
+ };
1910
+ };
1911
+
1837
1912
  const resolveBranchForClaim = (): string => {
1838
1913
  const existingBranch = fs.existsSync(path.join(dir, "state.json")) ? (loadJson(path.join(dir, "state.json")).branch as string | undefined) : undefined;
1839
1914
  return existingBranch || resolveSessionBranch(p, slug);
@@ -1846,17 +1921,17 @@ function enforceEnsureSessionOwnership(
1846
1921
  // ActorStruct triple), so this redundant belt-and-suspenders check agrees with the
1847
1922
  // effective_state computation instead of silently using a different identity.
1848
1923
  const isSelf = effective.reason === "self_is_holder" || (!!effective.holder?.actor && effective.holder.actor === resolution.branchActorKey);
1849
- if (isSelf) return; // resume own session — no refusal
1924
+ if (isSelf) return selectedWorkEvidence();
1850
1925
  const holderActor = sanitize(effective.holder?.actor ?? "unknown");
1851
1926
  const lastAtSuffix = effective.holder?.last_at ? ` (last_at ${sanitize(effective.holder.last_at)})` : "";
1852
1927
  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.`);
1853
- return;
1928
+ return null;
1854
1929
  }
1855
1930
  case "human-held": {
1856
1931
  const assignee = effective.holder?.assignee ? sanitize(effective.holder.assignee) : "an assigned human";
1857
1932
  const idleSuffix = effective.holder?.idle_days != null ? ` (idle ${Number(effective.holder.idle_days)} day(s))` : "";
1858
1933
  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.`);
1859
- return;
1934
+ return null;
1860
1935
  }
1861
1936
  case "reclaimable": {
1862
1937
  const holderActor = sanitize(effective.holder?.actor ?? "unknown");
@@ -1889,11 +1964,12 @@ function enforceEnsureSessionOwnership(
1889
1964
  // computeEffectiveState's holderActorKey (assignment-provider.ts) matches this same
1890
1965
  // branchActorKey string on the next status/guard check, cross-tool.
1891
1966
  actorKey: resolution.branchActorKey,
1967
+ ...(workItemRef ? { workItemRef } : {}),
1892
1968
  });
1893
1969
  // Render-don't-execute: emit the incumbent's branch so the skill continues it (never a new
1894
1970
  // branch). The successor re-enters the SAME artifact dir (deterministic slug) by construction.
1895
1971
  printJson({ role: "SupersedeTakeover", subject: sanitize(slug), superseded_actor: holderActor, ...(incumbentBranch ? { resumed_branch: sanitizeWide(incumbentBranch) } : {}), reason: sanitizeWide(takeoverReason) });
1896
- return;
1972
+ return selectedWorkEvidence();
1897
1973
  }
1898
1974
  case "free": {
1899
1975
  performLocalClaim(root, slug, resolution.actorStruct, {
@@ -1903,8 +1979,9 @@ function enforceEnsureSessionOwnership(
1903
1979
  reason: opt(p, "reason", "ensure-session entry"),
1904
1980
  // F1 fix (fix-plan iteration 1, HIGH): see the performLocalSupersede call above.
1905
1981
  actorKey: resolution.branchActorKey,
1982
+ ...(workItemRef ? { workItemRef } : {}),
1906
1983
  });
1907
- return;
1984
+ return selectedWorkEvidence();
1908
1985
  }
1909
1986
  default:
1910
1987
  die(`ensure-session ownership guard: unrecognized effective_state ${JSON.stringify(effective.effective_state)}`);
@@ -1947,27 +2024,66 @@ function resolveEnsureSessionEntry(p: ReturnType<typeof parseArgs>, dir: string)
1947
2024
  return { flowId, stepId: explicitStep || firstStep, firstStep };
1948
2025
  }
1949
2026
 
2027
+ function assertCanonicalBuilderArtifactRoot(root: string): void {
2028
+ const kontouraiRoot = path.dirname(root);
2029
+ const projectRoot = path.dirname(kontouraiRoot);
2030
+ if (path.basename(root) !== "flow-agents" || path.basename(kontouraiRoot) !== ".kontourai") {
2031
+ die("ensure-session --flow-id builder.build requires --artifact-root <project>/.kontourai/flow-agents");
2032
+ }
2033
+
2034
+ const statIfPresent = (candidate: string): fs.Stats | null => {
2035
+ try {
2036
+ return fs.lstatSync(candidate);
2037
+ } catch (error) {
2038
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
2039
+ throw error;
2040
+ }
2041
+ };
2042
+ const projectStat = statIfPresent(projectRoot);
2043
+ if (projectStat && (projectStat.isSymbolicLink() || !projectStat.isDirectory())) {
2044
+ die(`ensure-session --flow-id builder.build requires a non-symlink project root: ${projectRoot}`);
2045
+ }
2046
+ const realProjectRoot = projectStat ? fs.realpathSync(projectRoot) : projectRoot;
2047
+ for (const [candidate, expected, label] of [
2048
+ [kontouraiRoot, path.join(realProjectRoot, ".kontourai"), ".kontourai root"],
2049
+ [root, path.join(realProjectRoot, ".kontourai", "flow-agents"), "Flow Agents artifact root"],
2050
+ ] as const) {
2051
+ const stat = statIfPresent(candidate);
2052
+ if (!stat) continue;
2053
+ if (stat.isSymbolicLink() || !stat.isDirectory() || fs.realpathSync(candidate) !== expected) {
2054
+ die(`ensure-session --flow-id builder.build requires a non-symlink ${label}: ${candidate}`);
2055
+ }
2056
+ }
2057
+ }
2058
+
1950
2059
  function preflightEnsureSession(p: ReturnType<typeof parseArgs>): void {
1951
2060
  const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
1952
2061
  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)"));
1953
2062
  const dir = sessionDirFor(root, slug);
1954
- resolveEnsureSessionEntry(p, dir);
2063
+ assertSafeSessionDirectory(root, dir);
2064
+ const entry = resolveEnsureSessionEntry(p, dir);
2065
+ if (entry.flowId === "builder.build") assertCanonicalBuilderArtifactRoot(root);
1955
2066
  sessionWorkItem(p, slug, dir);
1956
2067
  }
1957
2068
 
1958
- function ensureSession(p: ReturnType<typeof parseArgs>): number {
2069
+ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
1959
2070
  const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
1960
2071
  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)"));
1961
2072
  const dir = sessionDirFor(root, slug);
2073
+ assertSafeSessionDirectory(root, dir);
1962
2074
  const entry = resolveEnsureSessionEntry(p, dir);
2075
+ if (entry.flowId === "builder.build") assertCanonicalBuilderArtifactRoot(root);
1963
2076
  const workItem = sessionWorkItem(p, slug, dir);
1964
2077
  // #291 Wave 2 Task 2.1 (§3, §4): resolve the actor ONCE, then run the ownership guard BEFORE
1965
2078
  // any directory/file is created — a refusal must never leave a stray empty session dir. Reused
1966
2079
  // below (writeCurrent's per-actor dual-write) so the branch-naming actor and the
1967
2080
  // assignment-claim actor are always the same identity.
1968
2081
  const actorResolution = resolveEnsureSessionActor(p);
1969
- enforceEnsureSessionOwnership(p, root, slug, dir, actorResolution);
1970
- fs.mkdirSync(dir, { recursive: true });
2082
+ const selectionWorkItemRef = entry.flowId === "builder.build" && assignmentSubjectMatchesWorkItem(slug, workItem.ref)
2083
+ ? workItem.ref
2084
+ : undefined;
2085
+ const selectedWorkEvidence = enforceEnsureSessionOwnership(p, root, slug, dir, actorResolution, selectionWorkItemRef);
2086
+ ensureSafeDirectory(root, dir);
1971
2087
  const timestamp = opt(p, "timestamp", now());
1972
2088
  if (workItem.localRecord && !fs.existsSync(path.join(dir, "work-item.json"))) {
1973
2089
  writeJson(path.join(dir, "work-item.json"), workItem.localRecord);
@@ -1994,7 +2110,6 @@ function ensureSession(p: ReturnType<typeof parseArgs>): number {
1994
2110
  summary: `Start the canonical Flow run; activate \`pull-work\` for work item ${JSON.stringify(workItem.ref)}, and satisfy the declared gate before advancing.`,
1995
2111
  skills: ["pull-work"],
1996
2112
  command: startCommand,
1997
- enforcement: "before_tool_use",
1998
2113
  }
1999
2114
  : `Continue at Flow step ${JSON.stringify(entry.stepId)} for work item ${JSON.stringify(workItem.ref)}; satisfy its declared gate before advancing.`
2000
2115
  : opt(p, "next-action", "Continue.");
@@ -2025,6 +2140,46 @@ function ensureSession(p: ReturnType<typeof parseArgs>): number {
2025
2140
  ? persistedCurrent.active_step_id
2026
2141
  : entry.stepId;
2027
2142
  writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", entry.flowId || undefined, resumedStep || undefined, actorResolution.unresolved ? undefined : actorResolution.branchActorKey);
2143
+ if (entry.flowId === "builder.build") {
2144
+ try {
2145
+ const started = await startBuilderFlowSession({ sessionDir: dir });
2146
+ if (started.run.state.current_step === "pull-work"
2147
+ && selectedWorkEvidence
2148
+ && assignmentSubjectMatchesWorkItem(slug, workItem.ref)) {
2149
+ await withSubjectLock(root, slug, async () => {
2150
+ const assignment = readLocalAssignmentStatus(root, slug).record;
2151
+ if (!assignment
2152
+ || assignment.status !== "claimed"
2153
+ || assignment.subject_id !== slug
2154
+ || assignment.actor_key !== selectedWorkEvidence.actorKey
2155
+ || assignment.work_item_ref !== workItem.ref) {
2156
+ die(`ensure-session refused to emit selected-work evidence: assignment provenance changed before gate evaluation for ${JSON.stringify(workItem.ref)}`);
2157
+ }
2158
+ const assignmentContent = fs.readFileSync(selectedWorkEvidence.assignmentFile);
2159
+ const assignmentDigest = createHash("sha256").update(assignmentContent).digest("hex");
2160
+ const projectRoot = path.dirname(path.dirname(root));
2161
+ const evidenceFile = path.relative(projectRoot, selectedWorkEvidence.assignmentFile);
2162
+ await recordGateClaim(parseArgs([
2163
+ "record-gate-claim",
2164
+ dir,
2165
+ "--actor", selectedWorkEvidence.actorKey,
2166
+ "--expectation", "selected-work",
2167
+ "--status", "pass",
2168
+ "--summary", `Work Item ${workItem.ref} is durably assigned to the active workflow actor (assignment sha256:${assignmentDigest}).`,
2169
+ "--evidence-ref-json", JSON.stringify({
2170
+ kind: "artifact",
2171
+ file: evidenceFile,
2172
+ summary: `Durable local assignment record for the selected Work Item and active actor; sha256:${assignmentDigest}.`,
2173
+ }),
2174
+ "--timestamp", timestamp,
2175
+ ]));
2176
+ });
2177
+ }
2178
+ } catch (error) {
2179
+ 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");
2180
+ throw error;
2181
+ }
2182
+ }
2028
2183
  console.log(dir);
2029
2184
  return 0;
2030
2185
  }
@@ -449,8 +449,8 @@ export function resolvePhaseMap(flowId: string, repoRoot: string): Record<string
449
449
 
450
450
  /**
451
451
  * Find the repository root from a starting directory by walking upward to locate
452
- * the nearest ancestor that contains a `kits/` subdirectory. If none is found,
453
- * falls back to `process.cwd()` so the default "run from repo root" case still works.
452
+ * the nearest ancestor that contains a `kits/` subdirectory. A canonical `.kontourai`
453
+ * artifact path falls back to its owning project; other layouts fall back to cwd.
454
454
  *
455
455
  * This is required because the runtime artifact directory can live anywhere (temp dirs,
456
456
  * subprojects, CI workspaces) while the kits/ directory is always at the repo root.
@@ -464,6 +464,10 @@ function findRepoRoot(startDir: string): string {
464
464
  if (parent === dir) break; // reached filesystem root
465
465
  dir = parent;
466
466
  }
467
+ // A canonical product artifact root identifies its owning project even when that
468
+ // consumer has no source-tree kits/. resolveFlowFilePath can then use the installed
469
+ // package fallback without consulting an unrelated ambient cwd.
470
+ if (path.basename(startDir) === ".kontourai") return path.dirname(startDir);
467
471
  // Fallback: process.cwd() covers the common "run from repo root" case
468
472
  return process.cwd();
469
473
  }
@@ -337,7 +337,6 @@ function exportClaudeSettings(): string {
337
337
  hooks.UserPromptSubmit.push({ hooks: [shellHook(claudePolicy("UserPromptSubmit", "workflow-steering.js"), 30, "Running Flow Agents hook policy")] });
338
338
  hooks.PostToolUse.push({ hooks: [shellHook(claudePolicy("PostToolUse", "quality-gate.js"), 30, "Running Flow Agents hook policy")] });
339
339
  hooks.PostToolUse.push({ hooks: [shellHook(claudePolicy("PostToolUse", "evidence-capture.js"), 30, "Capturing Flow Agents command evidence")] });
340
- hooks.PreToolUse.push({ hooks: [shellHook(claudePolicy("PreToolUse", "workflow-steering.js"), 30, "Enforcing Flow Agents projected action")] });
341
340
  hooks.PreToolUse.push({ hooks: [shellHook(claudePolicy("PreToolUse", "config-protection.js"), 30, "Running Flow Agents hook policy")] });
342
341
  return `${JSON.stringify({
343
342
  statusLine: { type: "command", command: 'bash -lc \'root="${CLAUDE_PROJECT_DIR:-$(pwd)}"; node "$root/scripts/statusline/flow-agents-statusline.js"\'' },
@@ -355,7 +354,6 @@ function exportCodexHooks(): string {
355
354
  hooks.SessionStart.push({ hooks: [shellHook(codexPolicy("workflow-steering.js"), 30, "Running Flow Agents hook policy")] });
356
355
  hooks.UserPromptSubmit.push({ hooks: [shellHook(codexPolicy("workflow-steering.js"), 30, "Running Flow Agents hook policy")] });
357
356
  hooks.PostToolUse.push({ hooks: [shellHook(codexPolicy("evidence-capture.js"), 30, "Capturing Flow Agents command evidence")] });
358
- hooks.PreToolUse.push({ hooks: [shellHook(codexPolicy("workflow-steering.js"), 30, "Enforcing Flow Agents projected action")] });
359
357
  return `${JSON.stringify({ hooks }, null, 2)}\n`;
360
358
  }
361
359