@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.
@@ -50,6 +50,48 @@ else
50
50
  fail "symlinked Builder root wrote externally or returned the wrong diagnostic: $(cat "$TMP/symlink-root.out")"
51
51
  fi
52
52
 
53
+ SESSION_SYMLINK_PROJECT="$TMP/session-symlink-project"
54
+ SESSION_SYMLINK_ROOT="$SESSION_SYMLINK_PROJECT/.kontourai/flow-agents"
55
+ SESSION_SYMLINK_EXTERNAL="$TMP/session-symlink-external"
56
+ mkdir -p "$SESSION_SYMLINK_ROOT" "$SESSION_SYMLINK_EXTERNAL"
57
+ ln -s "$SESSION_SYMLINK_EXTERNAL" "$SESSION_SYMLINK_ROOT/session-symlink"
58
+ if flow_agents_node "$WRITER" ensure-session \
59
+ --artifact-root "$SESSION_SYMLINK_ROOT" \
60
+ --task-slug session-symlink \
61
+ --actor builder-entry-session-symlink \
62
+ --title "Session symlink" \
63
+ --summary "Session state must not escape the artifact root." \
64
+ --flow-id builder.build >"$TMP/session-symlink.out" 2>&1; then
65
+ fail "symlinked session directory should be rejected"
66
+ elif [[ -z "$(find "$SESSION_SYMLINK_EXTERNAL" -mindepth 1 -print -quit)" ]] \
67
+ && grep -q 'session directory must be a real directory under the artifact root' "$TMP/session-symlink.out"; then
68
+ pass "symlinked session directory is rejected before external reads or writes"
69
+ else
70
+ fail "symlinked session directory escaped or returned the wrong diagnostic: $(cat "$TMP/session-symlink.out")"
71
+ fi
72
+
73
+ NESTED_PROJECT="$TMP/nested-symlink-project"
74
+ NESTED_ROOT="$NESTED_PROJECT/.kontourai/flow-agents"
75
+ NESTED_EXTERNAL="$TMP/nested-symlink-external.json"
76
+ mkdir -p "$NESTED_ROOT/assignment"
77
+ printf 'external must survive\n' >"$NESTED_EXTERNAL"
78
+ ln -s "$NESTED_EXTERNAL" "$NESTED_ROOT/assignment/nested-symlink.json"
79
+ if flow_agents_node "$WRITER" ensure-session \
80
+ --artifact-root "$NESTED_ROOT" \
81
+ --task-slug nested-symlink \
82
+ --actor builder-entry-nested-symlink \
83
+ --title "Nested assignment symlink" \
84
+ --summary "Assignment evidence must stay inside the product root." \
85
+ --flow-id builder.build >"$TMP/nested-symlink.out" 2>&1; then
86
+ fail "symlinked assignment record should be rejected"
87
+ elif [[ "$(cat "$NESTED_EXTERNAL")" == "external must survive" ]] \
88
+ && [[ ! -e "$NESTED_ROOT/nested-symlink" ]] \
89
+ && grep -q 'assignment record must be a regular file, not a symlink' "$TMP/nested-symlink.out"; then
90
+ pass "nested assignment symlink is rejected before external write or trust evidence"
91
+ else
92
+ fail "nested assignment symlink escaped or returned the wrong diagnostic: $(cat "$TMP/nested-symlink.out")"
93
+ fi
94
+
53
95
  RACE_PROJECT="$TMP/race-project"
54
96
  RACE_ROOT="$RACE_PROJECT/.kontourai/flow-agents"
55
97
  RACE_MOVED="$RACE_PROJECT/.kontourai/flow-agents-acquired"
@@ -146,6 +188,7 @@ LOCAL_ROOT="$TMP/local/.kontourai/flow-agents"
146
188
  if flow_agents_node "$WRITER" ensure-session \
147
189
  --artifact-root "$LOCAL_ROOT" \
148
190
  --task-slug local-request \
191
+ --actor builder-entry-local \
149
192
  --title "Local request" \
150
193
  --summary "Providerless work still needs an anchor." \
151
194
  --flow-id builder.build \
@@ -158,19 +201,24 @@ const current = JSON.parse(fs.readFileSync(path.join(root, 'current.json'), 'utf
158
201
  const state = JSON.parse(fs.readFileSync(path.join(root, 'local-request', 'state.json'), 'utf8'));
159
202
  const workItem = JSON.parse(fs.readFileSync(path.join(root, 'local-request', 'work-item.json'), 'utf8'));
160
203
  const flowState = JSON.parse(fs.readFileSync(path.join(path.dirname(root), 'flow', 'runs', 'local-request', 'state.json'), 'utf8'));
161
- if (current.active_flow_id !== 'builder.build' || current.active_step_id !== 'pull-work') process.exit(1);
162
- if (state.status !== 'new' || state.phase !== 'pickup') process.exit(1);
204
+ if (current.active_flow_id !== 'builder.build' || current.active_step_id !== 'design-probe') process.exit(1);
205
+ if (state.status !== 'in_progress' || state.phase !== 'pickup') process.exit(1);
163
206
  if (JSON.stringify(state.work_item_refs) !== JSON.stringify(['local:local-request'])) process.exit(1);
164
207
  if (workItem.id !== 'local-request' || workItem.title !== 'Local request') process.exit(1);
165
208
  if (workItem.source_provider?.kind !== 'local' || workItem.source_provider?.path !== 'work-item.json') process.exit(1);
166
- if (flowState.current_step !== 'pull-work' || flowState.subject !== 'local:local-request') process.exit(1);
167
- if (state.flow_run?.current_step !== 'pull-work') process.exit(1);
168
- if (JSON.stringify(state.next_action?.skills) !== JSON.stringify(['pull-work'])) process.exit(1);
209
+ if (flowState.current_step !== 'design-probe' || flowState.subject !== 'local:local-request') process.exit(1);
210
+ if (state.flow_run?.current_step !== 'design-probe') process.exit(1);
211
+ if (JSON.stringify(state.next_action?.skills) !== JSON.stringify(['pickup-probe'])) process.exit(1);
169
212
  if (!state.next_action?.command?.includes('builder-run sync')) process.exit(1);
170
213
  if ('enforcement' in state.next_action) process.exit(1);
214
+ const bundle = JSON.parse(fs.readFileSync(path.join(root, 'local-request', 'trust.bundle'), 'utf8'));
215
+ const selected = (bundle.claims || []).find((claim) => claim.claimType === 'builder.pull-work.selected');
216
+ if (selected?.status !== 'verified') process.exit(1);
217
+ if (selected?.metadata?.workflow_subject_ref !== 'local:local-request') process.exit(1);
218
+ if (!(selected?.metadata?.artifact_refs || []).some((ref) => ref.file === '.kontourai/flow-agents/assignment/local-request.json')) process.exit(1);
171
219
  NODE
172
220
  then
173
- pass "providerless request creates a local Work Item and canonical Flow run at pull-work"
221
+ pass "durably acquired local Work Item satisfies pull-work through the trust bundle and Flow advances"
174
222
  else
175
223
  fail "local Work Item or first-step state is invalid"
176
224
  fi
@@ -183,6 +231,7 @@ FLOW_DIGEST_BEFORE="$(find "$TMP/local/.kontourai/flow/runs/local-request" -type
183
231
  if flow_agents_node "$WRITER" ensure-session \
184
232
  --artifact-root "$LOCAL_ROOT" \
185
233
  --task-slug local-request \
234
+ --actor builder-entry-local \
186
235
  --title "Local request" \
187
236
  --summary "Providerless work still needs an anchor." \
188
237
  --flow-id builder.build \
@@ -195,9 +244,9 @@ const project = process.argv[2];
195
244
  const session = process.argv[3];
196
245
  const flowState = JSON.parse(fs.readFileSync(path.join(project, '.kontourai', 'flow', 'runs', 'local-request', 'state.json'), 'utf8'));
197
246
  const sidecar = JSON.parse(fs.readFileSync(path.join(session, 'state.json'), 'utf8'));
198
- if (flowState.current_step !== 'pull-work' || flowState.subject !== 'local:local-request') process.exit(1);
199
- if (sidecar.flow_run?.current_step !== 'pull-work') process.exit(1);
200
- if (JSON.stringify(sidecar.next_action?.skills) !== JSON.stringify(['pull-work'])) process.exit(1);
247
+ if (flowState.current_step !== 'design-probe' || flowState.subject !== 'local:local-request') process.exit(1);
248
+ if (sidecar.flow_run?.current_step !== 'design-probe') process.exit(1);
249
+ if (JSON.stringify(sidecar.next_action?.skills) !== JSON.stringify(['pickup-probe'])) process.exit(1);
201
250
  if (!sidecar.next_action?.command?.includes('builder-run sync')) process.exit(1);
202
251
  NODE
203
252
  then
@@ -213,14 +262,15 @@ printf 'not a run-store directory\n' > "$BROKEN_PROJECT/.kontourai/flow"
213
262
  if flow_agents_node "$WRITER" ensure-session \
214
263
  --artifact-root "$BROKEN_ROOT" \
215
264
  --task-slug broken-start \
265
+ --actor builder-entry-broken \
216
266
  --title "Broken start" \
217
267
  --summary "Flow startup must fail visibly." \
218
268
  --flow-id builder.build >"$TMP/broken-start.out" 2>&1; then
219
269
  fail "invalid canonical Flow startup should fail ensure-session"
220
270
  elif [[ -f "$BROKEN_ROOT/broken-start/state.json" ]] \
221
271
  && [[ ! -e "$BROKEN_PROJECT/.kontourai/flow/runs/broken-start" ]] \
222
- && grep -q 'canonical Builder Flow run did not start' "$TMP/broken-start.out" \
223
- && grep -q 'flow-agents builder-run start --session-dir .kontourai/flow-agents/broken-start' "$TMP/broken-start.out" \
272
+ && grep -q 'canonical Builder Flow entry failed' "$TMP/broken-start.out" \
273
+ && grep -q 'Re-run the same ensure-session command' "$TMP/broken-start.out" \
224
274
  && node - "$BROKEN_ROOT/broken-start/state.json" <<'NODE'
225
275
  const fs = require('node:fs');
226
276
  const state = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
@@ -233,13 +283,30 @@ else
233
283
  fail "failed canonical startup forged state or lost recovery guidance: $(cat "$TMP/broken-start.out")"
234
284
  fi
235
285
 
236
- if flow_agents_node "$WRITER" record-gate-claim "$LOCAL_SESSION" \
237
- --expectation selected-work \
238
- --status pass \
239
- --summary "Selected local:local-request with scope and acceptance context." \
240
- --evidence-ref-json '{"kind":"artifact","file":".kontourai/flow-agents/local-request/work-item.json","summary":"Provider-neutral local Work Item."}' \
241
- >"$TMP/selected-work.out" 2>&1 \
242
- && node - "$TMP/local" "$LOCAL_SESSION" <<'NODE'
286
+ rm -f "$BROKEN_PROJECT/.kontourai/flow"
287
+ if flow_agents_node "$WRITER" ensure-session \
288
+ --artifact-root "$BROKEN_ROOT" \
289
+ --task-slug broken-start \
290
+ --actor builder-entry-broken \
291
+ --title "Broken start" \
292
+ --summary "Flow startup must recover from persisted acquisition provenance." \
293
+ --flow-id builder.build >"$TMP/broken-start-retry.out" 2>&1 \
294
+ && node - "$BROKEN_PROJECT" <<'NODE'
295
+ const fs = require('node:fs');
296
+ const path = require('node:path');
297
+ const project = process.argv[2];
298
+ const flowState = JSON.parse(fs.readFileSync(path.join(project, '.kontourai', 'flow', 'runs', 'broken-start', 'state.json'), 'utf8'));
299
+ const bundle = JSON.parse(fs.readFileSync(path.join(project, '.kontourai', 'flow-agents', 'broken-start', 'trust.bundle'), 'utf8'));
300
+ if (flowState.current_step !== 'design-probe') process.exit(1);
301
+ if (!(bundle.claims || []).some((claim) => claim.claimType === 'builder.pull-work.selected' && claim.status === 'verified')) process.exit(1);
302
+ NODE
303
+ then
304
+ pass "interrupted Flow startup retries from exact persisted acquisition provenance"
305
+ else
306
+ fail "interrupted acquisition could not recover: $(cat "$TMP/broken-start-retry.out")"
307
+ fi
308
+
309
+ if node - "$TMP/local" "$LOCAL_SESSION" <<'NODE'
243
310
  const fs = require('node:fs');
244
311
  const path = require('node:path');
245
312
  const project = process.argv[2];
@@ -251,9 +318,158 @@ if (sidecar.flow_run?.current_step !== 'design-probe') process.exit(1);
251
318
  if (JSON.stringify(sidecar.next_action?.skills) !== JSON.stringify(['pickup-probe'])) process.exit(1);
252
319
  NODE
253
320
  then
254
- pass "gate-claim write synchronizes Flow and projects the next skill"
321
+ pass "automatic selected-work claim synchronizes Flow and projects the next skill"
322
+ else
323
+ fail "automatic selected-work claim did not advance the canonical run"
324
+ fi
325
+
326
+ SKIPPED_ROOT="$TMP/skipped/.kontourai/flow-agents"
327
+ if flow_agents_node "$WRITER" ensure-session \
328
+ --artifact-root "$SKIPPED_ROOT" \
329
+ --task-slug skipped-ownership \
330
+ --actor builder-entry-skipped \
331
+ --skip-ownership-guard \
332
+ --title "Skipped ownership" \
333
+ --summary "Unproven ownership must remain at pull-work." \
334
+ --flow-id builder.build >"$TMP/skipped.out" 2>&1 \
335
+ && node - "$TMP/skipped" <<'NODE'
336
+ const fs = require('node:fs');
337
+ const path = require('node:path');
338
+ const project = process.argv[2];
339
+ const session = path.join(project, '.kontourai', 'flow-agents', 'skipped-ownership');
340
+ const flowState = JSON.parse(fs.readFileSync(path.join(project, '.kontourai', 'flow', 'runs', 'skipped-ownership', 'state.json'), 'utf8'));
341
+ const sidecar = JSON.parse(fs.readFileSync(path.join(session, 'state.json'), 'utf8'));
342
+ if (flowState.current_step !== 'pull-work' || sidecar.flow_run?.current_step !== 'pull-work') process.exit(1);
343
+ if (JSON.stringify(sidecar.next_action?.skills) !== JSON.stringify(['pull-work'])) process.exit(1);
344
+ if (fs.existsSync(path.join(session, 'trust.bundle'))) process.exit(1);
345
+ if (fs.existsSync(path.join(project, '.kontourai', 'flow-agents', 'assignment', 'skipped-ownership.json'))) process.exit(1);
346
+ NODE
347
+ then
348
+ pass "skipped ownership remains at pull-work without selected-work evidence"
349
+ else
350
+ fail "unproven ownership advanced the canonical run: $(cat "$TMP/skipped.out")"
351
+ fi
352
+
353
+ MISMATCH_ROOT="$TMP/mismatched-subject/.kontourai/flow-agents"
354
+ if flow_agents_node "$WRITER" ensure-session \
355
+ --artifact-root "$MISMATCH_ROOT" \
356
+ --task-slug arbitrary-session-name \
357
+ --work-item "kontourai/flow-agents#541" \
358
+ --actor builder-entry-mismatch \
359
+ --title "Mismatched assignment subject" \
360
+ --summary "An arbitrary slug is not exact Work Item evidence." \
361
+ --flow-id builder.build >"$TMP/mismatched-subject.out" 2>&1 \
362
+ && node - "$TMP/mismatched-subject" <<'NODE'
363
+ const fs = require('node:fs');
364
+ const path = require('node:path');
365
+ const project = process.argv[2];
366
+ const session = path.join(project, '.kontourai', 'flow-agents', 'arbitrary-session-name');
367
+ const flowState = JSON.parse(fs.readFileSync(path.join(project, '.kontourai', 'flow', 'runs', 'arbitrary-session-name', 'state.json'), 'utf8'));
368
+ const sidecar = JSON.parse(fs.readFileSync(path.join(session, 'state.json'), 'utf8'));
369
+ if (flowState.subject !== 'kontourai/flow-agents#541' || flowState.current_step !== 'pull-work') process.exit(1);
370
+ if (sidecar.flow_run?.current_step !== 'pull-work') process.exit(1);
371
+ if (fs.existsSync(path.join(session, 'trust.bundle'))) process.exit(1);
372
+ if (!fs.existsSync(path.join(project, '.kontourai', 'flow-agents', 'assignment', 'arbitrary-session-name.json'))) process.exit(1);
373
+ NODE
374
+ then
375
+ pass "assignment subject must canonically match the exact Work Item before pull-work can pass"
376
+ else
377
+ fail "mismatched assignment subject was treated as exact Work Item evidence: $(cat "$TMP/mismatched-subject.out")"
378
+ fi
379
+
380
+ COLLISION_ROOT="$TMP/collision/.kontourai/flow-agents"
381
+ if ! flow_agents_node "$WRITER" ensure-session \
382
+ --artifact-root "$COLLISION_ROOT" \
383
+ --work-item "owner/a.b#541" \
384
+ --actor builder-entry-collision \
385
+ --title "Collision seed" \
386
+ --summary "Bind the first exact Work Item." >/dev/null 2>&1; then
387
+ fail "collision fixture could not seed the first exact Work Item"
388
+ else
389
+ COLLISION_ASSIGNMENT="$COLLISION_ROOT/assignment/owner-a-b-541.json"
390
+ COLLISION_DIGEST="$(shasum -a 256 "$COLLISION_ASSIGNMENT")"
391
+ if flow_agents_node "$WRITER" ensure-session \
392
+ --artifact-root "$COLLISION_ROOT" \
393
+ --work-item "owner/a-b#541" \
394
+ --actor builder-entry-collision \
395
+ --title "Collision attempt" \
396
+ --summary "A colliding slug must not change the exact Work Item." \
397
+ --flow-id builder.build >"$TMP/collision.out" 2>&1; then
398
+ fail "colliding exact Work Item refs should be rejected"
399
+ elif grep -q 'already bound to Work Item "owner/a.b#541", not "owner/a-b#541"' "$TMP/collision.out" \
400
+ && [[ "$COLLISION_DIGEST" == "$(shasum -a 256 "$COLLISION_ASSIGNMENT")" ]] \
401
+ && [[ ! -e "$TMP/collision/.kontourai/flow/runs/owner-a-b-541" ]]; then
402
+ pass "colliding slugs cannot change an existing session's exact Work Item binding"
403
+ else
404
+ fail "slug collision mutated ownership or returned the wrong diagnostic: $(cat "$TMP/collision.out")"
405
+ fi
406
+ fi
407
+
408
+ ASSIGNMENT_ONLY_ROOT="$TMP/assignment-only-collision/.kontourai/flow-agents"
409
+ mkdir -p "$ASSIGNMENT_ONLY_ROOT/assignment"
410
+ cat >"$ASSIGNMENT_ONLY_ROOT/assignment/owner-a-b-542.json" <<'JSON'
411
+ {
412
+ "schema_version": "1.0",
413
+ "role": "AssignmentClaimRecord",
414
+ "subject_id": "owner-a-b-542",
415
+ "actor": { "runtime": "codex", "session_id": "interrupted", "host": "eval-host", "human": null },
416
+ "actor_key": "interrupted",
417
+ "work_item_ref": "owner/a.b#542",
418
+ "claimed_at": "2020-01-01T00:00:00Z",
419
+ "ttl_seconds": 1,
420
+ "branch": "agent/interrupted/owner-a-b-542",
421
+ "artifact_dir": "owner-a-b-542",
422
+ "status": "claimed",
423
+ "audit_trail": []
424
+ }
425
+ JSON
426
+ ASSIGNMENT_ONLY_FILE="$ASSIGNMENT_ONLY_ROOT/assignment/owner-a-b-542.json"
427
+ ASSIGNMENT_ONLY_DIGEST="$(shasum -a 256 "$ASSIGNMENT_ONLY_FILE")"
428
+ if flow_agents_node "$WRITER" ensure-session \
429
+ --artifact-root "$ASSIGNMENT_ONLY_ROOT" \
430
+ --work-item "owner/a-b#542" \
431
+ --actor builder-entry-assignment-only-collision \
432
+ --supersede-stale \
433
+ --now "2026-07-10T00:00:00Z" \
434
+ --title "Assignment-only collision" \
435
+ --summary "A crash before state creation must preserve exact assignment identity." \
436
+ --flow-id builder.build >"$TMP/assignment-only-collision.out" 2>&1; then
437
+ fail "assignment-only colliding Work Item should be rejected"
438
+ elif grep -q 'already bound to Work Item "owner/a.b#542", not "owner/a-b#542"' "$TMP/assignment-only-collision.out" \
439
+ && [[ "$ASSIGNMENT_ONLY_DIGEST" == "$(shasum -a 256 "$ASSIGNMENT_ONLY_FILE")" ]] \
440
+ && [[ ! -e "$ASSIGNMENT_ONLY_ROOT/owner-a-b-542" ]]; then
441
+ pass "assignment provenance blocks slug collisions before session state exists"
442
+ else
443
+ fail "assignment-only collision mutated provenance or returned the wrong diagnostic: $(cat "$TMP/assignment-only-collision.out")"
444
+ fi
445
+
446
+ PREEXISTING_ROOT="$TMP/preexisting/.kontourai/flow-agents"
447
+ if flow_agents_node "$WRITER" ensure-session \
448
+ --artifact-root "$PREEXISTING_ROOT" \
449
+ --task-slug preexisting-selection \
450
+ --actor builder-entry-preexisting \
451
+ --title "Preexisting selection" \
452
+ --summary "Seed an assignment before the canonical Builder run." >/dev/null 2>&1 \
453
+ && flow_agents_node "$WRITER" ensure-session \
454
+ --artifact-root "$PREEXISTING_ROOT" \
455
+ --task-slug preexisting-selection \
456
+ --actor builder-entry-preexisting \
457
+ --title "Preexisting selection" \
458
+ --summary "An older self-held assignment is not new selection evidence." \
459
+ --flow-id builder.build >"$TMP/preexisting.out" 2>&1 \
460
+ && node - "$TMP/preexisting" <<'NODE'
461
+ const fs = require('node:fs');
462
+ const path = require('node:path');
463
+ const project = process.argv[2];
464
+ const session = path.join(project, '.kontourai', 'flow-agents', 'preexisting-selection');
465
+ const flowState = JSON.parse(fs.readFileSync(path.join(project, '.kontourai', 'flow', 'runs', 'preexisting-selection', 'state.json'), 'utf8'));
466
+ if (flowState.current_step !== 'pull-work') process.exit(1);
467
+ if (fs.existsSync(path.join(session, 'trust.bundle'))) process.exit(1);
468
+ NODE
469
+ then
470
+ pass "preexisting self-held assignments do not retroactively satisfy pull-work"
255
471
  else
256
- fail "selected-work claim did not advance the canonical run: $(cat "$TMP/selected-work.out")"
472
+ fail "preexisting assignment was treated as current selection evidence: $(cat "$TMP/preexisting.out")"
257
473
  fi
258
474
 
259
475
  if FLOW_AGENTS_GOAL_FIT_MODE=block FLOW_AGENTS_GOAL_FIT_MAX_BLOCKS=100000 \
@@ -881,6 +881,49 @@ else
881
881
  _fail "Codex full install lost Builder specialist role routing"
882
882
  fi
883
883
 
884
+ echo ""
885
+ echo "--- Packed Package Builder Entry ---"
886
+ PACKAGE_CONSUMER="$TMPDIR_EVAL/package-consumer"
887
+ PACKAGE_PROJECT="$TMPDIR_EVAL/package-project"
888
+ PACKAGE_AMBIENT="$TMPDIR_EVAL/package-ambient"
889
+ mkdir -p "$PACKAGE_CONSUMER" "$PACKAGE_PROJECT/.kontourai/flow-agents" "$PACKAGE_AMBIENT/kits/builder/flows"
890
+ cat >"$PACKAGE_AMBIENT/kits/builder/flows/build.flow.json" <<'JSON'
891
+ {
892
+ "id": "builder.build",
893
+ "version": "poison-ambient-cwd",
894
+ "steps": [{ "id": "ambient-poison", "next": null }],
895
+ "gates": {}
896
+ }
897
+ JSON
898
+ PACKAGE_PACK_LOG="$TMPDIR_EVAL/package-pack.log"
899
+ if (cd "$ROOT_DIR" && npm pack --silent --pack-destination "$TMPDIR_EVAL" >"$PACKAGE_PACK_LOG") \
900
+ && PACKAGE_TARBALL="$(find "$TMPDIR_EVAL" -maxdepth 1 -type f -name 'kontourai-flow-agents-*.tgz' -print -quit)" \
901
+ && [[ -n "$PACKAGE_TARBALL" ]] \
902
+ && npm install --silent --no-audit --no-fund --ignore-scripts --prefix "$PACKAGE_CONSUMER" "$PACKAGE_TARBALL" \
903
+ && (cd "$PACKAGE_AMBIENT" && node "$PACKAGE_CONSUMER/node_modules/@kontourai/flow-agents/build/src/cli/workflow-sidecar.js" ensure-session \
904
+ --artifact-root "$PACKAGE_PROJECT/.kontourai/flow-agents" \
905
+ --task-slug packed-builder-entry \
906
+ --actor packed-package-consumer \
907
+ --title "Packed Builder entry" \
908
+ --summary "Installed package should project pickup-probe." \
909
+ --flow-id builder.build >/dev/null 2>&1) \
910
+ && node - "$PACKAGE_PROJECT" <<'NODE'
911
+ const fs = require('node:fs');
912
+ const path = require('node:path');
913
+ const project = process.argv[2];
914
+ const state = JSON.parse(fs.readFileSync(path.join(project, '.kontourai', 'flow-agents', 'packed-builder-entry', 'state.json'), 'utf8'));
915
+ const flow = JSON.parse(fs.readFileSync(path.join(project, '.kontourai', 'flow', 'runs', 'packed-builder-entry', 'state.json'), 'utf8'));
916
+ const bundle = JSON.parse(fs.readFileSync(path.join(project, '.kontourai', 'flow-agents', 'packed-builder-entry', 'trust.bundle'), 'utf8'));
917
+ if (flow.current_step !== 'design-probe' || state.flow_run?.current_step !== 'design-probe') process.exit(1);
918
+ if (JSON.stringify(state.next_action?.skills) !== JSON.stringify(['pickup-probe'])) process.exit(1);
919
+ if (!(bundle.claims || []).some((claim) => claim.claimType === 'builder.pull-work.selected' && claim.status === 'verified')) process.exit(1);
920
+ NODE
921
+ then
922
+ _pass "packed npm consumer ignores unrelated ambient Flow definitions and projects pickup-probe"
923
+ else
924
+ _fail "packed npm consumer did not execute canonical Builder entry"
925
+ fi
926
+
884
927
  if [[ -d "$CODEX_FULL_DEST/.codex/skills/plan-work" && -d "$CODEX_FULL_DEST/.codex/skills/deliver" && -d "$CODEX_FULL_DEST/.codex/skills/agentic-engineering" ]]; then
885
928
  _pass "Codex full install ships kit-skills and standalone skills together"
886
929
  else
@@ -377,6 +377,7 @@ flow_agents_node "workflow-sidecar" ensure-session \
377
377
  --task-slug gate-actor-a \
378
378
  --actor eval-actor-a-session \
379
379
  --flow-id builder.build \
380
+ --skip-ownership-guard \
380
381
  --source-request "Actor A is active on a FlowDefinition-driven session." \
381
382
  --summary "Actor A, builder.build, pull-work step." \
382
383
  --timestamp "2026-07-01T00:00:00Z" \
@@ -258,6 +258,7 @@ if flow_agents_node "$WRITER" ensure-session \
258
258
  --artifact-root "$SESSION_ROOT" \
259
259
  --task-slug dual-emit-test \
260
260
  --flow-id builder.build \
261
+ --skip-ownership-guard \
261
262
  --title "Declared-Only Test" \
262
263
  --summary "Test declared-only emit for ADR 0016 P-d." \
263
264
  --criterion "Tests pass" \
@@ -348,6 +349,7 @@ if flow_agents_node "$WRITER" ensure-session \
348
349
  --artifact-root "$SESSION_ROOT" \
349
350
  --task-slug dual-emit-policy \
350
351
  --flow-id builder.build \
352
+ --skip-ownership-guard \
351
353
  --title "Declared Policy Test" \
352
354
  --summary "Test declared-only policy emit for ADR 0016 P-d." \
353
355
  --criterion "Policy passes" \
@@ -51,6 +51,7 @@ mkdir -p "$MAIN_AROOT"
51
51
  if flow_agents_node "$WRITER" ensure-session \
52
52
  --artifact-root "$MAIN_AROOT" \
53
53
  --task-slug "$SLUG" \
54
+ --actor activation-test-actor \
54
55
  --title "Step 1 activation test" \
55
56
  --summary "Test that --flow-id builder.build activates the FlowDefinition-driven path." \
56
57
  --criterion "All gates produce declared claims" \
@@ -65,12 +66,14 @@ node -e "
65
66
  const fs = require('fs');
66
67
  const c = JSON.parse(fs.readFileSync('$MAIN_AROOT/current.json', 'utf8'));
67
68
  const flow = JSON.parse(fs.readFileSync('$TMP/main-project/.kontourai/flow/runs/$SLUG/state.json', 'utf8'));
69
+ const bundle = JSON.parse(fs.readFileSync('$SESSION_DIR/trust.bundle', 'utf8'));
68
70
  if (c.active_flow_id !== 'builder.build') throw new Error('expected active_flow_id=builder.build, got ' + c.active_flow_id);
69
- if (!c.active_step_id) throw new Error('expected active_step_id to be set (first step default), got ' + c.active_step_id);
70
- if (flow.status !== 'active' || flow.current_step !== 'pull-work') throw new Error('canonical Flow did not start at pull-work: ' + JSON.stringify(flow));
71
+ if (c.active_step_id !== 'design-probe') throw new Error('expected active_step_id=design-probe, got ' + c.active_step_id);
72
+ if (flow.status !== 'active' || flow.current_step !== 'design-probe') throw new Error('canonical Flow did not advance through trusted selection: ' + JSON.stringify(flow));
73
+ if (!(bundle.claims || []).some((claim) => claim.claimType === 'builder.pull-work.selected' && claim.status === 'verified')) throw new Error('missing verified selected-work claim');
71
74
  console.log('current.json: active_flow_id=' + c.active_flow_id + ' active_step_id=' + c.active_step_id);
72
75
  " 2>&1 \
73
- && _pass "ensure-session writes current projection and a canonical Flow run at pull-work" \
76
+ && _pass "ensure-session records trusted selection and projects the canonical Flow run at design-probe" \
74
77
  || _fail "ensure-session did not create and project the canonical Flow run"
75
78
 
76
79
  # ─── TEST 2: advance-state sets active_step_id via phase_map ─────────────────
@@ -15,10 +15,10 @@ _pass() { echo " ✓ $1"; }
15
15
  _fail() { echo " ✗ $1"; errors=$((errors + 1)); }
16
16
 
17
17
  REPO="$TMPDIR_EVAL/repo"
18
- mkdir -p "$REPO/.flow-agents/stuck"
18
+ mkdir -p "$REPO/.kontourai/flow-agents/stuck"
19
19
  printf '# Test Repo\n' > "$REPO/AGENTS.md"
20
20
  printf '# Stuck\n\nbranch: main\nstatus: executing\ntype: deliver\n\n## Plan\n\nTBD.\n' \
21
- > "$REPO/.flow-agents/stuck/stuck--deliver.md"
21
+ > "$REPO/.kontourai/flow-agents/stuck/stuck--deliver.md"
22
22
 
23
23
  PAYLOAD="{\"hook_event_name\":\"Stop\",\"cwd\":\"$REPO\"}"
24
24
 
@@ -54,7 +54,7 @@ c4=$(run_block "$TMPDIR_EVAL/b4.err")
54
54
  printf '%s' "$PAYLOAD" | FLOW_AGENTS_GOAL_FIT_MODE=block FLOW_AGENTS_GOAL_FIT_MAX_BLOCKS=3 node "$ROOT/scripts/hooks/stop-goal-fit.js" >/dev/null 2>/dev/null
55
55
  # mutate the artifact so the warning set differs
56
56
  printf '# Stuck\n\nbranch: main\nstatus: verifying\ntype: deliver\n\n## Plan\n\nDifferent.\n' \
57
- > "$REPO/.flow-agents/stuck/stuck--deliver.md"
57
+ > "$REPO/.kontourai/flow-agents/stuck/stuck--deliver.md"
58
58
  cd=$(run_block "$TMPDIR_EVAL/bd.err")
59
59
  [[ "$cd" -eq 2 ]] && rg -q 'Stop blocked .* \(block 1; after 3 identical blocks' "$TMPDIR_EVAL/bd.err" \
60
60
  && _pass "changed goal-fit gap resets the streak to 1/3" \
@@ -129,6 +129,7 @@ flow_agents_node "workflow-sidecar" ensure-session \
129
129
  --title "Ensure Default Step" \
130
130
  --summary "Test ensure-session default step." \
131
131
  --flow-id builder.build \
132
+ --skip-ownership-guard \
132
133
  --timestamp "2026-06-26T00:00:00Z" >/dev/null 2>&1
133
134
 
134
135
  node -e "
@@ -154,6 +155,7 @@ flow_agents_node "workflow-sidecar" ensure-session \
154
155
  --title "Gate Claim Test" \
155
156
  --summary "Test gate claim producer." \
156
157
  --flow-id builder.build \
158
+ --skip-ownership-guard \
157
159
  --timestamp "2026-06-26T00:00:00Z" >/dev/null 2>&1
158
160
 
159
161
  flow_agents_node "workflow-sidecar" init-plan "$CLAIM_ROOT/gate-claim/gate-claim--deliver.md" \
@@ -261,6 +263,7 @@ flow_agents_node "workflow-sidecar" ensure-session \
261
263
  --title "Tamper Test" \
262
264
  --summary "Testing tamper detection." \
263
265
  --flow-id builder.build \
266
+ --skip-ownership-guard \
264
267
  --timestamp "2026-06-26T00:00:00Z" >/dev/null 2>&1
265
268
 
266
269
  flow_agents_node "workflow-sidecar" init-plan "$T_DIR/.kontourai/flow-agents/tamper/tamper--deliver.md" \
@@ -364,6 +367,7 @@ flow_agents_node "workflow-sidecar" ensure-session \
364
367
  --title "Clean Test" \
365
368
  --summary "Testing clean gate claim." \
366
369
  --flow-id builder.build \
370
+ --skip-ownership-guard \
367
371
  --timestamp "2026-06-26T00:00:00Z" >/dev/null 2>&1
368
372
 
369
373
  flow_agents_node "workflow-sidecar" init-plan "$C_DIR/.kontourai/flow-agents/clean/clean--deliver.md" \
@@ -4228,6 +4228,7 @@ flow_agents_node "$WRITER" ensure-session \
4228
4228
  --task-slug "$UNRESOLVABLE_SLUG" \
4229
4229
  --actor unresolvable-actor \
4230
4230
  --flow-id builder.build \
4231
+ --skip-ownership-guard \
4231
4232
  --title "Unresolvable FlowDefinition regression" \
4232
4233
  --source-request "Regression: an unresolvable FlowDefinition must die with the dedicated cannot-be-loaded message." \
4233
4234
  --summary "Seed session with a REAL flow so a real stamped gate claim can be recorded." \
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/flow-agents",
3
- "version": "3.4.3",
3
+ "version": "3.5.0",
4
4
  "description": "Flow Agents — a Kontour product that applies Flow and Veritas discipline as a portable process layer inside the agent tools you already use: Claude Code, Codex, Kiro, opencode, pi, and GitHub Actions — with framework adapters (AWS Strands preview) on the same policy-engine contract.",
5
5
  "keywords": [
6
6
  "agents",
@@ -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,