@kontourai/flow-agents 3.4.2 → 3.4.3

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 (29) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/agents/dev.json +0 -5
  3. package/build/src/builder-flow-runtime.js +18 -12
  4. package/build/src/cli/workflow-sidecar.js +77 -5
  5. package/build/src/tools/build-universal-bundles.js +0 -2
  6. package/context/scripts/hooks/workflow-steering.js +2 -40
  7. package/docs/spec/builder-flow-runtime.md +12 -7
  8. package/docs/spec/runtime-hook-surface.md +4 -4
  9. package/evals/integration/test_builder_entry_enforcement.sh +138 -7
  10. package/evals/integration/test_builder_step_producers.sh +1 -1
  11. package/evals/integration/test_bundle_install.sh +3 -3
  12. package/evals/integration/test_current_json_per_actor.sh +2 -2
  13. package/evals/integration/test_dual_emit_flow_step.sh +41 -9
  14. package/evals/integration/test_flowdef_session_activation.sh +11 -5
  15. package/evals/integration/test_install_merge.sh +24 -0
  16. package/evals/integration/test_phase_map_and_gate_claim.sh +10 -10
  17. package/evals/integration/test_resolvefirststep_security.sh +1 -1
  18. package/evals/integration/test_sidecar_field_preservation.sh +4 -2
  19. package/evals/integration/test_takeover_protocol.sh +1 -1
  20. package/evals/integration/test_workflow_sidecar_writer.sh +17 -6
  21. package/evals/integration/test_workflow_steering_hook.sh +0 -72
  22. package/package.json +1 -1
  23. package/schemas/workflow-state.schema.json +1 -0
  24. package/scripts/hooks/workflow-steering.js +2 -40
  25. package/scripts/install-merge.js +2 -0
  26. package/src/builder-flow-runtime.ts +23 -12
  27. package/src/cli/builder-flow-runtime.test.mjs +32 -0
  28. package/src/cli/workflow-sidecar.ts +76 -6
  29. package/src/tools/build-universal-bundles.ts +0 -2
@@ -34,7 +34,7 @@ cleanup() { rm -rf "$TMP"; }
34
34
  trap cleanup EXIT
35
35
 
36
36
  WRITER="workflow-sidecar"
37
- SESSION_ROOT="$TMP/.flow-agents"
37
+ SESSION_ROOT="$TMP/.kontourai/flow-agents"
38
38
 
39
39
  echo "── P-a resolver unit checks ──"
40
40
 
@@ -269,11 +269,25 @@ fi
269
269
 
270
270
  DUAL_DIR="$SESSION_ROOT/dual-emit-test"
271
271
 
272
- flow_agents_node "$WRITER" advance-state "$DUAL_DIR" \
273
- --status in_progress --phase verification \
274
- --summary "Testing declared-only verify claims." --next-action "Record evidence." \
275
- --flow-definition builder.build \
276
- --timestamp "2026-06-26T00:00:30Z" >/dev/null 2>&1
272
+ advance_builder_to_verify() {
273
+ local session_dir="$1"
274
+ flow_agents_node "$WRITER" record-gate-claim "$session_dir" --status pass --expectation selected-work \
275
+ --summary "Selected the fixture Work Item." --timestamp "2026-06-26T00:00:10Z" >/dev/null 2>&1 \
276
+ && flow_agents_node "$WRITER" record-gate-claim "$session_dir" --status pass --expectation pickup-probe-readiness \
277
+ --summary "Pickup readiness is established." --timestamp "2026-06-26T00:00:20Z" >/dev/null 2>&1 \
278
+ && flow_agents_node "$WRITER" record-gate-claim "$session_dir" --status pass --expectation probe-decisions-or-accepted-gaps \
279
+ --summary "Probe decisions are recorded." --timestamp "2026-06-26T00:00:30Z" >/dev/null 2>&1 \
280
+ && flow_agents_node "$WRITER" record-gate-claim "$session_dir" --status pass --expectation implementation-plan \
281
+ --summary "Implementation plan is recorded." --timestamp "2026-06-26T00:00:40Z" >/dev/null 2>&1 \
282
+ && flow_agents_node "$WRITER" record-gate-claim "$session_dir" --status pass --expectation implementation-scope \
283
+ --summary "Implementation scope is recorded." --timestamp "2026-06-26T00:00:50Z" >/dev/null 2>&1
284
+ }
285
+
286
+ if advance_builder_to_verify "$DUAL_DIR"; then
287
+ _pass "canonical Builder Flow advances through required gates to verify"
288
+ else
289
+ _fail "canonical Builder Flow did not reach verify"
290
+ fi
277
291
 
278
292
  # Verify current.json carries the flow keys
279
293
  if node -e "
@@ -328,8 +342,26 @@ fi
328
342
  echo ""
329
343
  echo "── P-d declared-only: policy-kind check maps to builder.verify.policy-compliance ──"
330
344
 
331
- # Record a policy check with the same flow context
332
- if flow_agents_node "$WRITER" record-evidence "$DUAL_DIR" \
345
+ # Use a separate canonical run because the failing tests evidence above legitimately
346
+ # routes its run back to execute before this independent producer assertion.
347
+ if flow_agents_node "$WRITER" ensure-session \
348
+ --artifact-root "$SESSION_ROOT" \
349
+ --task-slug dual-emit-policy \
350
+ --flow-id builder.build \
351
+ --title "Declared Policy Test" \
352
+ --summary "Test declared-only policy emit for ADR 0016 P-d." \
353
+ --criterion "Policy passes" \
354
+ --timestamp "2026-06-26T00:01:30Z" >/dev/null 2>&1 \
355
+ && advance_builder_to_verify "$SESSION_ROOT/dual-emit-policy"; then
356
+ _pass "policy fixture canonical Builder Flow reaches verify"
357
+ else
358
+ _fail "policy fixture canonical Builder Flow did not reach verify"
359
+ fi
360
+
361
+ POLICY_DIR="$SESSION_ROOT/dual-emit-policy"
362
+ POLICY_BUNDLE="$POLICY_DIR/trust.bundle"
363
+
364
+ if flow_agents_node "$WRITER" record-evidence "$POLICY_DIR" \
333
365
  --verdict pass \
334
366
  --check-json '{"id":"policy-check","kind":"policy","status":"pass","summary":"Policy compliance passed"}' \
335
367
  --timestamp "2026-06-26T00:02:00Z" >"$TMP/policy-evidence.out" 2>"$TMP/policy-evidence.err"; then
@@ -340,7 +372,7 @@ fi
340
372
 
341
373
  if node -e "
342
374
  const fs = require('fs');
343
- const bundle = JSON.parse(fs.readFileSync('${BUNDLE}', 'utf8'));
375
+ const bundle = JSON.parse(fs.readFileSync('${POLICY_BUNDLE}', 'utf8'));
344
376
  const claims = bundle.claims;
345
377
  // Declared claim for policy kind should be builder.verify.policy-compliance
346
378
  const policyDeclared = claims.find(c => c.claimType === 'builder.verify.policy-compliance');
@@ -43,29 +43,35 @@ WRITER="workflow-sidecar"
43
43
  echo ""
44
44
  echo "=== 1. ensure-session --flow-id builder.build activates FlowDefinition-driven path ==="
45
45
 
46
- MAIN_AROOT="$TMP/main-aroot"
46
+ MAIN_AROOT="$TMP/main-project/.kontourai/flow-agents"
47
47
  SLUG="activation-test"
48
48
  SESSION_DIR="$MAIN_AROOT/$SLUG"
49
49
  mkdir -p "$MAIN_AROOT"
50
50
 
51
- flow_agents_node "$WRITER" ensure-session \
51
+ if flow_agents_node "$WRITER" ensure-session \
52
52
  --artifact-root "$MAIN_AROOT" \
53
53
  --task-slug "$SLUG" \
54
54
  --title "Step 1 activation test" \
55
55
  --summary "Test that --flow-id builder.build activates the FlowDefinition-driven path." \
56
56
  --criterion "All gates produce declared claims" \
57
57
  --flow-id builder.build \
58
- --timestamp "2026-06-01T00:00:00Z" >/dev/null 2>&1
58
+ --timestamp "2026-06-01T00:00:00Z" >/dev/null 2>&1; then
59
+ _pass "ensure-session starts the canonical Builder Flow from a canonical artifact root"
60
+ else
61
+ _fail "ensure-session failed to start the canonical Builder Flow"
62
+ fi
59
63
 
60
64
  node -e "
61
65
  const fs = require('fs');
62
66
  const c = JSON.parse(fs.readFileSync('$MAIN_AROOT/current.json', 'utf8'));
67
+ const flow = JSON.parse(fs.readFileSync('$TMP/main-project/.kontourai/flow/runs/$SLUG/state.json', 'utf8'));
63
68
  if (c.active_flow_id !== 'builder.build') throw new Error('expected active_flow_id=builder.build, got ' + c.active_flow_id);
64
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));
65
71
  console.log('current.json: active_flow_id=' + c.active_flow_id + ' active_step_id=' + c.active_step_id);
66
72
  " 2>&1 \
67
- && _pass "ensure-session --flow-id builder.build writes active_flow_id + default active_step_id to current.json" \
68
- || _fail "ensure-session --flow-id builder.build did NOT write active_flow_id to current.json"
73
+ && _pass "ensure-session writes current projection and a canonical Flow run at pull-work" \
74
+ || _fail "ensure-session did not create and project the canonical Flow run"
69
75
 
70
76
  # ─── TEST 2: advance-state sets active_step_id via phase_map ─────────────────
71
77
  echo ""
@@ -47,6 +47,30 @@ else
47
47
  fi
48
48
  echo ""
49
49
 
50
+ # The 3.4.2 bootstrap hook is intentionally absent from new bundles, but its
51
+ # marker remains an ownership tombstone so upgrades remove the retired denial.
52
+ if node - "$ROOT_DIR/scripts/install-merge.js" <<'NODE'
53
+ const { mergeSettings, isManagedHookGroup } = require(process.argv[2]);
54
+ const retired = { hooks: [{ type: "command", command: "old-flow-agents-entry", statusMessage: "Enforcing Flow Agents projected action" }] };
55
+ const user = { hooks: [{ type: "command", command: "user-pretool" }] };
56
+ const current = { hooks: [{ type: "command", command: "new-flow-agents", statusMessage: "Running Flow Agents hook policy" }] };
57
+ if (!isManagedHookGroup(retired)) throw new Error("retired hook marker is no longer recognized as managed");
58
+ const merged = mergeSettings(
59
+ { hooks: { PreToolUse: [retired, user] } },
60
+ { hooks: { UserPromptSubmit: [current] } },
61
+ );
62
+ const text = JSON.stringify(merged);
63
+ if (text.includes("old-flow-agents-entry")) throw new Error("retired bootstrap hook survived upgrade");
64
+ if (!text.includes("user-pretool")) throw new Error("user-owned PreToolUse hook was removed");
65
+ if (!text.includes("new-flow-agents")) throw new Error("current managed hook was not installed");
66
+ NODE
67
+ then
68
+ _pass "upgrade removes the retired 3.4.2 bootstrap hook while preserving user hooks"
69
+ else
70
+ _fail "upgrade did not cleanly replace the retired 3.4.2 bootstrap hook"
71
+ fi
72
+ echo ""
73
+
50
74
  # ─── Scenario 1: Seeded user config ──────────────────────────────────────────
51
75
  echo "--- Scenario 1: Seeded user config (user keys + non-FA hook survive) ---"
52
76
 
@@ -8,7 +8,8 @@
8
8
  # 4. record-gate-claim at pull-work step produces builder.pull-work.selected claim (status=verified).
9
9
  # 5. A TAMPERED bundle (stored verified, evidence fail) at pull-work step BLOCKS (exit 2)
10
10
  # with the tamper warning naming the declared claimType.
11
- # 6. A CLEAN record-gate-claim bundle (passing evidence verified) is NOT blocked.
11
+ # 6. A CLEAN record-gate-claim bundle is not reported as false completion;
12
+ # its still-active canonical Flow run remains blocked from stopping.
12
13
  #
13
14
  # Deterministic, no model spend, self-cleaning.
14
15
  # Usage: bash evals/integration/test_phase_map_and_gate_claim.sh
@@ -119,7 +120,7 @@ test_advance_state "learning" "learn"
119
120
  echo ""
120
121
  echo "=== 3. ensure-session --flow-id builder.build defaults to pull-work ==="
121
122
 
122
- ENSURE_ROOT="$TMP/ensure-test"
123
+ ENSURE_ROOT="$TMP/ensure-project/.kontourai/flow-agents"
123
124
  mkdir -p "$ENSURE_ROOT"
124
125
 
125
126
  flow_agents_node "workflow-sidecar" ensure-session \
@@ -144,7 +145,7 @@ node -e "
144
145
  echo ""
145
146
  echo "=== 4. record-gate-claim produces builder.pull-work.selected claim ==="
146
147
 
147
- CLAIM_ROOT="$TMP/gate-claim-test"
148
+ CLAIM_ROOT="$TMP/gate-claim-project/.kontourai/flow-agents"
148
149
  mkdir -p "$CLAIM_ROOT"
149
150
 
150
151
  flow_agents_node "workflow-sidecar" ensure-session \
@@ -192,7 +193,7 @@ node -e "
192
193
  echo ""
193
194
  echo "=== 4b. composed publish-learn gate claim emits builder.pr-open.pull-request ==="
194
195
 
195
- COMPOSED_ROOT="$TMP/composed-gate-claim-test"
196
+ COMPOSED_ROOT="$TMP/composed-gate-claim-project/.kontourai/flow-agents"
196
197
  mkdir -p "$COMPOSED_ROOT"
197
198
 
198
199
  flow_agents_node "workflow-sidecar" ensure-session \
@@ -200,7 +201,6 @@ flow_agents_node "workflow-sidecar" ensure-session \
200
201
  --task-slug composed-gate-claim \
201
202
  --title "Composed Gate Claim Test" \
202
203
  --summary "Test composed Builder publish/learn flow producer." \
203
- --flow-id builder.build \
204
204
  --timestamp "2026-06-26T00:00:00Z" >/dev/null 2>&1
205
205
 
206
206
  flow_agents_node "workflow-sidecar" init-plan "$COMPOSED_ROOT/composed-gate-claim/composed-gate-claim--deliver.md" \
@@ -350,9 +350,9 @@ else
350
350
  _fail "tamper warning does not name claimType: $tamper_out"
351
351
  fi
352
352
 
353
- # ─── Clean gate-claim: passing evidence NOT blocked ────────────────────────
353
+ # ─── Clean gate-claim: no false completion, active Flow still blocks ─────────
354
354
  echo ""
355
- echo "=== 6. CLEAN record-gate-claim (passing evidence verified) NOT BLOCKED ==="
355
+ echo "=== 6. CLEAN record-gate-claim remains governed by active canonical Flow ==="
356
356
 
357
357
  C_DIR="$TMP/clean-test"
358
358
  mkdir -p "$C_DIR"
@@ -400,10 +400,10 @@ clean_out="$(FLOW_AGENTS_GOAL_FIT_MODE=block FLOW_AGENTS_GOAL_FIT_BACKSTOP=skip
400
400
  clean_exit="$?"
401
401
  set -e
402
402
 
403
- if [ "$clean_exit" -ne 2 ]; then
404
- _pass "clean builder.pull-work.selected bundle not blocked (exit $clean_exit)"
403
+ if [ "$clean_exit" -eq 2 ] && echo "$clean_out" | grep -q 'release skipped for active Flow run "clean"'; then
404
+ _pass "clean claim advances to the next Flow step and active run blocks premature Stop"
405
405
  else
406
- _fail "clean builder.pull-work.selected bundle false-blocked (exit 2): $clean_out"
406
+ _fail "clean active Flow run returned the wrong Stop decision (exit $clean_exit): $clean_out"
407
407
  fi
408
408
 
409
409
  if echo "$clean_out" | grep -q "caught false-completion"; then
@@ -185,7 +185,7 @@ fi
185
185
  echo ""
186
186
  echo "=== 4. Legit --flow-id builder.build → active_step_id set (first step resolved) ==="
187
187
 
188
- LEGIT_AROOT="$TMP/legit-aroot"
188
+ LEGIT_AROOT="$TMP/legit-project/.kontourai/flow-agents"
189
189
  mkdir -p "$LEGIT_AROOT"
190
190
 
191
191
  set +e
@@ -85,13 +85,15 @@ _assert_preserved() {
85
85
  [[ "$ok" -eq 1 ]] && _pass "persistent identity fields (${FIELDS[*]}) survive $label"
86
86
  }
87
87
 
88
- # ─── Seed: ensure-session with an injected actor (derived branch) and a KNOWN timestamp ──────
88
+ # ─── Seed: standalone sidecar session with an injected actor and a KNOWN timestamp ──────────
89
+ # This sweep tests sidecar writer preservation. Canonical Flow startup and transitions are
90
+ # covered separately; advance-state below activates Builder FlowDefinition claim typing without
91
+ # creating a run that would legitimately project over this fixture's synthetic phase sequence.
89
92
  SEED_TS="2026-06-20T08:00:00Z"
90
93
  if ! flow_agents_node "$WRITER" ensure-session \
91
94
  --artifact-root "$SESSION_ROOT" \
92
95
  --task-slug "$SLUG" \
93
96
  --actor sweep-actor \
94
- --flow-id builder.build \
95
97
  --title "Sidecar Field Preservation Sweep" \
96
98
  --source-request "Seed a fully-populated session for the field-preservation invariant sweep." \
97
99
  --summary "Seed session for the class-level field-preservation sweep." \
@@ -40,7 +40,7 @@ WRITER="workflow-sidecar"
40
40
 
41
41
  TMPDIR_EVAL="$(mktemp -d)"
42
42
  trap 'rm -rf "$TMPDIR_EVAL"' EXIT
43
- ARTIFACT_ROOT="$TMPDIR_EVAL/artifact-root"
43
+ ARTIFACT_ROOT="$TMPDIR_EVAL/project/.kontourai/flow-agents"
44
44
 
45
45
  errors=0
46
46
  pass() { echo " [PASS] $1"; }
@@ -3437,7 +3437,7 @@ fi
3437
3437
  # ─── #270/#298 compose layer: gate-claim accumulation, gate-claim typing survives rebuild, ──
3438
3438
  # compose-two/three/four-writer round-trip, waiver + artifact_refs/standard_refs round-trip,
3439
3439
  # runnability rejection at record time (AC1-AC6, AC8, AC10) ──────────────────────────────────
3440
- COMPOSE_ROOT="$TMPDIR_EVAL/compose-root"
3440
+ COMPOSE_ROOT="$TMPDIR_EVAL/compose-project/.kontourai/flow-agents"
3441
3441
  COMPOSE_SLUG="compose-270"
3442
3442
  COMPOSE_DIR="$COMPOSE_ROOT/$COMPOSE_SLUG"
3443
3443
  mkdir -p "$COMPOSE_ROOT"
@@ -3446,13 +3446,24 @@ flow_agents_node "$WRITER" ensure-session \
3446
3446
  --artifact-root "$COMPOSE_ROOT" \
3447
3447
  --task-slug "$COMPOSE_SLUG" \
3448
3448
  --actor compose-actor \
3449
- --flow-id builder.build \
3450
3449
  --title "Compose layer session" \
3451
3450
  --source-request "Compose-safe writer layer smoke session." \
3452
3451
  --summary "Seed session for compose-layer round-trip assertions." \
3453
3452
  --criterion "Compose layer round-trips losslessly" \
3454
3453
  --timestamp "2026-07-05T09:00:00Z" >"$TMPDIR_EVAL/compose-seed.out" 2>"$TMPDIR_EVAL/compose-seed.err"
3455
3454
 
3455
+ # This fixture exercises sidecar writer composition, not canonical run
3456
+ # transitions. Activate FlowDefinition claim typing through the standalone
3457
+ # transition primitive so preliminary generic evidence cannot advance Flow.
3458
+ flow_agents_node "$WRITER" advance-state "$COMPOSE_DIR" \
3459
+ --actor compose-actor \
3460
+ --status in_progress \
3461
+ --phase pickup \
3462
+ --summary "Compose writer fixture at pull-work." \
3463
+ --next-action "Record composed claims." \
3464
+ --flow-definition builder.build \
3465
+ --timestamp "2026-07-05T09:00:30Z" >"$TMPDIR_EVAL/compose-activate.out" 2>"$TMPDIR_EVAL/compose-activate.err"
3466
+
3456
3467
  _compose_claims_json() {
3457
3468
  cat "$COMPOSE_DIR/trust.bundle"
3458
3469
  }
@@ -4157,7 +4168,7 @@ fi
4157
4168
  # misclassified as an unstamped pre-cluster-270 gate claim on the NEXT rebuild — is now rejected
4158
4169
  # AT RECORD TIME instead, in a FRESH session (isolated from COMPOSE_DIR) so no rebuild landmine
4159
4170
  # is ever created.
4160
- COLLISION_ROOT="$TMPDIR_EVAL/collision-root"
4171
+ COLLISION_ROOT="$TMPDIR_EVAL/collision-project/.kontourai/flow-agents"
4161
4172
  COLLISION_SLUG="collision-270"
4162
4173
  COLLISION_DIR="$COLLISION_ROOT/$COLLISION_SLUG"
4163
4174
  mkdir -p "$COLLISION_ROOT"
@@ -4207,7 +4218,7 @@ fi
4207
4218
  # (real flow, real record-gate-claim), then break resolution by pointing active_flow_id at a
4208
4219
  # bogus flow id that cannot resolve under kits/ (mirrors the re-reviewer's approach of pointing
4209
4220
  # at an unresolvable kits/ path), in an ISOLATED fixture copied from a fresh session.
4210
- UNRESOLVABLE_ROOT="$TMPDIR_EVAL/unresolvable-root"
4221
+ UNRESOLVABLE_ROOT="$TMPDIR_EVAL/unresolvable-project/.kontourai/flow-agents"
4211
4222
  UNRESOLVABLE_SLUG="unresolvable-270"
4212
4223
  UNRESOLVABLE_DIR="$UNRESOLVABLE_ROOT/$UNRESOLVABLE_SLUG"
4213
4224
  mkdir -p "$UNRESOLVABLE_ROOT"
@@ -4327,7 +4338,7 @@ fi
4327
4338
  # label, exactly the real kontourai-flow-agents-270 wedge this closes) can NEVER be corrected:
4328
4339
  # every attempt to re-record that exact id — the only way to supersede/fix it — is itself
4329
4340
  # rejected by the guard, permanently wedging that id and blocking publish-preflight forever.
4330
- CORRECTION_ROOT="$TMPDIR_EVAL/correction-root"
4341
+ CORRECTION_ROOT="$TMPDIR_EVAL/correction-project/.kontourai/flow-agents"
4331
4342
  CORRECTION_SLUG="correction-270"
4332
4343
  CORRECTION_DIR="$CORRECTION_ROOT/$CORRECTION_SLUG"
4333
4344
  mkdir -p "$CORRECTION_ROOT"
@@ -4479,7 +4490,7 @@ fi
4479
4490
  # check_kind so the replacement claim can be shaped to evade gateClaimShapeUnstampedId's detector
4480
4491
  # (which requires check_kind==="external"). This eval reproduces that exact attack end-to-end
4481
4492
  # against the CURRENT (narrowed) binary and asserts it now dies, with the stamp intact afterward.
4482
- STAMPED_ROOT="$TMPDIR_EVAL/stamped-claim-root"
4493
+ STAMPED_ROOT="$TMPDIR_EVAL/stamped-claim-project/.kontourai/flow-agents"
4483
4494
  STAMPED_SLUG="stamped-claim-270"
4484
4495
  STAMPED_DIR="$STAMPED_ROOT/$STAMPED_SLUG"
4485
4496
  mkdir -p "$STAMPED_ROOT"
@@ -21,78 +21,6 @@ mkdir -p "$REPO/docs"
21
21
  printf '# Test Repo\n' > "$REPO/AGENTS.md"
22
22
  printf '# Context Map\n' > "$REPO/docs/context-map.md"
23
23
 
24
- ENTRY_REPO="$TMPDIR_EVAL/entry-repo"
25
- ENTRY_COMMAND='flow-agents builder-run start --session-dir .kontourai/flow-agents/entry-demo'
26
- mkdir -p "$ENTRY_REPO/.kontourai/flow-agents/entry-demo"
27
- printf '# Entry Repo\n' > "$ENTRY_REPO/AGENTS.md"
28
- cat > "$ENTRY_REPO/.kontourai/flow-agents/entry-demo/state.json" <<JSON
29
- {
30
- "schema_version": "1.0",
31
- "task_slug": "entry-demo",
32
- "status": "new",
33
- "phase": "pickup",
34
- "updated_at": "2026-07-10T00:00:00Z",
35
- "next_action": {
36
- "status": "continue",
37
- "summary": "Start the canonical Flow run.",
38
- "skills": ["pull-work"],
39
- "command": "$ENTRY_COMMAND",
40
- "enforcement": "before_tool_use"
41
- }
42
- }
43
- JSON
44
-
45
- if node "$ROOT/scripts/hooks/workflow-steering.js" >"$TMPDIR_EVAL/entry-direct.out" 2>"$TMPDIR_EVAL/entry-direct.err" <<JSON
46
- {"hook_event_name":"PreToolUse","cwd":"$ENTRY_REPO","tool_name":"Bash","tool_input":{"command":"git status --short"}}
47
- JSON
48
- then
49
- _fail "workflow entry enforcement should block unrelated tools"
50
- else
51
- status=$?
52
- if [[ "$status" -eq 2 ]] && rg -F -q "Run exactly: $ENTRY_COMMAND" "$TMPDIR_EVAL/entry-direct.err"; then
53
- _pass "workflow entry enforcement blocks unrelated tools with the projected action"
54
- else
55
- _fail "workflow entry enforcement returned the wrong direct-hook decision: rc=$status $(cat "$TMPDIR_EVAL/entry-direct.err")"
56
- fi
57
- fi
58
-
59
- if node "$ROOT/scripts/hooks/workflow-steering.js" >"$TMPDIR_EVAL/entry-allow.out" 2>"$TMPDIR_EVAL/entry-allow.err" <<JSON
60
- {"hook_event_name":"PreToolUse","cwd":"$ENTRY_REPO","tool_name":"Bash","tool_input":{"command":"$ENTRY_COMMAND"}}
61
- JSON
62
- then
63
- _pass "workflow entry enforcement allows the exact projected action"
64
- else
65
- _fail "workflow entry enforcement blocked the projected action: $(cat "$TMPDIR_EVAL/entry-allow.err")"
66
- fi
67
-
68
- for adapter in codex claude; do
69
- if [[ "$adapter" == "codex" ]]; then
70
- adapter_command=(node "$ROOT/scripts/hooks/codex-hook-adapter.js" pre:workflow-entry workflow-steering.js standard,strict)
71
- else
72
- adapter_command=(node "$ROOT/scripts/hooks/claude-hook-adapter.js" PreToolUse pre:workflow-entry workflow-steering.js standard,strict)
73
- fi
74
- if "${adapter_command[@]}" >"$TMPDIR_EVAL/entry-$adapter.out" 2>"$TMPDIR_EVAL/entry-$adapter.err" <<JSON
75
- {"hook_event_name":"PreToolUse","cwd":"$ENTRY_REPO","tool_name":"Bash","tool_input":{"command":"git status --short"}}
76
- JSON
77
- then
78
- if node - "$TMPDIR_EVAL/entry-$adapter.out" "$ENTRY_COMMAND" <<'NODE'
79
- const fs = require('node:fs');
80
- const payload = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
81
- const expected = process.argv[3];
82
- if (payload.hookSpecificOutput?.hookEventName !== 'PreToolUse') process.exit(1);
83
- if (payload.hookSpecificOutput?.permissionDecision !== 'deny') process.exit(1);
84
- if (!String(payload.hookSpecificOutput?.permissionDecisionReason || '').includes(expected)) process.exit(1);
85
- NODE
86
- then
87
- _pass "$adapter adapter translates projected-action enforcement into a PreToolUse denial"
88
- else
89
- _fail "$adapter adapter returned the wrong projected-action denial: $(cat "$TMPDIR_EVAL/entry-$adapter.out")"
90
- fi
91
- else
92
- _fail "$adapter adapter should translate a policy block without failing: $(cat "$TMPDIR_EVAL/entry-$adapter.err")"
93
- fi
94
- done
95
-
96
24
  cat > "$REPO/.kontourai/flow-agents/steering-demo/state.json" <<'JSON'
97
25
  {
98
26
  "schema_version": "1.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/flow-agents",
3
- "version": "3.4.2",
3
+ "version": "3.4.3",
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",
@@ -124,6 +124,7 @@
124
124
  "minLength": 1
125
125
  },
126
126
  "enforcement": {
127
+ "description": "Deprecated compatibility field. Runtime steering ignores this value; session orchestration owns Builder Flow startup.",
127
128
  "type": "string",
128
129
  "enum": [
129
130
  "advisory",
@@ -9,8 +9,7 @@
9
9
  * survive context loss instead of relying on the model voluntarily re-reading
10
10
  * the sidecar.
11
11
  *
12
- * Advisory by default. A structured next action may explicitly require its
13
- * projected command before unrelated tool use; only that PreToolUse case blocks.
12
+ * Non-blocking always exits 0.
14
13
  */
15
14
 
16
15
  'use strict';
@@ -283,32 +282,6 @@ function stateSteering(root) {
283
282
  return parts.join(' ');
284
283
  }
285
284
 
286
- function normalizedCommand(value) {
287
- return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
288
- }
289
-
290
- function toolCommands(input) {
291
- const toolInput = input && input.tool_input && typeof input.tool_input === 'object' ? input.tool_input : {};
292
- return [
293
- toolInput.command,
294
- toolInput.content && toolInput.content.command,
295
- toolInput.args && toolInput.args.command,
296
- ].map(normalizedCommand).filter(Boolean);
297
- }
298
-
299
- function beforeToolUseEnforcement(input, current) {
300
- if (!input || input.hook_event_name !== 'PreToolUse' || !current) return null;
301
- const next = current.payload && current.payload.next_action;
302
- if (!next || next.status !== 'continue' || next.enforcement !== 'before_tool_use') return null;
303
- const expected = normalizedCommand(next.command);
304
- if (!expected) return null;
305
- if (toolCommands(input).some(command => command === expected)) return null;
306
- return {
307
- exitCode: 2,
308
- stderr: `[workflow-entry] Run the required projected action before using other tools. Run exactly: ${safeStateText(expected, 500)}`,
309
- };
310
- }
311
-
312
285
  function contextMapSteering(root) {
313
286
  const mapPath = path.join(root, 'docs', 'context-map.md');
314
287
  if (!fs.existsSync(mapPath)) return '';
@@ -599,8 +572,6 @@ function run(rawInput) {
599
572
  const toolInput = input.tool_input || {};
600
573
  const root = findRepoRoot(input.cwd || process.cwd());
601
574
  const current = latestWorkflowState(root);
602
- const enforcement = beforeToolUseEnforcement(input, current);
603
- if (enforcement) return enforcement;
604
575
  const hints = [];
605
576
  let shouldAppendWorkflowContext = false;
606
577
 
@@ -677,14 +648,7 @@ if (require.main === module) {
677
648
  data += chunk;
678
649
  });
679
650
  process.stdin.on('end', () => {
680
- const result = run(data);
681
- if (result && typeof result === 'object') {
682
- if (result.stderr) process.stderr.write(String(result.stderr) + (String(result.stderr).endsWith('\n') ? '' : '\n'));
683
- if (Object.prototype.hasOwnProperty.call(result, 'stdout')) process.stdout.write(String(result.stdout || ''));
684
- process.exitCode = Number.isInteger(result.exitCode) ? result.exitCode : 0;
685
- } else {
686
- process.stdout.write(String(result));
687
- }
651
+ process.stdout.write(String(run(data)));
688
652
  });
689
653
  }
690
654
 
@@ -703,6 +667,4 @@ module.exports = {
703
667
  promptText,
704
668
  looksLikeImplementationWork,
705
669
  kitWorkflowSteering,
706
- beforeToolUseEnforcement,
707
- toolCommands,
708
670
  };
@@ -48,6 +48,8 @@ const FA_MARKERS = [
48
48
  "Recording Flow Agents telemetry",
49
49
  "Running Flow Agents hook policy",
50
50
  "Capturing Flow Agents command evidence",
51
+ // Cleanup tombstone: 3.4.2 installed this retired PreToolUse hook. Keep the
52
+ // marker recognizable so upgrades remove it even though new bundles do not.
51
53
  "Enforcing Flow Agents projected action",
52
54
  ];
53
55
 
@@ -83,16 +83,20 @@ export async function startBuilderFlowSession(input: BuilderFlowSessionInput): P
83
83
  },
84
84
  });
85
85
  }
86
- return syncAndProject(context, run);
86
+ assertRunSubjectBinding(run, subject);
87
+ return syncAndProject(context, run, sidecarSnapshot);
87
88
  }
88
89
 
89
90
  export async function syncBuilderFlowSession(input: BuilderFlowSessionInput): Promise<BuilderFlowSessionResult> {
90
91
  const context = resolveSessionContext(input.sessionDir);
92
+ const sidecarSnapshot = readSidecarSnapshot(context);
93
+ const subject = workflowSubject(sidecarSnapshot.state);
91
94
  const run = await loadBuilderBuildRun({
92
95
  cwd: context.projectRoot,
93
96
  runId: context.slug,
94
97
  });
95
- return syncAndProject(context, run);
98
+ assertRunSubjectBinding(run, subject);
99
+ return syncAndProject(context, run, sidecarSnapshot);
96
100
  }
97
101
 
98
102
  export async function recoverBuilderFlowSession(input: BuilderFlowSessionInput): Promise<BuilderFlowSessionResult> {
@@ -103,14 +107,7 @@ export async function recoverBuilderFlowSession(input: BuilderFlowSessionInput):
103
107
  cwd: context.projectRoot,
104
108
  runId: context.slug,
105
109
  });
106
- if (run.state.subject !== subject) {
107
- throw new BuilderBuildRunInputError("flow_run.state.subject", "must match the selected Work Item");
108
- }
109
- if (isRecord(run.state.params)
110
- && Object.prototype.hasOwnProperty.call(run.state.params, "subject")
111
- && run.state.params.subject !== subject) {
112
- throw new BuilderBuildRunInputError("flow_run.state.params.subject", "must match the selected Work Item");
113
- }
110
+ assertRunSubjectBinding(run, subject);
114
111
  const projection = projectFlowRun(context, run, sidecarSnapshot.state);
115
112
  writeProjection(context, projection, sidecarSnapshot.raw, "recovery");
116
113
  return {
@@ -134,7 +131,11 @@ export async function syncBuilderFlowSessionIfPresent(sessionDir: string): Promi
134
131
  return syncBuilderFlowSession({ sessionDir });
135
132
  }
136
133
 
137
- async function syncAndProject(context: SessionContext, initial: BuilderBuildRunResult): Promise<BuilderFlowSessionResult> {
134
+ async function syncAndProject(
135
+ context: SessionContext,
136
+ initial: BuilderBuildRunResult,
137
+ sidecarSnapshot: SidecarSnapshot,
138
+ ): Promise<BuilderFlowSessionResult> {
138
139
  let run = initial;
139
140
  let attached = false;
140
141
  const gates = openGatesForResult(run);
@@ -164,7 +165,6 @@ async function syncAndProject(context: SessionContext, initial: BuilderBuildRunR
164
165
  }
165
166
  }
166
167
  }
167
- const sidecarSnapshot = readSidecarSnapshot(context);
168
168
  const projection = projectFlowRun(context, run, sidecarSnapshot.state);
169
169
  writeProjection(context, projection, sidecarSnapshot.raw, "projection");
170
170
  return {
@@ -176,6 +176,17 @@ async function syncAndProject(context: SessionContext, initial: BuilderBuildRunR
176
176
  };
177
177
  }
178
178
 
179
+ function assertRunSubjectBinding(run: BuilderBuildRunResult, subject: string): void {
180
+ if (run.state.subject !== subject) {
181
+ throw new BuilderBuildRunInputError("flow_run.state.subject", "must match the selected Work Item");
182
+ }
183
+ if (isRecord(run.state.params)
184
+ && Object.prototype.hasOwnProperty.call(run.state.params, "subject")
185
+ && run.state.params.subject !== subject) {
186
+ throw new BuilderBuildRunInputError("flow_run.state.params.subject", "must match the selected Work Item");
187
+ }
188
+ }
189
+
179
190
  function resolveSessionContext(sessionDirInput: string): SessionContext {
180
191
  const sessionDir = path.resolve(sessionDirInput);
181
192
  const artifactRoot = path.dirname(sessionDir);
@@ -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 });