@kontourai/flow-agents 3.1.0 → 3.3.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 (86) hide show
  1. package/.github/workflows/ci.yml +16 -0
  2. package/CHANGELOG.md +30 -0
  3. package/build/src/cli/assignment-provider.d.ts +45 -0
  4. package/build/src/cli/assignment-provider.js +107 -13
  5. package/build/src/cli/workflow-artifact-cleanup-audit.js +418 -11
  6. package/build/src/cli/workflow-sidecar.d.ts +238 -4
  7. package/build/src/cli/workflow-sidecar.js +875 -14
  8. package/build/src/tools/validate-source-tree.js +3 -2
  9. package/context/contracts/artifact-contract.md +16 -2
  10. package/context/contracts/assignment-provider-contract.md +1 -1
  11. package/context/contracts/execution-contract.md +78 -0
  12. package/context/scripts/hooks/config-protection.js +11 -4
  13. package/context/scripts/hooks/stop-goal-fit.js +259 -4
  14. package/context/scripts/hooks/workflow-steering.js +73 -1
  15. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +111 -0
  16. package/docs/coordination-guide.md +370 -0
  17. package/docs/decisions/agent-coordination.md +26 -9
  18. package/docs/decisions/index.md +2 -2
  19. package/docs/decisions/trust-reconcile.md +42 -9
  20. package/docs/fixture-ownership.md +3 -2
  21. package/docs/index.md +4 -0
  22. package/docs/integrations/flow-agents-console.md +108 -0
  23. package/docs/integrations/index.md +4 -0
  24. package/docs/workflow-artifact-lifecycle.md +38 -1
  25. package/evals/ci/antigaming-suite.sh +1 -0
  26. package/evals/ci/run-baseline.sh +8 -0
  27. package/evals/fixtures/reconcile-preflight/disputed-critique-unsuperseded.json +48 -0
  28. package/evals/fixtures/reconcile-preflight/standalone-disputed-session-local.json +59 -0
  29. package/evals/integration/test_checkpoint_signing.sh +14 -5
  30. package/evals/integration/test_ci_actor_identity.sh +221 -0
  31. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  32. package/evals/integration/test_gate_lockdown.sh +36 -0
  33. package/evals/integration/test_model_routing_escalation.sh +145 -0
  34. package/evals/integration/test_publish_delivery.sh +73 -8
  35. package/evals/integration/test_reconcile_preflight.sh +304 -0
  36. package/evals/integration/test_stop_hook_release.sh +552 -0
  37. package/evals/integration/test_takeover_protocol.sh +340 -0
  38. package/evals/integration/test_trust_reconcile_negatives.sh +261 -0
  39. package/evals/integration/test_verify_hold.sh +910 -0
  40. package/evals/integration/test_veritas_governance_kit.sh +257 -0
  41. package/evals/integration/test_workflow_artifact_cleanup_audit.sh +575 -3
  42. package/evals/run.sh +14 -0
  43. package/evals/static/test_model_routing_hints.sh +107 -0
  44. package/kits/builder/skills/builder-shape/SKILL.md +10 -0
  45. package/kits/builder/skills/continue-work/SKILL.md +2 -0
  46. package/kits/builder/skills/deliver/SKILL.md +125 -11
  47. package/kits/builder/skills/design-probe/SKILL.md +10 -0
  48. package/kits/builder/skills/execute-plan/SKILL.md +13 -0
  49. package/kits/builder/skills/fix-bug/SKILL.md +17 -0
  50. package/kits/builder/skills/idea-to-backlog/SKILL.md +10 -0
  51. package/kits/builder/skills/plan-work/SKILL.md +9 -0
  52. package/kits/builder/skills/pull-work/SKILL.md +22 -2
  53. package/kits/builder/skills/review-work/SKILL.md +11 -0
  54. package/kits/builder/skills/tdd-workflow/SKILL.md +17 -0
  55. package/kits/builder/skills/verify-work/SKILL.md +11 -0
  56. package/kits/knowledge/adapters/default-store/index.js +56 -15
  57. package/kits/knowledge/adapters/flow-runner/index.js +912 -16
  58. package/kits/knowledge/adapters/obsidian-store/index.js +29 -11
  59. package/kits/knowledge/adapters/shared/codec.js +124 -0
  60. package/kits/knowledge/docs/store-contract.md +405 -3
  61. package/kits/knowledge/evals/audit-freshness/suite.test.js +92 -1
  62. package/kits/knowledge/evals/consolidate-incremental/suite.test.js +494 -0
  63. package/kits/knowledge/evals/consolidation/suite.test.js +1 -1
  64. package/kits/knowledge/evals/contract-suite/suite.test.js +36 -0
  65. package/kits/knowledge/evals/freshness/suite.test.js +339 -0
  66. package/kits/knowledge/evals/inbound-references/suite.test.js +351 -0
  67. package/kits/knowledge/evals/retirement/suite.test.js +1 -1
  68. package/kits/knowledge/evals/supersede-propagation/suite.test.js +384 -0
  69. package/kits/veritas-governance/docs/README.md +81 -3
  70. package/kits/veritas-governance/fixtures/exemption/approved.trust-bundle.json +74 -0
  71. package/kits/veritas-governance/fixtures/exemption/not-approved.trust-bundle.json +74 -0
  72. package/kits/veritas-governance/flows/exemption-issuance.flow.json +35 -0
  73. package/kits/veritas-governance/kit.json +5 -0
  74. package/package.json +1 -1
  75. package/schemas/workflow-handoff.schema.json +6 -0
  76. package/scripts/ci/mint-attestation.js +33 -6
  77. package/scripts/ci/trust-reconcile.js +222 -279
  78. package/scripts/hooks/config-protection.js +11 -4
  79. package/scripts/hooks/lib/actor-identity.js +82 -0
  80. package/scripts/hooks/stop-goal-fit.js +259 -4
  81. package/scripts/hooks/workflow-steering.js +73 -1
  82. package/scripts/lib/reconcile-shape.js +381 -0
  83. package/src/cli/assignment-provider.ts +122 -13
  84. package/src/cli/workflow-artifact-cleanup-audit.ts +483 -10
  85. package/src/cli/workflow-sidecar.ts +965 -14
  86. package/src/tools/validate-source-tree.ts +3 -2
@@ -706,6 +706,42 @@ else
706
706
  _fail "AC1.26: redirect > delivery/trust.bundle NOT blocked (exit=$prot_exit)"
707
707
  fi
708
708
 
709
+ echo ""
710
+ echo "--- AC1.26b: #379 per-session Write to delivery/<slug>/trust.bundle BLOCKED ---"
711
+ set +e
712
+ prot_out=$(echo '{"tool_name":"Write","tool_input":{"path":"/repo/delivery/i379-delivery-paths/trust.bundle"}}' | node "$PROT" 2>&1)
713
+ prot_exit=$?
714
+ set -e
715
+ if [ "$prot_exit" -eq 2 ] && echo "$prot_out" | grep -q "BLOCKED"; then
716
+ _pass "AC1.26b: Write to delivery/<slug>/trust.bundle blocked (exit 2) — #379 forgery surface moved with the write path"
717
+ else
718
+ _fail "AC1.26b: Write to delivery/<slug>/trust.bundle NOT blocked (exit=$prot_exit, out=$prot_out)"
719
+ fi
720
+
721
+ echo ""
722
+ echo "--- AC1.26c: #379 per-session cp to delivery/<slug>/trust.checkpoint.json BLOCKED ---"
723
+ set +e
724
+ prot_out=$(echo '{"tool_name":"Bash","tool_input":{"command":"cp forged.json delivery/some-session/trust.checkpoint.json"}}' | node "$PROT" 2>&1)
725
+ prot_exit=$?
726
+ set -e
727
+ if [ "$prot_exit" -eq 2 ] && echo "$prot_out" | grep -q "BLOCKED"; then
728
+ _pass "AC1.26c: cp to delivery/<slug>/trust.checkpoint.json blocked (exit 2)"
729
+ else
730
+ _fail "AC1.26c: cp to delivery/<slug>/trust.checkpoint.json NOT blocked (exit=$prot_exit, out=$prot_out)"
731
+ fi
732
+
733
+ echo ""
734
+ echo "--- AC1.26d: #379 per-session redirect > delivery/<slug>/trust.bundle BLOCKED ---"
735
+ set +e
736
+ prot_out=$(echo '{"tool_name":"Bash","tool_input":{"command":"echo {} > delivery/some-session/trust.bundle"}}' | node "$PROT" 2>&1)
737
+ prot_exit=$?
738
+ set -e
739
+ if [ "$prot_exit" -eq 2 ] && echo "$prot_out" | grep -q "BLOCKED"; then
740
+ _pass "AC1.26d: redirect > delivery/<slug>/trust.bundle blocked (exit 2)"
741
+ else
742
+ _fail "AC1.26d: redirect > delivery/<slug>/trust.bundle NOT blocked (exit=$prot_exit)"
743
+ fi
744
+
709
745
  echo ""
710
746
  echo "--- AC1.27: cp x src/foo.ts ALLOWED (no over-block on normal copy) ---"
711
747
  set +e
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env bash
2
+ # test_model_routing_escalation.sh — #376
3
+ # Deliver-loop eval: a cheap-tier delegate's output FAILS a gate, the fix is
4
+ # re-dispatched one tier HIGHER (escalation), and the escalation + every
5
+ # per-delegation role/model decision are recorded on the session artifact in a
6
+ # shape a downstream economics record (#349) can consume.
7
+ #
8
+ # R2/AC2: fail -> escalate -> pass, escalation recorded with the tier it climbed FROM.
9
+ # R3/AC3: a verify/review delegation's role tier is >= the worker tier it checks.
10
+ # R4/AC4: per-delegation role/model land on agents/<id>/events.jsonl, additive.
11
+ set -uo pipefail
12
+
13
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
14
+ source "$ROOT/evals/lib/node.sh"
15
+
16
+ WRITER="workflow-sidecar"
17
+ VALIDATOR="validate-workflow-artifacts"
18
+ TMPDIR_EVAL="$(mktemp -d)"
19
+ errors=0
20
+ cleanup() { rm -rf "$TMPDIR_EVAL"; }
21
+ trap cleanup EXIT
22
+
23
+ _pass() { echo " ✓ $1"; }
24
+ _fail() { echo " ✗ $1"; errors=$((errors + 1)); }
25
+
26
+ AR="$TMPDIR_EVAL/repo/.kontourai/flow-agents"
27
+ SLUG="routing-escalation"
28
+ SDIR="$AR/$SLUG"
29
+ mkdir -p "$AR"
30
+
31
+ ev() { flow_agents_node "$WRITER" record-agent-event --artifact-root "$AR" "$@"; }
32
+
33
+ # ── session ───────────────────────────────────────────────────────────────────
34
+ if flow_agents_node "$WRITER" ensure-session \
35
+ --artifact-root "$AR" --task-slug "$SLUG" \
36
+ --title "Routing escalation" \
37
+ --summary "Cheap tier fails a gate; fix escalates one tier higher." \
38
+ --criterion "Escalation recorded" \
39
+ --timestamp "2026-07-04T00:00:00Z" >"$TMPDIR_EVAL/ensure.out" 2>&1; then
40
+ _pass "ensure-session created the deliver-loop session"
41
+ else
42
+ _fail "ensure-session failed: $(cat "$TMPDIR_EVAL/ensure.out")"
43
+ fi
44
+
45
+ # ── deliver loop: delegate cheap -> gate FAIL -> escalate one tier up -> PASS ──
46
+ # 1. Dispatch a fully-specified mechanical slice at the cheap tier (R4 record).
47
+ ev --agent-id tool-worker-1 --kind delegation --status active \
48
+ --role delegate-mechanical --model "claude-haiku-4-5@anthropic" \
49
+ --summary "mechanical slice: routine config edit" \
50
+ --timestamp "2026-07-04T00:00:01Z" >/dev/null 2>&1 \
51
+ && _pass "recorded mechanical delegation (delegate-mechanical)" \
52
+ || _fail "failed to record mechanical delegation"
53
+
54
+ # 2. Verify gate FAILS on that cheap delegate's output.
55
+ ev --agent-id tool-verifier --kind evidence --status fail \
56
+ --role delegate-implementation --model "claude-sonnet-5@anthropic" \
57
+ --summary "verify gate FAILED on tool-worker-1 output" \
58
+ --timestamp "2026-07-04T00:00:02Z" >/dev/null 2>&1 \
59
+ && _pass "recorded verify gate FAILURE on cheap-tier output" \
60
+ || _fail "failed to record gate failure"
61
+
62
+ # 3. Escalate: re-dispatch the FIX one tier higher, recording the tier climbed FROM.
63
+ ev --agent-id tool-worker-2 --kind escalation --status active \
64
+ --role delegate-implementation --model "claude-sonnet-5@anthropic" \
65
+ --escalated-from delegate-mechanical \
66
+ --summary "fix re-dispatched one tier higher after gate failure" \
67
+ --timestamp "2026-07-04T00:00:03Z" >/dev/null 2>&1 \
68
+ && _pass "recorded escalation (delegate-mechanical -> delegate-implementation)" \
69
+ || _fail "failed to record escalation"
70
+
71
+ # 4. Re-verify PASSES on the escalated fix. Goodhart guard: the verifier tier
72
+ # (delegate-implementation) is >= the worker tier it checks (delegate-implementation).
73
+ ev --agent-id tool-verifier --kind evidence --status pass \
74
+ --role delegate-implementation --model "claude-sonnet-5@anthropic" \
75
+ --summary "verify gate PASSED on escalated fix" \
76
+ --timestamp "2026-07-04T00:00:04Z" >/dev/null 2>&1 \
77
+ && _pass "recorded verify PASS after escalation" \
78
+ || _fail "failed to record verify pass"
79
+
80
+ # ── assertions over the recorded session artifact ─────────────────────────────
81
+ node - "$SDIR" <<'NODE'
82
+ const fs = require("fs");
83
+ const path = require("path");
84
+ const sdir = process.argv[2];
85
+ const TIER = { "delegate-mechanical": 1, "delegate-implementation": 2, "delegate-design": 3, "orchestrator": 4 };
86
+
87
+ function readEvents(agent) {
88
+ const f = path.join(sdir, "agents", agent, "events.jsonl");
89
+ if (!fs.existsSync(f)) return [];
90
+ return fs.readFileSync(f, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l));
91
+ }
92
+
93
+ let bad = 0;
94
+ const say = (ok, msg) => { console.log(` ${ok ? "✓" : "✗"} ${msg}`); if (!ok) bad++; };
95
+
96
+ // R4/AC4: every delegation/escalation event carries a consumable role + model.
97
+ const w1 = readEvents("tool-worker-1");
98
+ const del = w1.find((e) => e.kind === "delegation");
99
+ say(del && del.role === "delegate-mechanical" && del.model === "claude-haiku-4-5@anthropic",
100
+ "R4: mechanical delegation event carries role + model (economics-consumable)");
101
+
102
+ // R4 additive/back-compat: a plain event carries no routing keys.
103
+ const plainRoot = path.join(sdir, "state.json");
104
+ say(fs.existsSync(plainRoot), "session state.json exists (additive change did not break session shape)");
105
+
106
+ // R2/AC2: escalation event recorded, one tier higher, with the tier climbed FROM.
107
+ const esc = readEvents("tool-worker-2").find((e) => e.kind === "escalation");
108
+ say(!!esc, "R2: escalation event recorded");
109
+ say(esc && esc.escalated_from === "delegate-mechanical", "R2: escalation records escalated_from = delegate-mechanical");
110
+ say(esc && TIER[esc.role] === TIER[esc.escalated_from] + 1,
111
+ "R2: escalation climbed exactly one tier up the ladder (mechanical -> implementation)");
112
+
113
+ // R3/AC3: the verifier tier is >= the worker tier it checks (Goodhart guard).
114
+ const verifs = readEvents("tool-verifier").filter((e) => e.role);
115
+ const workerTier = TIER["delegate-implementation"]; // tier of the escalated fix under verification
116
+ const allGuarded = verifs.every((v) => TIER[v.role] >= workerTier);
117
+ say(verifs.length >= 1 && allGuarded, "R3: verify role tier >= worker tier (never a cheaper checker)");
118
+
119
+ // AC4: demonstrate the #349-consumable reduction — price per role across the run.
120
+ const allEvents = [];
121
+ for (const agent of fs.readdirSync(path.join(sdir, "agents"))) {
122
+ allEvents.push(...readEvents(agent));
123
+ }
124
+ const byRole = {};
125
+ for (const e of allEvents) if (e.role) byRole[e.role] = (byRole[e.role] || 0) + 1;
126
+ say(Object.keys(byRole).length >= 2 && byRole["delegate-mechanical"] >= 1 && byRole["delegate-implementation"] >= 1,
127
+ `AC4: routing records reduce to a per-role economics shape: ${JSON.stringify(byRole)}`);
128
+
129
+ process.exit(bad === 0 ? 0 : 1);
130
+ NODE
131
+ if [[ $? -eq 0 ]]; then _pass "session artifact routing/escalation/Goodhart assertions passed"; else _fail "session artifact assertions failed"; fi
132
+
133
+ # ── additive shape must still validate ────────────────────────────────────────
134
+ if flow_agents_node "$VALIDATOR" "$SDIR" >"$TMPDIR_EVAL/validate.out" 2>&1; then
135
+ _pass "validate-workflow-artifacts accepts the additive routing fields"
136
+ else
137
+ _fail "validate-workflow-artifacts rejected routing fields: $(cat "$TMPDIR_EVAL/validate.out")"
138
+ fi
139
+
140
+ if [[ "$errors" -eq 0 ]]; then
141
+ echo "Model routing escalation (deliver-loop) integration passed."
142
+ exit 0
143
+ fi
144
+ echo "Model routing escalation integration failed: $errors issue(s)."
145
+ exit 1
@@ -9,6 +9,9 @@
9
9
  # 4. RECONCILE-MATCHING: delivery trust.bundle (+ matching-sha checkpoint sibling) + CI
10
10
  # pass -> exit 0.
11
11
  # 5. FAIL-SOFT: no trust.bundle -> publishDelivery skips, record-release exits 0.
12
+ # 6. AC6 SHAPE-INVALID-REFUSED (#356): a shape-invalid trust.bundle (test_output claim
13
+ # naming a non-manifest command) is REFUSED by publishDelivery() -- non-zero exit, loud
14
+ # message, nothing copied to delivery/. Distinct from FAIL-SOFT (bundle absent).
12
15
  #
13
16
  # Deterministic, no model spend, self-cleaning.
14
17
  # Usage: bash evals/integration/test_publish_delivery.sh
@@ -118,7 +121,14 @@ FIXTURE1="$TMP/fixture1.json"
118
121
  write_bundle_to "$FIXTURE1" "node --version" "true"
119
122
  setup_session "$AROOT1" "$SLUG1" "$FIXTURE1"
120
123
 
121
- rr_out1=$(flow_agents_node "$WRITER" record-release "$SESSION_DIR1" \
124
+ # #356: the fixture bundle's evidence.execution.label is "node --version" (a real,
125
+ # deterministic command, not a manifest-registered one in this scratch repo) — the new
126
+ # reconcile-preflight gate inside publishDelivery() resolves the manifest the SAME way
127
+ # trust-reconcile.js itself does, whose legacy fallback tier folds TRUST_RECONCILE_COMMANDS
128
+ # into the manifest. Setting it here makes this fixture genuinely shape-valid (a
129
+ # manifest-matched command claim), matching what CI would actually accept for a repo whose
130
+ # canonical verify is "node --version" — not a preflight-specific carve-out.
131
+ rr_out1=$(TRUST_RECONCILE_COMMANDS="node --version" flow_agents_node "$WRITER" record-release "$SESSION_DIR1" \
122
132
  --decision merge \
123
133
  --gate-json '{"name":"merge","status":"pass","summary":"Ready."}' \
124
134
  --summary "Release." --repo-root "$REPO1" \
@@ -131,11 +141,19 @@ else
131
141
  _fail "END-TO-END-RECORD-RELEASE: record-release exited $rr_exit1 -- $rr_out1"
132
142
  fi
133
143
 
134
- DELIVERY_BUNDLE1="$REPO1/delivery/trust.bundle"
144
+ # #379: publishDelivery now writes to the PER-SESSION path delivery/<slug>/trust.bundle
145
+ # (slug = the session artifact dir basename) so concurrent deliveries never contend on one
146
+ # shared file. The flat path is no longer written.
147
+ DELIVERY_BUNDLE1="$REPO1/delivery/$SLUG1/trust.bundle"
135
148
  if [[ -f "$DELIVERY_BUNDLE1" ]]; then
136
- _pass "END-TO-END-RECORD-RELEASE: delivery/trust.bundle exists after record-release"
149
+ _pass "END-TO-END-RECORD-RELEASE: delivery/$SLUG1/trust.bundle exists after record-release (#379 per-session path)"
137
150
  else
138
- _fail "END-TO-END-RECORD-RELEASE: delivery/trust.bundle NOT found at $DELIVERY_BUNDLE1"
151
+ _fail "END-TO-END-RECORD-RELEASE: delivery/$SLUG1/trust.bundle NOT found at $DELIVERY_BUNDLE1"
152
+ fi
153
+ if [[ ! -f "$REPO1/delivery/trust.bundle" ]]; then
154
+ _pass "END-TO-END-RECORD-RELEASE: flat delivery/trust.bundle NOT written (#379 migrated off the shared path)"
155
+ else
156
+ _fail "END-TO-END-RECORD-RELEASE: flat delivery/trust.bundle was written — the shared-path contention #379 fixes is back"
139
157
  fi
140
158
 
141
159
  if [[ -f "$DELIVERY_BUNDLE1" && -f "$SESSION_DIR1/trust.bundle" ]]; then
@@ -160,7 +178,9 @@ FIXTURE2="$TMP/fixture2.json"
160
178
  write_bundle_to "$FIXTURE2" "node --version" "true"
161
179
  setup_session "$AROOT2" "$SLUG2" "$FIXTURE2"
162
180
 
163
- pd_out=$(flow_agents_node "$WRITER" publish-delivery "$SESSION_DIR2" \
181
+ # #356: same rationale as TEST 1 above — make the fixture genuinely shape-valid for the
182
+ # new reconcile-preflight gate inside publishDelivery().
183
+ pd_out=$(TRUST_RECONCILE_COMMANDS="node --version" flow_agents_node "$WRITER" publish-delivery "$SESSION_DIR2" \
164
184
  --repo-root "$REPO2" 2>&1)
165
185
  pd_exit=$?
166
186
 
@@ -170,11 +190,11 @@ else
170
190
  _fail "SUBCOMMAND: publish-delivery exited $pd_exit -- $pd_out"
171
191
  fi
172
192
 
173
- DELIVERY_BUNDLE2="$REPO2/delivery/trust.bundle"
193
+ DELIVERY_BUNDLE2="$REPO2/delivery/$SLUG2/trust.bundle"
174
194
  if [[ -f "$DELIVERY_BUNDLE2" ]]; then
175
- _pass "SUBCOMMAND: delivery/trust.bundle exists after publish-delivery"
195
+ _pass "SUBCOMMAND: delivery/$SLUG2/trust.bundle exists after publish-delivery (#379 per-session path)"
176
196
  else
177
- _fail "SUBCOMMAND: delivery/trust.bundle NOT found at $DELIVERY_BUNDLE2"
197
+ _fail "SUBCOMMAND: delivery/$SLUG2/trust.bundle NOT found at $DELIVERY_BUNDLE2"
178
198
  fi
179
199
 
180
200
  if [[ -f "$DELIVERY_BUNDLE2" && -f "$SESSION_DIR2/trust.bundle" ]]; then
@@ -274,6 +294,51 @@ else
274
294
  _fail "FAIL-SOFT: delivery/trust.bundle was created unexpectedly"
275
295
  fi
276
296
 
297
+ # ==== TEST 6: AC6 SHAPE-INVALID-REFUSED (#356) ======================
298
+ # publishDelivery() now runs the reconcile-preflight shape check BEFORE copying anything
299
+ # into delivery/ (#356 Wave 3, AC6). A bundle whose test_output-evidenced claim names a
300
+ # command that is not in the resolved manifest (no TRUST_RECONCILE_COMMANDS/run-baseline.sh
301
+ # entry naming it here) is shape-invalid and must be REFUSED: publish-delivery exits
302
+ # non-zero, loudly, and nothing is copied to delivery/. This must never be confused with the
303
+ # FAIL-SOFT (bundle-absent) case above, which stays a soft, exit-0 no-op.
304
+ echo ""
305
+ echo "=== TEST 6: AC6 SHAPE-INVALID-REFUSED ==="
306
+
307
+ REPO6="$TMP/repo6"
308
+ AROOT6="$REPO6/.flow-agents"
309
+ SLUG6="pd-shape-invalid-test"
310
+ SESSION_DIR6="$AROOT6/$SLUG6"
311
+ mkdir -p "$REPO6/kits"
312
+
313
+ FIXTURE6="$TMP/fixture6.json"
314
+ write_bundle_to "$FIXTURE6" "node --version" "true"
315
+ setup_session "$AROOT6" "$SLUG6" "$FIXTURE6"
316
+
317
+ # Deliberately NO TRUST_RECONCILE_COMMANDS env var here — "node --version" resolves to no
318
+ # manifest entry in this scratch repo (no run-baseline.sh/package.json manifest source
319
+ # either), so the bundle is shape-invalid (not-run: command not in the reconcile manifest).
320
+ shape_out=$(flow_agents_node "$WRITER" publish-delivery "$SESSION_DIR6" --repo-root "$REPO6" 2>&1)
321
+ shape_exit=$?
322
+
323
+ if [[ $shape_exit -ne 0 ]]; then
324
+ _pass "AC6 SHAPE-INVALID-REFUSED: publish-delivery exits non-zero for a shape-invalid bundle"
325
+ else
326
+ _fail "AC6 SHAPE-INVALID-REFUSED: expected non-zero exit, got 0 -- $shape_out"
327
+ fi
328
+
329
+ if echo "$shape_out" | grep -q "REFUSING to publish"; then
330
+ _pass "AC6 SHAPE-INVALID-REFUSED: output names the refusal loudly (REFUSING to publish)"
331
+ else
332
+ _fail "AC6 SHAPE-INVALID-REFUSED: expected a loud REFUSING to publish message, got: $shape_out"
333
+ fi
334
+
335
+ DELIVERY_BUNDLE6="$REPO6/delivery/$SLUG6/trust.bundle"
336
+ if [[ ! -f "$DELIVERY_BUNDLE6" ]]; then
337
+ _pass "AC6 SHAPE-INVALID-REFUSED: delivery/$SLUG6/trust.bundle NOT created for a shape-invalid bundle"
338
+ else
339
+ _fail "AC6 SHAPE-INVALID-REFUSED: delivery/$SLUG6/trust.bundle was created despite invalid shape"
340
+ fi
341
+
277
342
  # ---- Summary ----
278
343
  echo ""
279
344
  echo "----------------------------------------------"
@@ -0,0 +1,304 @@
1
+ #!/usr/bin/env bash
2
+ # test_reconcile_preflight.sh — #356 (Local Reconcile-Shape Preflight) proof.
3
+ #
4
+ # Proves `workflow-sidecar reconcile-preflight <artifact-dir>` catches every documented
5
+ # ADR-0020-invalid trust.bundle shape LOCALLY, before git push — reusing (never forking)
6
+ # scripts/lib/reconcile-shape.js's own classification (the same module
7
+ # scripts/ci/trust-reconcile.js requires), so the local check can never silently drift from
8
+ # what CI enforces. Mirrors evals/integration/test_trust_reconcile_negatives.sh's
9
+ # fixture-per-case + run_case() helper shape.
10
+ #
11
+ # 1. non-manifest command-backed claim (reuses trust-reconcile-exploits/no-label-bypass.json)
12
+ # → not-run divergence + fix hint to name the exact manifest command.
13
+ # 2. unwaived assumed claim (reuses trust-reconcile-exploits/skip-assumed-bypass.json)
14
+ # → unwaived-assumed divergence + fix hint (also carries Q2's root-cause hint for shape 5).
15
+ # 3. un-superseded disputed critique (new fixture: disputed-critique-unsuperseded.json)
16
+ # → session-local-failed divergence (a workflow.critique.review claim, no superseded_by).
17
+ # 4. standalone fail/disputed session-local claim, non-critique (#292/#384 variant; new
18
+ # fixture: standalone-disputed-session-local.json) → the SAME session-local-failed
19
+ # divergence type as case 3, proving a disjoint pre-existing failure can never be
20
+ # smuggled in as a standalone claim either way.
21
+ # 5. waiver-voided-by-mixed-call — reproduced via the REAL producer CLI (record-evidence),
22
+ # not a hand-built fixture. Confirms the actual current behavior: the command-backed-
23
+ # waiver guard at src/cli/workflow-sidecar.ts's recordEvidence() dies at RECORD time
24
+ # when --accepted-gap-reason/--waived-by is combined with ANY command-backed check in
25
+ # the same call (even alongside other, session-local checks in the same --check-json
26
+ # set) — so there is no "silently voided" bundle to reconcile in this repo's current
27
+ # producer; this is documented, correct, prevention-at-the-source, not a preflight gap.
28
+ # 6. dropped waiver metadata round-trip (AC2) — a regression PROOF, not a preflight
29
+ # detection: record-evidence (waiver on a session-local check) → record-critique
30
+ # (rebuilds via checksFromBundle) → the bundle's claim STILL has metadata.waiver.
31
+ #
32
+ # Plus: CLEAN-BUNDLE (AC4, reuses trust-reconcile-mixed-bundle/mixed-bundle.json) → exit 0,
33
+ # no issues. AC5 (local/fast, no CI spawn): a --manifest override naming a sentinel-writing
34
+ # command is never invoked by the preflight on the clean bundle.
35
+ #
36
+ # Deterministic, no model spend, self-cleaning.
37
+ # Usage: bash evals/integration/test_reconcile_preflight.sh
38
+
39
+ set -uo pipefail
40
+
41
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
42
+ source "$ROOT/evals/lib/node.sh"
43
+
44
+ WRITER="workflow-sidecar"
45
+ FX_EXPLOITS="$ROOT/evals/fixtures/trust-reconcile-exploits"
46
+ FX_PREFLIGHT="$ROOT/evals/fixtures/reconcile-preflight"
47
+ MIXED_BUNDLE="$ROOT/evals/fixtures/trust-reconcile-mixed-bundle/mixed-bundle.json"
48
+
49
+ TMP="$(mktemp -d)"
50
+ errors=0
51
+
52
+ _pass() { echo " PASS: $1"; }
53
+ _fail() { echo " FAIL: $1"; errors=$((errors + 1)); }
54
+
55
+ cleanup() { rm -rf "$TMP"; }
56
+ trap cleanup EXIT
57
+
58
+ # run_case <label> <bundle> <needle...>
59
+ # Copies <bundle> into a fresh session dir as trust.bundle, runs reconcile-preflight, and
60
+ # asserts: exit non-zero AND stdout/stderr contains EVERY needle passed after <bundle>.
61
+ run_case() {
62
+ local label="$1" bundle="$2"
63
+ shift 2
64
+ echo "=== $label ==="
65
+ if [[ ! -f "$bundle" ]]; then _fail "$label: fixture not found at $bundle"; return; fi
66
+ local session="$TMP/case-$RANDOM$RANDOM"
67
+ mkdir -p "$session"
68
+ cp "$bundle" "$session/trust.bundle"
69
+ local out code
70
+ out="$(flow_agents_node "$WRITER" reconcile-preflight "$session" --repo-root "$ROOT" 2>&1)"
71
+ code=$?
72
+ if [[ $code -ne 0 ]]; then
73
+ _pass "$label: reconcile-preflight exits non-zero ($code)"
74
+ else
75
+ _fail "$label: expected non-zero exit, got 0 — output: $out"
76
+ fi
77
+ local needle
78
+ for needle in "$@"; do
79
+ if echo "$out" | grep -qF "$needle"; then
80
+ _pass "$label: output contains \"$needle\""
81
+ else
82
+ _fail "$label: expected \"$needle\" in output — output: $out"
83
+ fi
84
+ done
85
+ }
86
+
87
+ # ==== Case 1: non-manifest command-backed claim (reused fixture) ====================
88
+ run_case "case 1: non-manifest command-backed claim" "$FX_EXPLOITS/no-label-bypass.json" \
89
+ "c-fabricated-test" \
90
+ "no manifest-matched execution.label" \
91
+ "FIX: fold this into a non-command summary"
92
+
93
+ # ==== Case 2: unwaived assumed claim (reused fixture) ================================
94
+ run_case "case 2: unwaived assumed claim" "$FX_EXPLOITS/skip-assumed-bypass.json" \
95
+ "c-skipped" \
96
+ "re-derived status 'assumed' but carries no waiver" \
97
+ "requires a documented waiver (--accepted-gap-reason/--waived-by)"
98
+
99
+ # ==== Case 3: un-superseded disputed critique (new fixture) ==========================
100
+ run_case "case 3: un-superseded disputed critique" "$FX_PREFLIGHT/disputed-critique-unsuperseded.json" \
101
+ "c-disputed-critique" \
102
+ "workflow.critique.review" \
103
+ "has re-derived status 'disputed'" \
104
+ "FIX: a disputed/failing claim always blocks reconcile. Document a disjoint pre-existing failure as prose in a WAIVED non-command summary, not as a standalone claim."
105
+
106
+ # ==== Case 4: standalone fail/disputed session-local claim, non-critique (#292/#384) =
107
+ run_case "case 4: standalone disputed session-local claim (non-critique)" "$FX_PREFLIGHT/standalone-disputed-session-local.json" \
108
+ "c-standalone-disputed" \
109
+ "has re-derived status 'disputed'" \
110
+ "FIX: a disputed/failing claim always blocks reconcile. Document a disjoint pre-existing failure as prose in a WAIVED non-command summary, not as a standalone claim."
111
+
112
+ # ==== Case 5: waiver-voided-by-mixed-call — REAL PRODUCER CLI reproduction ===========
113
+ # Q2 resolution: shape #5 is NOT a distinct predicate — per the plan, confirm the ACTUAL
114
+ # current behavior first. This repo's guard at record-evidence time (the command-backed-
115
+ # waiver check ahead of any bundle write) dies loudly BEFORE a bundle is ever written when
116
+ # --accepted-gap-reason/--waived-by is combined with a command-backed check in the same
117
+ # call — including when a session-local check is ALSO present in the same call. That is the
118
+ # correct prevention (fail at the source, not a silently-voided bundle downstream) — assert
119
+ # the die message is clear and that no trust.bundle is produced.
120
+ echo "=== case 5: waiver-voided-by-mixed-call (real producer CLI reproduction) ==="
121
+
122
+ AROOT5="$TMP/repo5/.flow-agents"
123
+ SLUG5="case5-mixed-call"
124
+ SESSION_DIR5="$AROOT5/$SLUG5"
125
+ mkdir -p "$TMP/repo5/kits" "$AROOT5"
126
+
127
+ flow_agents_node "$WRITER" ensure-session --artifact-root "$AROOT5" --task-slug "$SLUG5" \
128
+ --title "Case 5" --summary "waiver-voided-by-mixed-call repro" --criterion "x" \
129
+ --timestamp "2026-07-04T10:00:00Z" >/dev/null 2>&1
130
+ flow_agents_node "$WRITER" init-plan "$SESSION_DIR5/${SLUG5}--deliver.md" \
131
+ --source-request "t" --summary "t" --timestamp "2026-07-04T10:01:00Z" >/dev/null 2>&1
132
+
133
+ # 5a. Single command-backed check + waiver flags in the SAME call → dies at record time.
134
+ case5a_out="$(flow_agents_node "$WRITER" record-evidence "$SESSION_DIR5" --verdict pass \
135
+ --check-json '{"id":"cmdcheck","kind":"test","status":"pass","summary":"cmd ran","command":"npm run validate:source --"}' \
136
+ --accepted-gap-reason "load test env unavailable" --waived-by "brian" \
137
+ --timestamp "2026-07-04T10:02:00Z" 2>&1)"
138
+ case5a_code=$?
139
+ if [[ $case5a_code -ne 0 ]]; then
140
+ _pass "case 5a: record-evidence dies at RECORD time for a mixed waiver+command-backed call ($case5a_code)"
141
+ else
142
+ _fail "case 5a: expected record-evidence to die (non-zero exit), got 0 — output: $case5a_out"
143
+ fi
144
+ if echo "$case5a_out" | grep -qF "cannot be applied to a command-backed check"; then
145
+ _pass "case 5a: die message clearly names the command-backed-waiver rejection"
146
+ else
147
+ _fail "case 5a: expected the command-backed-waiver die message — output: $case5a_out"
148
+ fi
149
+ if [[ ! -f "$SESSION_DIR5/trust.bundle" ]]; then
150
+ _pass "case 5a: no trust.bundle written (dies BEFORE any bundle write — no silently-voided waiver bundle downstream)"
151
+ else
152
+ _fail "case 5a: a trust.bundle was written despite the mixed-call guard firing"
153
+ fi
154
+
155
+ # 5b. TWO checks in the SAME call (one command-backed, one session-local) + waiver flags →
156
+ # the guard scans ALL checks in the call, so this ALSO dies at record time (proves the
157
+ # guard is not scoped only to a single-check call — the exact "mixed call" shape).
158
+ case5b_out="$(flow_agents_node "$WRITER" record-evidence "$SESSION_DIR5" --verdict pass \
159
+ --check-json '{"id":"cmdcheck2","kind":"test","status":"pass","summary":"cmd ran","command":"npm run validate:source --"}' \
160
+ --check-json '{"id":"sessioncheck2","kind":"external","status":"skip","summary":"manual load test skip"}' \
161
+ --accepted-gap-reason "load test env unavailable" --waived-by "brian" \
162
+ --timestamp "2026-07-04T10:03:00Z" 2>&1)"
163
+ case5b_code=$?
164
+ if [[ $case5b_code -ne 0 ]]; then
165
+ _pass "case 5b: record-evidence ALSO dies for a two-check mixed call (command-backed + session-local, one waiver) ($case5b_code)"
166
+ else
167
+ _fail "case 5b: expected record-evidence to die (non-zero exit), got 0 — output: $case5b_out"
168
+ fi
169
+ if echo "$case5b_out" | grep -qF "cannot be applied to a command-backed check"; then
170
+ _pass "case 5b: die message clearly names the command-backed-waiver rejection"
171
+ else
172
+ _fail "case 5b: expected the command-backed-waiver die message — output: $case5b_out"
173
+ fi
174
+ if [[ ! -f "$SESSION_DIR5/trust.bundle" ]]; then
175
+ _pass "case 5b: no trust.bundle written"
176
+ else
177
+ _fail "case 5b: a trust.bundle was written despite the mixed-call guard firing"
178
+ fi
179
+
180
+ # 5c. Sanity: the Q2 root-cause hint IS reachable via reconcile-preflight's message for an
181
+ # honest (non-mixed-call) unwaived-assumed divergence — i.e. an agent who DOES end up
182
+ # with an unwaived-assumed claim (by whatever path) gets pointed at the mixed-call guard
183
+ # as a likely root cause. Reuses case 2's fixture; restated here for traceability to
184
+ # shape #5 per Q2's resolution (shape #5 is #2 enriched with a root-cause hint, not a
185
+ # distinct predicate).
186
+ Q2_SESSION="$TMP/q2-check"
187
+ mkdir -p "$Q2_SESSION"
188
+ cp "$FX_EXPLOITS/skip-assumed-bypass.json" "$Q2_SESSION/trust.bundle"
189
+ q2_out="$(flow_agents_node "$WRITER" reconcile-preflight "$Q2_SESSION" --repo-root "$ROOT" 2>&1)"
190
+ if echo "$q2_out" | grep -qF "voids/rejects the waiver"; then
191
+ _pass "case 5c (Q2 traceability): unwaived-assumed's preflight message carries the mixed-call root-cause hint"
192
+ else
193
+ _fail "case 5c (Q2 traceability): expected the mixed-call root-cause hint on an unwaived-assumed divergence — output: $q2_out"
194
+ fi
195
+
196
+ # ==== Case 6 (AC2): dropped waiver metadata round-trip — regression PROOF, not detection ==
197
+ echo "=== case 6 (AC2): waiver metadata round-trip survives record-critique rebuild ==="
198
+
199
+ AROOT6="$TMP/repo6/.flow-agents"
200
+ SLUG6="case6-waiver-roundtrip"
201
+ SESSION_DIR6="$AROOT6/$SLUG6"
202
+ mkdir -p "$TMP/repo6/kits" "$AROOT6"
203
+
204
+ flow_agents_node "$WRITER" ensure-session --artifact-root "$AROOT6" --task-slug "$SLUG6" \
205
+ --title "Case 6" --summary "waiver round-trip repro" --criterion "x" \
206
+ --timestamp "2026-07-04T10:00:00Z" >/dev/null 2>&1
207
+ flow_agents_node "$WRITER" init-plan "$SESSION_DIR6/${SLUG6}--deliver.md" \
208
+ --source-request "t" --summary "t" --timestamp "2026-07-04T10:01:00Z" >/dev/null 2>&1
209
+
210
+ flow_agents_node "$WRITER" record-evidence "$SESSION_DIR6" --verdict pass \
211
+ --check-json '{"id":"loadtest","kind":"external","status":"skip","summary":"load test skip"}' \
212
+ --accepted-gap-reason "load test env unavailable" --waived-by "brian" \
213
+ --timestamp "2026-07-04T10:02:00Z" >/dev/null 2>&1
214
+
215
+ flow_agents_node "$WRITER" record-critique "$SESSION_DIR6" --verdict pass --summary "ok." \
216
+ --timestamp "2026-07-04T10:03:00Z" >/dev/null 2>&1
217
+
218
+ if [[ -f "$SESSION_DIR6/trust.bundle" ]]; then
219
+ _pass "case 6: trust.bundle exists after record-evidence -> record-critique rebuild"
220
+ bundle_json="$(cat "$SESSION_DIR6/trust.bundle")"
221
+ if echo "$bundle_json" | grep -qF '"waiver"'; then
222
+ _pass "case 6: rebuilt trust.bundle's claim STILL carries metadata.waiver (AC2 fix holds — pre-fix this silently dropped)"
223
+ else
224
+ _fail "case 6: rebuilt trust.bundle's claim LOST metadata.waiver — AC2 round-trip regressed. bundle: $bundle_json"
225
+ fi
226
+ if echo "$bundle_json" | grep -qF '"approved_by": "brian"'; then
227
+ _pass "case 6: waiver's approved_by ('brian') survives the rebuild"
228
+ else
229
+ _fail "case 6: expected waiver.approved_by 'brian' to survive the rebuild — bundle: $bundle_json"
230
+ fi
231
+ else
232
+ _fail "case 6: trust.bundle was not found at $SESSION_DIR6/trust.bundle after the round-trip sequence"
233
+ fi
234
+
235
+ # Reconcile-preflight must ALSO pass cleanly on the rebuilt (waiver-intact) bundle —
236
+ # an accepted-gap waiver on a session-local check is a shape-clean, WAIVED divergence-free
237
+ # bundle by ADR 0020's own rules.
238
+ preflight6_out="$(flow_agents_node "$WRITER" reconcile-preflight "$SESSION_DIR6" --repo-root "$ROOT" 2>&1)"
239
+ preflight6_code=$?
240
+ if [[ $preflight6_code -eq 0 ]]; then
241
+ _pass "case 6: reconcile-preflight exits 0 on the round-tripped, waiver-intact bundle"
242
+ else
243
+ _fail "case 6: expected reconcile-preflight exit 0 on the round-tripped bundle, got $preflight6_code — output: $preflight6_out"
244
+ fi
245
+
246
+ # ==== CLEAN-BUNDLE (AC4) ==============================================================
247
+ echo "=== CLEAN-BUNDLE (AC4): a valid ADR-0020-conformant bundle passes ==="
248
+
249
+ CLEAN_SESSION="$TMP/clean-bundle"
250
+ mkdir -p "$CLEAN_SESSION"
251
+ cp "$MIXED_BUNDLE" "$CLEAN_SESSION/trust.bundle"
252
+
253
+ clean_out="$(flow_agents_node "$WRITER" reconcile-preflight "$CLEAN_SESSION" --repo-root "$ROOT" 2>&1)"
254
+ clean_code=$?
255
+ if [[ $clean_code -eq 0 ]]; then
256
+ _pass "CLEAN-BUNDLE: reconcile-preflight exits 0 for a valid bundle"
257
+ else
258
+ _fail "CLEAN-BUNDLE: expected exit 0, got $clean_code — output: $clean_out"
259
+ fi
260
+ if echo "$clean_out" | grep -qF "OK — no shape issues found"; then
261
+ _pass "CLEAN-BUNDLE: output reports no issues"
262
+ else
263
+ _fail "CLEAN-BUNDLE: expected the no-issues OK line — output: $clean_out"
264
+ fi
265
+
266
+ # ==== AC5: preflight never spawns a manifest command =================================
267
+ echo "=== AC5: reconcile-preflight never spawns a fresh manifest command ==="
268
+
269
+ AC5_SESSION="$TMP/ac5-bundle"
270
+ mkdir -p "$AC5_SESSION"
271
+ cp "$MIXED_BUNDLE" "$AC5_SESSION/trust.bundle"
272
+ SENTINEL="$TMP/ac5-sentinel-should-not-exist"
273
+ rm -f "$SENTINEL"
274
+
275
+ # --manifest override: the mixed bundle's real command ("npm run check:content-boundary --")
276
+ # PLUS a second entry whose command WRITES a sentinel file if actually executed. Since
277
+ # reconcile-preflight resolves the manifest (a pure, local, no-command-execution lookup) but
278
+ # never calls runCommand/spawns a manifest entry's command to prove reconciliation, the
279
+ # sentinel must never appear.
280
+ AC5_MANIFEST='[{"id":"content-boundary","command":"npm run check:content-boundary --"},{"id":"sentinel","command":"touch '"$SENTINEL"'"}]'
281
+
282
+ ac5_out="$(flow_agents_node "$WRITER" reconcile-preflight "$AC5_SESSION" --repo-root "$ROOT" --manifest "$AC5_MANIFEST" 2>&1)"
283
+ ac5_code=$?
284
+ if [[ $ac5_code -eq 0 ]]; then
285
+ _pass "AC5: reconcile-preflight exits 0 on the clean bundle with the sentinel manifest override"
286
+ else
287
+ _fail "AC5: expected exit 0, got $ac5_code — output: $ac5_out"
288
+ fi
289
+ if [[ ! -f "$SENTINEL" ]]; then
290
+ _pass "AC5: sentinel file absent after the preflight run — no manifest command was spawned"
291
+ else
292
+ _fail "AC5: sentinel file EXISTS — reconcile-preflight spawned a manifest command (AC5 violated)"
293
+ fi
294
+
295
+ # ---- Summary ----
296
+ echo ""
297
+ echo "----------------------------------------------"
298
+ if [[ $errors -eq 0 ]]; then
299
+ echo "test_reconcile_preflight: all checks passed."
300
+ exit 0
301
+ else
302
+ echo "test_reconcile_preflight: $errors check(s) failed."
303
+ exit 1
304
+ fi