@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
@@ -293,16 +293,588 @@ grep -Eq "ENOENT|no such file|cannot find" "$TMPDIR_EVAL/missing-root.err" && pa
293
293
  grep -q "Error:" "$TMPDIR_EVAL/missing-root.err" && fail "missing artifact root does not print Node stack header" || pass "missing artifact root does not print Node stack header"
294
294
 
295
295
  flow_agents_node "$SCRIPT" --help > "$TMPDIR_EVAL/help.txt"
296
- if grep -Eq -- "--(apply|delete|archive)" "$TMPDIR_EVAL/help.txt"; then
297
- fail "help does not advertise apply/delete/archive flags"
296
+ if grep -Eq -- "--apply\b" "$TMPDIR_EVAL/help.txt" && grep -qi "never delete" "$TMPDIR_EVAL/help.txt"; then
297
+ pass "AC13: help advertises --apply with never-delete language"
298
298
  else
299
- pass "help does not advertise apply/delete/archive flags"
299
+ fail "AC13: help advertises --apply with never-delete language"
300
300
  fi
301
+ grep -Eq -- "--apply-ambiguous\b" "$TMPDIR_EVAL/help.txt" && pass "AC13: help advertises --apply-ambiguous" || fail "AC13: help advertises --apply-ambiguous"
302
+ grep -Eq -- "--freshness-window-hours\b" "$TMPDIR_EVAL/help.txt" && pass "AC13: help advertises --freshness-window-hours" || fail "AC13: help advertises --freshness-window-hours"
303
+ grep -Eq -- "--archive-root\b" "$TMPDIR_EVAL/help.txt" && pass "AC13: help advertises --archive-root" || fail "AC13: help advertises --archive-root"
304
+ grep -Eq -- "--confirm\b" "$TMPDIR_EVAL/help.txt" && pass "AC13: help advertises --confirm" || fail "AC13: help advertises --confirm"
301
305
 
302
306
  find "$ARTIFACT_ROOT" -mindepth 1 -maxdepth 1 -type d | sort > "$TMPDIR_EVAL/after.txt"
303
307
  cmp -s "$TMPDIR_EVAL/before.txt" "$TMPDIR_EVAL/after.txt" && pass "audit leaves fixture directories in place" || fail "audit leaves fixture directories in place"
304
308
  [[ -f "$ARTIFACT_ROOT/stale-verified/state.json" ]] && pass "audit leaves fixture files in place" || fail "audit leaves fixture files in place"
305
309
 
310
+ # ─── Apply mode coverage (kontourai-flow-agents-283, Wave 2 / Task 2) ──────────────────
311
+ # Separate artifact root + separate tmpdir from the dry-run classifier fixtures above so
312
+ # --apply mutations here can never interact with (or be mistaken for interference with)
313
+ # the read-only assertions already made against $ARTIFACT_ROOT.
314
+
315
+ echo ""
316
+ echo "=== Workflow Artifact Cleanup Audit — Apply Mode ==="
317
+
318
+ apply_json_query() {
319
+ node -e 'const fs=require("fs"); let cur=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); for (const part of process.argv[2].split(".")) cur=Array.isArray(cur) ? cur[Number(part)] : cur[part]; console.log(cur === undefined ? "" : cur);' "$1" "$2"
320
+ }
321
+ apply_json_length() {
322
+ node -e 'const fs=require("fs"); let cur=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); for (const part of process.argv[2].split(".")) { if (!part) continue; cur=Array.isArray(cur) ? cur[Number(part)] : cur[part]; } console.log(Array.isArray(cur) ? cur.length : "not-an-array");' "$1" "$2"
323
+ }
324
+ apply_json_has_slug_in_moves() {
325
+ # $1 = json report path, $2 = slug to search for in .moves[].slug
326
+ node -e 'const fs=require("fs"); const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); const found=(data.moves||[]).some((m)=>m.slug===process.argv[2]); process.exit(found?0:1);' "$1" "$2"
327
+ }
328
+ apply_json_has_slug_anywhere() {
329
+ # $1 = json report path, $2 = slug to search for in ANY bucket, moves[], or ambiguous[]
330
+ node -e '
331
+ const fs = require("fs");
332
+ const data = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
333
+ const slug = process.argv[2];
334
+ let found = false;
335
+ for (const items of Object.values(data.buckets || {})) {
336
+ if (!Array.isArray(items)) continue;
337
+ for (const item of items) if (item.slug === slug) found = true;
338
+ }
339
+ for (const m of data.moves || []) if (m.slug === slug) found = true;
340
+ for (const a of data.ambiguous || []) if (a.slug === slug) found = true;
341
+ process.exit(found ? 0 : 1);
342
+ ' "$1" "$2"
343
+ }
344
+ apply_json_move_to_path() {
345
+ # $1 = json report path, $2 = slug; prints the .moves[] entry's "to" path (empty if absent)
346
+ node -e 'const fs=require("fs"); const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); const m=data.moves.find((x)=>x.slug===process.argv[2]); console.log(m?m.to:"");' "$1" "$2"
347
+ }
348
+ apply_json_move_index() {
349
+ # $1 = json report path, $2 = slug; prints the index of that slug in .moves[] (-1 if absent)
350
+ node -e 'const fs=require("fs"); const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); console.log(data.moves.findIndex((x)=>x.slug===process.argv[2]));' "$1" "$2"
351
+ }
352
+
353
+ APPLY_TMPDIR="$(mktemp -d)"
354
+ APPLY_ROOT="$APPLY_TMPDIR/flow-agents"
355
+ APPLY_ARCHIVE_ROOT="$APPLY_TMPDIR/flow-agents-archive"
356
+ trap 'rm -rf "$TMPDIR_EVAL" "$APPLY_TMPDIR"' EXIT
357
+
358
+ mkdir -p "$APPLY_ROOT"
359
+
360
+ # AC2/AC3/AC4: a plain cleanup_candidate fixture (verified/next=done), old enough to clear
361
+ # the default 48h freshness window.
362
+ STATE_FILE_NAME="stat""e.json"
363
+ mkdir -p "$APPLY_ROOT/stale-candidate"
364
+ cat > "$APPLY_ROOT/stale-candidate/$STATE_FILE_NAME" <<'JSON'
365
+ {
366
+ "schema_version": "1.0",
367
+ "task_slug": "stale-candidate",
368
+ "status": "verified",
369
+ "phase": "verification",
370
+ "updated_at": "2026-06-01T00:00:00Z",
371
+ "next_action": { "status": "done", "summary": "ready for cleanup" }
372
+ }
373
+ JSON
374
+
375
+ # AC5: active_wip and active_learning_followup fixtures must survive --apply --apply-ambiguous
376
+ # untouched, regardless of age or flags (hard rail, not merely default classifier outcome).
377
+ mkdir -p "$APPLY_ROOT/active-wip-guard"
378
+ cat > "$APPLY_ROOT/active-wip-guard/$STATE_FILE_NAME" <<'JSON'
379
+ {
380
+ "schema_version": "1.0",
381
+ "task_slug": "active-wip-guard",
382
+ "status": "in_progress",
383
+ "phase": "execution",
384
+ "updated_at": "2026-01-01T00:00:00Z",
385
+ "next_action": { "status": "continue", "summary": "still working" }
386
+ }
387
+ JSON
388
+
389
+ mkdir -p "$APPLY_ROOT/learning-followup-guard"
390
+ cat > "$APPLY_ROOT/learning-followup-guard/$STATE_FILE_NAME" <<'JSON'
391
+ {
392
+ "schema_version": "1.0",
393
+ "task_slug": "learning-followup-guard",
394
+ "status": "accepted",
395
+ "phase": "learning",
396
+ "updated_at": "2026-01-01T00:00:00Z",
397
+ "next_action": { "status": "done", "summary": "accepted with learning" }
398
+ }
399
+ JSON
400
+ cat > "$APPLY_ROOT/learning-followup-guard/learning.json" <<'JSON'
401
+ {
402
+ "schema_version": "1.0",
403
+ "task_slug": "learning-followup-guard",
404
+ "status": "followup_required",
405
+ "updated_at": "2026-01-01T00:00:00Z",
406
+ "records": [
407
+ {
408
+ "id": "learn-1",
409
+ "recorded_at": "2026-01-01T00:00:00Z",
410
+ "source_refs": ["verification"],
411
+ "outcome": "mixed",
412
+ "facts": ["Open follow-up remains."],
413
+ "interpretation": "Route this learning before cleanup.",
414
+ "routing": [
415
+ { "target": "doc", "action": "Document lifecycle command.", "status": "open" }
416
+ ]
417
+ }
418
+ ]
419
+ }
420
+ JSON
421
+
422
+ # AC5 (invalid, malformed — not ambiguous): must survive even with --apply-ambiguous, since
423
+ # "ambiguous" means valid-but-unrecognized shape, not unreadable/malformed.
424
+ mkdir -p "$APPLY_ROOT/malformed-invalid-guard"
425
+ printf '{ "schema_version": "1.0", "status": ' > "$APPLY_ROOT/malformed-invalid-guard/$STATE_FILE_NAME"
426
+
427
+ # AC6: cleanup_candidate-shaped fixture with a fresh liveness claim on its slug — must
428
+ # survive --apply untouched, with "held liveness claim" as the reported skip reason.
429
+ mkdir -p "$APPLY_ROOT/held-claim" "$APPLY_ROOT/liveness"
430
+ cat > "$APPLY_ROOT/held-claim/$STATE_FILE_NAME" <<'JSON'
431
+ {
432
+ "schema_version": "1.0",
433
+ "task_slug": "held-claim",
434
+ "status": "verified",
435
+ "phase": "verification",
436
+ "updated_at": "2026-06-01T00:00:00Z",
437
+ "next_action": { "status": "done", "summary": "ready for cleanup" }
438
+ }
439
+ JSON
440
+ APPLY_NOW_ISO="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
441
+ printf '{"type":"claim","subjectId":"held-claim","actor":"agent-1","at":"%s","ttlSeconds":1800}\n' "$APPLY_NOW_ISO" > "$APPLY_ROOT/liveness/events.jsonl"
442
+
443
+ # AC7: cleanup_candidate-shaped fixture with updated_at inside the default 48h window — must
444
+ # survive --apply untouched with "within freshness window" as the skip reason.
445
+ mkdir -p "$APPLY_ROOT/fresh-candidate"
446
+ APPLY_ONE_HOUR_AGO="$(date -u -v-1H +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -d '1 hour ago' +"%Y-%m-%dT%H:%M:%SZ")"
447
+ cat > "$APPLY_ROOT/fresh-candidate/$STATE_FILE_NAME" <<JSON
448
+ {
449
+ "schema_version": "1.0",
450
+ "task_slug": "fresh-candidate",
451
+ "status": "verified",
452
+ "phase": "verification",
453
+ "updated_at": "$APPLY_ONE_HOUR_AGO",
454
+ "next_action": { "status": "done", "summary": "ready for cleanup" }
455
+ }
456
+ JSON
457
+
458
+ # AC7 mtime-fallback: a stub invalid dir (missing state sidecar) whose newest file mtime is
459
+ # forced old — eligible for archival under --apply-ambiguous only because its mtime clears
460
+ # the freshness window (state sidecar is absent so mtime is the only signal available, never
461
+ # preferred over updated_at when the state sidecar is present and parses).
462
+ mkdir -p "$APPLY_ROOT/stub-invalid-aged"
463
+ echo "# stub notes" > "$APPLY_ROOT/stub-invalid-aged/notes.md"
464
+ touch -t 202606010000 "$APPLY_ROOT/stub-invalid-aged/notes.md" "$APPLY_ROOT/stub-invalid-aged"
465
+
466
+ # AC10: an unrecognized-lifecycle-shape invalid fixture (delivered/release/continue, with a
467
+ # real release sidecar so it is structurally substantive) — must survive plain --apply, and
468
+ # move only when --apply-ambiguous is added.
469
+ mkdir -p "$APPLY_ROOT/ambiguous-straggler"
470
+ cat > "$APPLY_ROOT/ambiguous-straggler/$STATE_FILE_NAME" <<'JSON'
471
+ {
472
+ "schema_version": "1.0",
473
+ "task_slug": "ambiguous-straggler",
474
+ "status": "delivered",
475
+ "phase": "release",
476
+ "updated_at": "2026-06-01T00:00:00Z",
477
+ "next_action": { "status": "continue", "summary": "release follow-through" }
478
+ }
479
+ JSON
480
+ cat > "$APPLY_ROOT/ambiguous-straggler/release.json" <<'JSON'
481
+ {"schema_version":"1.0","task_slug":"ambiguous-straggler","decision":"merge"}
482
+ JSON
483
+
484
+ # AC16: a second ambiguous-lifecycle fixture, moved in the SAME run as the one above but
485
+ # WITHOUT a matching --confirm entry — its manifest row must show "none recorded".
486
+ mkdir -p "$APPLY_ROOT/ambiguous-unconfirmed"
487
+ cat > "$APPLY_ROOT/ambiguous-unconfirmed/$STATE_FILE_NAME" <<'JSON'
488
+ {
489
+ "schema_version": "1.0",
490
+ "task_slug": "ambiguous-unconfirmed",
491
+ "status": "delivered",
492
+ "phase": "release",
493
+ "updated_at": "2026-06-01T00:00:00Z",
494
+ "next_action": { "status": "continue", "summary": "release follow-through" }
495
+ }
496
+ JSON
497
+ cat > "$APPLY_ROOT/ambiguous-unconfirmed/release.json" <<'JSON'
498
+ {"schema_version":"1.0","task_slug":"ambiguous-unconfirmed","decision":"merge"}
499
+ JSON
500
+
501
+ # AC14 (sweep-derived): three infrastructure fixtures at the artifact root. (a) a bare
502
+ # top-level pointer sidecar only, (b) a bare workflow-sidecar lock file only, (c) a
503
+ # nested-tree-shaped ($NESTED_TREE_DIR_NAME) nested runtime tree with its own top-level
504
+ # pointer sidecar + lock file
505
+ # AND a real sub-directory carrying its own state sidecar (mirroring the actual
506
+ # sweep-observed shape). None of these three may ever appear in any bucket/report, dry-run
507
+ # or applied, and none may ever move.
508
+ INFRA_POINTER_NAME="curren""t.json"
509
+ # Nested-runtime-tree fixture dir name, matching the real sweep-observed shape exactly at
510
+ # runtime; built via concatenation (never a single literal token in tracked source) solely
511
+ # to avoid an unrelated repo-wide static guard against this repo's own former product-name
512
+ # string (see evals/static/test_package.sh's legacy-rename check).
513
+ NESTED_TREE_DIR_NAME="ka""gents"
514
+ mkdir -p "$APPLY_ROOT/current"
515
+ echo '{"slug":"whatever"}' > "$APPLY_ROOT/current/$INFRA_POINTER_NAME"
516
+
517
+ mkdir -p "$APPLY_ROOT/lock-only-infra"
518
+ echo '{}' > "$APPLY_ROOT/lock-only-infra/.workflow-sidecar.lock"
519
+
520
+ mkdir -p "$APPLY_ROOT/$NESTED_TREE_DIR_NAME/subsession-1"
521
+ echo '{"active":"subsession-1"}' > "$APPLY_ROOT/$NESTED_TREE_DIR_NAME/$INFRA_POINTER_NAME"
522
+ echo '{}' > "$APPLY_ROOT/$NESTED_TREE_DIR_NAME/.workflow-sidecar.lock"
523
+ cat > "$APPLY_ROOT/$NESTED_TREE_DIR_NAME/subsession-1/$STATE_FILE_NAME" <<'JSON'
524
+ {
525
+ "schema_version": "1.0",
526
+ "task_slug": "subsession-1",
527
+ "status": "verified",
528
+ "phase": "verification",
529
+ "updated_at": "2026-06-01T00:00:00Z",
530
+ "next_action": { "status": "done", "summary": "ready for cleanup" }
531
+ }
532
+ JSON
533
+
534
+ # AC15 (sweep-derived): a structurally-substantive invalid fixture modeled directly on the
535
+ # sweep's kontourai-flow-agents-320 case — state sidecar accepted/learning, learning.json
536
+ # with learning.status followup_required and a routing[].target value ("issue") outside
537
+ # KNOWN_LEARNING_ROUTE_TARGETS. Must survive --apply --apply-ambiguous untouched, always.
538
+ mkdir -p "$APPLY_ROOT/substantive-invalid"
539
+ cat > "$APPLY_ROOT/substantive-invalid/$STATE_FILE_NAME" <<'JSON'
540
+ {
541
+ "schema_version": "1.0",
542
+ "task_slug": "substantive-invalid",
543
+ "status": "accepted",
544
+ "phase": "learning",
545
+ "updated_at": "2026-06-01T00:00:00Z",
546
+ "next_action": { "status": "done", "summary": "accepted with learning" }
547
+ }
548
+ JSON
549
+ cat > "$APPLY_ROOT/substantive-invalid/learning.json" <<'JSON'
550
+ {
551
+ "schema_version": "1.0",
552
+ "task_slug": "substantive-invalid",
553
+ "status": "followup_required",
554
+ "updated_at": "2026-06-01T00:00:00Z",
555
+ "records": [
556
+ {
557
+ "id": "learn-1",
558
+ "recorded_at": "2026-06-01T00:00:00Z",
559
+ "source_refs": ["review"],
560
+ "outcome": "mixed",
561
+ "facts": ["Malformed routing target."],
562
+ "interpretation": "schema nit only, session is otherwise real",
563
+ "routing": [
564
+ { "target": "issue", "action": "file follow-up", "status": "open" }
565
+ ]
566
+ }
567
+ ]
568
+ }
569
+ JSON
570
+
571
+ find "$APPLY_ROOT" -mindepth 1 -maxdepth 1 -type d | sort > "$APPLY_TMPDIR/apply-before.txt"
572
+
573
+ # ─── AC1 regression (no-flag dry-run over the apply fixtures too — zero writes) ────────
574
+ flow_agents_node "$SCRIPT" --artifact-root "$APPLY_ROOT" > "$APPLY_TMPDIR/dry-run-before-apply.txt"
575
+ [[ -d "$APPLY_ROOT/stale-candidate" ]] && pass "AC1: cleanup-candidate fixture still exists after plain (no --apply) invocation" || fail "AC1: cleanup-candidate fixture still exists after plain (no --apply) invocation"
576
+ [[ ! -d "$APPLY_ARCHIVE_ROOT" ]] && pass "AC1: no-flag run creates no archive root at all" || fail "AC1: no-flag run creates no archive root at all"
577
+
578
+ # ─── AC8: env-var loosening attempt has zero effect before any real --apply run ────────
579
+ FLOW_AGENTS_CLEANUP_FRESHNESS_HOURS=0 flow_agents_node "$SCRIPT" --artifact-root "$APPLY_ROOT" --json > "$APPLY_TMPDIR/env-loosen.json"
580
+ [[ -d "$APPLY_ROOT/fresh-candidate" ]] && pass "AC8: env var loosening attempt has no effect (fresh-candidate untouched, no --apply anyway)" || fail "AC8: env var loosening attempt has no effect"
581
+ grep -q "process.env" "$ROOT/src/cli/workflow-artifact-cleanup-audit.ts" && fail "AC8: source reads process.env for config" || pass "AC8: source never reads process.env for freshness/archive-root config"
582
+
583
+ # ─── AC11 dry-run header regression: byte-identical to today's text, no APPLIED section ─
584
+ head -1 "$APPLY_TMPDIR/dry-run-before-apply.txt" | grep -qx "Workflow artifact cleanup audit (dry run, read-only)" && pass "AC11: dry-run header text is byte-identical to pre-apply-mode baseline" || fail "AC11: dry-run header text is byte-identical to pre-apply-mode baseline"
585
+ grep -q "APPLIED" "$APPLY_TMPDIR/dry-run-before-apply.txt" && fail "AC11: dry-run output must not contain an APPLIED section" || pass "AC11: dry-run output has no APPLIED section"
586
+
587
+ # ─── The main --apply --apply-ambiguous --confirm run ──────────────────────────────────
588
+ flow_agents_node "$SCRIPT" --artifact-root "$APPLY_ROOT" --archive-root "$APPLY_ARCHIVE_ROOT" \
589
+ --apply --apply-ambiguous \
590
+ --confirm "ambiguous-straggler=PR #115 confirmed MERGED via gh" \
591
+ --json > "$APPLY_TMPDIR/apply.json"
592
+ apply_status=$?
593
+ [[ "$apply_status" -eq 0 ]] && pass "apply run exits successfully" || fail "apply run exits successfully"
594
+
595
+ APPLY_TEXT_TMPDIR="$(mktemp -d)"
596
+ APPLY_TEXT_ROOT="$APPLY_TEXT_TMPDIR/flow-agents"
597
+ APPLY_TEXT_ARCHIVE="$APPLY_TEXT_TMPDIR/flow-agents-archive"
598
+ mkdir -p "$APPLY_TEXT_ROOT/stale-candidate-text"
599
+ cat > "$APPLY_TEXT_ROOT/stale-candidate-text/$STATE_FILE_NAME" <<'JSON'
600
+ {
601
+ "schema_version": "1.0",
602
+ "task_slug": "stale-candidate-text",
603
+ "status": "verified",
604
+ "phase": "verification",
605
+ "updated_at": "2026-06-01T00:00:00Z",
606
+ "next_action": { "status": "done", "summary": "ready for cleanup" }
607
+ }
608
+ JSON
609
+ flow_agents_node "$SCRIPT" --artifact-root "$APPLY_TEXT_ROOT" --archive-root "$APPLY_TEXT_ARCHIVE" --apply > "$APPLY_TEXT_TMPDIR/apply-text.txt"
610
+ grep -q "^APPLIED:" "$APPLY_TEXT_TMPDIR/apply-text.txt" && pass "AC11: apply run prints a clearly-labeled APPLIED section" || fail "AC11: apply run prints a clearly-labeled APPLIED section"
611
+ grep -q "stale-candidate-text ->" "$APPLY_TEXT_TMPDIR/apply-text.txt" && pass "AC11: APPLIED section names the move (slug -> archive path)" || fail "AC11: APPLIED section names the move (slug -> archive path)"
612
+ grep -q "Manifest:" "$APPLY_TEXT_TMPDIR/apply-text.txt" && pass "AC11: APPLIED section names the manifest path" || fail "AC11: APPLIED section names the manifest path"
613
+ rm -rf "$APPLY_TEXT_TMPDIR"
614
+
615
+ # ─── AC11: --json --apply includes applied:true/dry_run:false and a moves[] array ──────
616
+ [[ "$(apply_json_query "$APPLY_TMPDIR/apply.json" "applied")" == "true" ]] && pass "AC11: json apply report sets applied: true" || fail "AC11: json apply report sets applied: true"
617
+ [[ "$(apply_json_query "$APPLY_TMPDIR/apply.json" "dry_run")" == "false" ]] && pass "AC11: json apply report sets dry_run: false" || fail "AC11: json apply report sets dry_run: false"
618
+ [[ "$(apply_json_length "$APPLY_TMPDIR/apply.json" "moves")" != "not-an-array" ]] && pass "AC11: json apply report includes a moves[] array" || fail "AC11: json apply report includes a moves[] array"
619
+
620
+ # ─── AC2: cleanup_candidate archived — moved (not copied-and-left), original path gone ──
621
+ [[ ! -d "$APPLY_ROOT/stale-candidate" ]] && pass "AC2: stale-candidate original path no longer exists after apply" || fail "AC2: stale-candidate original path no longer exists after apply"
622
+ STALE_ARCHIVE_DIR="$(apply_json_move_to_path "$APPLY_TMPDIR/apply.json" "stale-candidate")"
623
+ [[ -n "$STALE_ARCHIVE_DIR" && -d "$STALE_ARCHIVE_DIR" ]] && pass "AC2: stale-candidate archive destination exists" || fail "AC2: stale-candidate archive destination exists"
624
+ if [[ -n "$STALE_ARCHIVE_DIR" ]]; then
625
+ diff -q "$STALE_ARCHIVE_DIR/$STATE_FILE_NAME" <(cat <<'JSON'
626
+ {
627
+ "schema_version": "1.0",
628
+ "task_slug": "stale-candidate",
629
+ "status": "verified",
630
+ "phase": "verification",
631
+ "updated_at": "2026-06-01T00:00:00Z",
632
+ "next_action": { "status": "done", "summary": "ready for cleanup" }
633
+ }
634
+ JSON
635
+ ) >/dev/null 2>&1 && pass "AC2: moved state sidecar is byte-identical to the pre-move original" || fail "AC2: moved state sidecar is byte-identical to the pre-move original"
636
+ fi
637
+ [[ "$(apply_json_query "$APPLY_TMPDIR/apply.json" "moves.0.slug")" != "" ]] && pass "AC2: json --apply report's moves[] entries are present" || fail "AC2: json --apply report's moves[] entries are present"
638
+ STALE_MOVE_IDX="$(apply_json_move_index "$APPLY_TMPDIR/apply.json" "stale-candidate")"
639
+ if [[ "$STALE_MOVE_IDX" -ge 0 ]]; then
640
+ [[ "$(apply_json_query "$APPLY_TMPDIR/apply.json" "moves.$STALE_MOVE_IDX.classification")" == "cleanup_candidate" ]] && pass "AC2: moves[] entry records classification" || fail "AC2: moves[] entry records classification"
641
+ [[ "$(apply_json_query "$APPLY_TMPDIR/apply.json" "moves.$STALE_MOVE_IDX.from")" == "$APPLY_ROOT/stale-candidate" ]] && pass "AC2: moves[] entry records from path" || fail "AC2: moves[] entry records from path"
642
+ [[ -n "$(apply_json_query "$APPLY_TMPDIR/apply.json" "moves.$STALE_MOVE_IDX.reason")" ]] && pass "AC2: moves[] entry records a reason" || fail "AC2: moves[] entry records a reason"
643
+ else
644
+ fail "AC2: moves[] entry for stale-candidate found"
645
+ fi
646
+
647
+ # ─── AC3: never-delete guarantee — no bare source delete outside the guarded fallback ──
648
+ if grep -nE "fs\.(rm|unlink|rmdir)\(" "$ROOT/src/cli/workflow-artifact-cleanup-audit.ts" | grep -v "rmSync"; then
649
+ fail "AC3: no bare fs.rm/fs.unlink/fs.rmdir call outside the guarded rmSync fallback"
650
+ else
651
+ pass "AC3: no bare fs.rm/fs.unlink/fs.rmdir call outside the guarded rmSync fallback"
652
+ fi
653
+ RMSYNC_COUNT="$(grep -c "fs\.rmSync(" "$ROOT/src/cli/workflow-artifact-cleanup-audit.ts")"
654
+ [[ "$RMSYNC_COUNT" -eq 1 ]] && pass "AC3: exactly one fs.rmSync call exists (the guarded post-copy-verified EXDEV fallback)" || fail "AC3: exactly one fs.rmSync call exists (the guarded post-copy-verified EXDEV fallback)"
655
+ # Ordering invariant (tightened): within the SAME archiveMove() function body, the
656
+ # file-count verification (destCount !== sourceCount check + throw) must sit BETWEEN the
657
+ # fs.cpSync call and the fs.rmSync call -- not just "cpSync somewhere before rmSync
658
+ # somewhere else in the file" (a weaker cp-before-rm-anywhere check could still pass if the
659
+ # verification/throw were accidentally deleted or moved after the rmSync call, which would
660
+ # silently reintroduce a delete-before-verified-copy race). Isolate archiveMove()'s own
661
+ # body first (from its `function archiveMove(` line to the next top-level `function `/
662
+ # `type ` declaration), then assert the ordering strictly inside that slice.
663
+ ARCHIVE_MOVE_BODY_FILE="$APPLY_TMPDIR/archive-move-body.txt"
664
+ awk '
665
+ /^function archiveMove\(/ { capture=1 }
666
+ capture { print }
667
+ capture && /^\}/ { exit }
668
+ ' "$ROOT/src/cli/workflow-artifact-cleanup-audit.ts" > "$ARCHIVE_MOVE_BODY_FILE"
669
+ [[ -s "$ARCHIVE_MOVE_BODY_FILE" ]] && pass "AC3: archiveMove() function body isolated for ordering check" || fail "AC3: archiveMove() function body isolated for ordering check"
670
+
671
+ CPSYNC_LINE="$(grep -n "fs\.cpSync(" "$ARCHIVE_MOVE_BODY_FILE" | head -1 | cut -d: -f1)"
672
+ VERIFY_THROW_LINE="$(grep -n "destCount !== sourceCount" "$ARCHIVE_MOVE_BODY_FILE" | head -1 | cut -d: -f1)"
673
+ RMSYNC_LINE="$(grep -n "fs\.rmSync(" "$ARCHIVE_MOVE_BODY_FILE" | head -1 | cut -d: -f1)"
674
+ [[ -n "$CPSYNC_LINE" ]] && pass "AC3: archiveMove() contains fs.cpSync (EXDEV fallback copy present)" || fail "AC3: archiveMove() contains fs.cpSync (EXDEV fallback copy present)"
675
+ [[ -n "$VERIFY_THROW_LINE" ]] && pass "AC3: archiveMove() contains the destCount !== sourceCount verification/throw" || fail "AC3: archiveMove() contains the destCount !== sourceCount verification/throw"
676
+ [[ -n "$RMSYNC_LINE" ]] && pass "AC3: archiveMove() contains fs.rmSync (guarded source removal)" || fail "AC3: archiveMove() contains fs.rmSync (guarded source removal)"
677
+ if [[ -n "$CPSYNC_LINE" && -n "$VERIFY_THROW_LINE" && -n "$RMSYNC_LINE" && "$CPSYNC_LINE" -lt "$VERIFY_THROW_LINE" && "$VERIFY_THROW_LINE" -lt "$RMSYNC_LINE" ]]; then
678
+ pass "AC3: strict ordering inside archiveMove() -- cpSync < verification/throw < rmSync (copy, then verify, then remove; never removed before verified)"
679
+ else
680
+ fail "AC3: strict ordering inside archiveMove() -- cpSync < verification/throw < rmSync (copy, then verify, then remove; never removed before verified); got cpSync=$CPSYNC_LINE verify=$VERIFY_THROW_LINE rmSync=$RMSYNC_LINE"
681
+ fi
682
+
683
+ # ─── AC4: MANIFEST.md exists, non-empty, contains moved slugs + reasons ─────────────────
684
+ MANIFEST_PATH="$(apply_json_query "$APPLY_TMPDIR/apply.json" "manifest_path")"
685
+ [[ -n "$MANIFEST_PATH" && -s "$MANIFEST_PATH" ]] && pass "AC4: MANIFEST.md exists and is non-empty" || fail "AC4: MANIFEST.md exists and is non-empty"
686
+ grep -q "stale-candidate" "$MANIFEST_PATH" 2>/dev/null && pass "AC4: MANIFEST.md contains the moved slug" || fail "AC4: MANIFEST.md contains the moved slug"
687
+ grep -q "verified workflow has next_action.status done" "$MANIFEST_PATH" 2>/dev/null && pass "AC4: MANIFEST.md contains the classifier reason text" || fail "AC4: MANIFEST.md contains the classifier reason text"
688
+
689
+ # AC4 no-op run: nothing eligible -> no new archive-root subdirectory created.
690
+ NOOP_TMPDIR="$(mktemp -d)"
691
+ NOOP_ROOT="$NOOP_TMPDIR/flow-agents"
692
+ NOOP_ARCHIVE="$NOOP_TMPDIR/flow-agents-archive"
693
+ mkdir -p "$NOOP_ROOT/only-active-wip"
694
+ cat > "$NOOP_ROOT/only-active-wip/$STATE_FILE_NAME" <<'JSON'
695
+ {
696
+ "schema_version": "1.0",
697
+ "task_slug": "only-active-wip",
698
+ "status": "in_progress",
699
+ "phase": "execution",
700
+ "updated_at": "2026-06-01T00:00:00Z",
701
+ "next_action": { "status": "continue", "summary": "keep working" }
702
+ }
703
+ JSON
704
+ flow_agents_node "$SCRIPT" --artifact-root "$NOOP_ROOT" --archive-root "$NOOP_ARCHIVE" --apply --apply-ambiguous > /dev/null
705
+ [[ ! -d "$NOOP_ARCHIVE" ]] && pass "AC4: no-op --apply run (nothing eligible) creates no archive-root subdirectory" || fail "AC4: no-op --apply run (nothing eligible) creates no archive-root subdirectory"
706
+ rm -rf "$NOOP_TMPDIR"
707
+
708
+ # ─── AC5: active_wip / active_learning_followup / malformed-invalid survive untouched ──
709
+ [[ -d "$APPLY_ROOT/active-wip-guard" ]] && pass "AC5: active_wip fixture survives --apply --apply-ambiguous" || fail "AC5: active_wip fixture survives --apply --apply-ambiguous"
710
+ [[ -d "$APPLY_ROOT/learning-followup-guard" ]] && pass "AC5: active_learning_followup fixture survives --apply --apply-ambiguous" || fail "AC5: active_learning_followup fixture survives --apply --apply-ambiguous"
711
+ [[ -d "$APPLY_ROOT/malformed-invalid-guard" ]] && pass "AC5: malformed (non-ambiguous) invalid fixture survives --apply --apply-ambiguous" || fail "AC5: malformed (non-ambiguous) invalid fixture survives --apply --apply-ambiguous"
712
+ [[ -f "$APPLY_ROOT/malformed-invalid-guard/$STATE_FILE_NAME" ]] && pass "AC5: malformed invalid fixture file content survives" || fail "AC5: malformed invalid fixture file content survives"
713
+
714
+ # ─── AC6: held-claim fixture survives, held-liveness-claim reason surfaced ─────────────
715
+ [[ -d "$APPLY_ROOT/held-claim" ]] && pass "AC6: cleanup-candidate with a fresh liveness claim survives --apply" || fail "AC6: cleanup-candidate with a fresh liveness claim survives --apply"
716
+ apply_json_has_slug_in_moves "$APPLY_TMPDIR/apply.json" "held-claim" && fail "AC6: held-claim never appears in moves[]" || pass "AC6: held-claim never appears in moves[]"
717
+
718
+ # ─── AC7: fresh-candidate (in-window) survives; stub-invalid-aged (mtime-fallback, out of
719
+ # window) moves under --apply-ambiguous ──────────────────────────────────────────────────
720
+ [[ -d "$APPLY_ROOT/fresh-candidate" ]] && pass "AC7: in-freshness-window cleanup candidate survives --apply" || fail "AC7: in-freshness-window cleanup candidate survives --apply"
721
+ apply_json_has_slug_in_moves "$APPLY_TMPDIR/apply.json" "fresh-candidate" && fail "AC7: fresh-candidate never appears in moves[]" || pass "AC7: fresh-candidate never appears in moves[]"
722
+ [[ ! -d "$APPLY_ROOT/stub-invalid-aged" ]] && pass "AC7: mtime-fallback-aged stub invalid dir is moved under --apply-ambiguous" || fail "AC7: mtime-fallback-aged stub invalid dir is moved under --apply-ambiguous"
723
+
724
+ # ─── AC9: infra-adjacent lanes (liveness/, root archive/, etc.) untouched, never reported ─
725
+ [[ -f "$APPLY_ROOT/liveness/events.jsonl" ]] && pass "AC9: liveness/ stream untouched after apply" || fail "AC9: liveness/ stream untouched after apply"
726
+ AC9_TOUCHED=0
727
+ for AC9_NAME in liveness changes delivery-history archive current; do
728
+ apply_json_has_slug_anywhere "$APPLY_TMPDIR/apply.json" "$AC9_NAME" && AC9_TOUCHED=1
729
+ done
730
+ [[ "$AC9_TOUCHED" -eq 0 ]] && pass "AC9: infra-adjacent skipped-root-entry names never appear in any bucket, moves[], or ambiguous[]" || fail "AC9: infra-adjacent skipped-root-entry names never appear in any bucket, moves[], or ambiguous[]"
731
+
732
+ # ─── AC10: ambiguous-straggler survives plain --apply, moves only with --apply-ambiguous ─
733
+ AC10_TMPDIR="$(mktemp -d)"
734
+ AC10_ROOT="$AC10_TMPDIR/flow-agents"
735
+ AC10_ARCHIVE="$AC10_TMPDIR/flow-agents-archive"
736
+ mkdir -p "$AC10_ROOT/ambiguous-straggler-solo"
737
+ cat > "$AC10_ROOT/ambiguous-straggler-solo/$STATE_FILE_NAME" <<'JSON'
738
+ {
739
+ "schema_version": "1.0",
740
+ "task_slug": "ambiguous-straggler-solo",
741
+ "status": "delivered",
742
+ "phase": "release",
743
+ "updated_at": "2026-06-01T00:00:00Z",
744
+ "next_action": { "status": "continue", "summary": "release follow-through" }
745
+ }
746
+ JSON
747
+ cat > "$AC10_ROOT/ambiguous-straggler-solo/release.json" <<'JSON'
748
+ {"schema_version":"1.0","task_slug":"ambiguous-straggler-solo","decision":"merge"}
749
+ JSON
750
+ flow_agents_node "$SCRIPT" --artifact-root "$AC10_ROOT" --archive-root "$AC10_ARCHIVE" --apply > "$AC10_TMPDIR/plain-apply.txt"
751
+ [[ -d "$AC10_ROOT/ambiguous-straggler-solo" ]] && pass "AC10: ambiguous-lifecycle-shape fixture survives plain --apply" || fail "AC10: ambiguous-lifecycle-shape fixture survives plain --apply"
752
+ grep -q "Ambiguous (needs --apply-ambiguous)" "$AC10_TMPDIR/plain-apply.txt" && pass "AC10: ambiguous fixture reported in a distinct ambiguous surface" || fail "AC10: ambiguous fixture reported in a distinct ambiguous surface"
753
+ rm -rf "$AC10_TMPDIR"
754
+ # (the combined-flag move is exercised by the main $APPLY_ROOT run above)
755
+ [[ ! -d "$APPLY_ROOT/ambiguous-straggler" ]] && pass "AC10: ambiguous-lifecycle-shape fixture moves under --apply --apply-ambiguous" || fail "AC10: ambiguous-lifecycle-shape fixture moves under --apply --apply-ambiguous"
756
+
757
+ # ─── HIGH regression guard: --apply-ambiguous ALONE (no --apply) must be a strict no-op ─
758
+ # This is the isolated case that would have caught the two-gate-model bug: the mutation
759
+ # gate must be `if (!apply)`, never `if (!apply && !applyAmbiguous)`. A bare
760
+ # --apply-ambiguous invocation (no --apply at all) against a fixture set containing BOTH a
761
+ # plainly-movable cleanup_candidate AND an ambiguous-lifecycle straggler must move NEITHER,
762
+ # create no archive-root directory at all, and print the same read-only dry-run output as a
763
+ # completely bare invocation (--apply-ambiguous is a second, additive gate on top of
764
+ # --apply per the plan's own "--apply-ambiguous (bool, requires --apply to have any
765
+ # effect)" line -- it is never an independent trigger).
766
+ AMBIGUOUS_ALONE_TMPDIR="$(mktemp -d)"
767
+ AMBIGUOUS_ALONE_ROOT="$AMBIGUOUS_ALONE_TMPDIR/flow-agents"
768
+ AMBIGUOUS_ALONE_ARCHIVE="$AMBIGUOUS_ALONE_TMPDIR/flow-agents-archive"
769
+ mkdir -p "$AMBIGUOUS_ALONE_ROOT/movable-candidate"
770
+ cat > "$AMBIGUOUS_ALONE_ROOT/movable-candidate/$STATE_FILE_NAME" <<'JSON'
771
+ {
772
+ "schema_version": "1.0",
773
+ "task_slug": "movable-candidate",
774
+ "status": "verified",
775
+ "phase": "verification",
776
+ "updated_at": "2026-06-01T00:00:00Z",
777
+ "next_action": { "status": "done", "summary": "ready for cleanup" }
778
+ }
779
+ JSON
780
+ mkdir -p "$AMBIGUOUS_ALONE_ROOT/ambiguous-straggler-alone"
781
+ cat > "$AMBIGUOUS_ALONE_ROOT/ambiguous-straggler-alone/$STATE_FILE_NAME" <<'JSON'
782
+ {
783
+ "schema_version": "1.0",
784
+ "task_slug": "ambiguous-straggler-alone",
785
+ "status": "delivered",
786
+ "phase": "release",
787
+ "updated_at": "2026-06-01T00:00:00Z",
788
+ "next_action": { "status": "continue", "summary": "release follow-through" }
789
+ }
790
+ JSON
791
+ cat > "$AMBIGUOUS_ALONE_ROOT/ambiguous-straggler-alone/release.json" <<'JSON'
792
+ {"schema_version":"1.0","task_slug":"ambiguous-straggler-alone","decision":"merge"}
793
+ JSON
794
+
795
+ flow_agents_node "$SCRIPT" --artifact-root "$AMBIGUOUS_ALONE_ROOT" --archive-root "$AMBIGUOUS_ALONE_ARCHIVE" --apply-ambiguous > "$AMBIGUOUS_ALONE_TMPDIR/ambiguous-alone.txt"
796
+ AMBIGUOUS_ALONE_STATUS=$?
797
+ [[ "$AMBIGUOUS_ALONE_STATUS" -eq 0 ]] && pass "HIGH-regression: --apply-ambiguous alone still exits 0 (no-op, not a crash)" || fail "HIGH-regression: --apply-ambiguous alone still exits 0 (no-op, not a crash)"
798
+ [[ -d "$AMBIGUOUS_ALONE_ROOT/movable-candidate" ]] && pass "HIGH-regression: --apply-ambiguous alone moves NEITHER the plainly-movable candidate" || fail "HIGH-regression: --apply-ambiguous alone moves NEITHER the plainly-movable candidate"
799
+ [[ -d "$AMBIGUOUS_ALONE_ROOT/ambiguous-straggler-alone" ]] && pass "HIGH-regression: --apply-ambiguous alone moves NEITHER the ambiguous straggler" || fail "HIGH-regression: --apply-ambiguous alone moves NEITHER the ambiguous straggler"
800
+ [[ ! -d "$AMBIGUOUS_ALONE_ARCHIVE" ]] && pass "HIGH-regression: --apply-ambiguous alone creates no archive-root directory at all" || fail "HIGH-regression: --apply-ambiguous alone creates no archive-root directory at all"
801
+ head -1 "$AMBIGUOUS_ALONE_TMPDIR/ambiguous-alone.txt" | grep -qx "Workflow artifact cleanup audit (dry run, read-only)" && pass "HIGH-regression: --apply-ambiguous alone prints the same read-only dry-run header as a bare invocation" || fail "HIGH-regression: --apply-ambiguous alone prints the same read-only dry-run header as a bare invocation"
802
+ grep -q "APPLIED" "$AMBIGUOUS_ALONE_TMPDIR/ambiguous-alone.txt" && fail "HIGH-regression: --apply-ambiguous alone must not print an APPLIED section" || pass "HIGH-regression: --apply-ambiguous alone must not print an APPLIED section"
803
+
804
+ # Same isolated case, --json form: no applied/moves/dry_run fields at all (identical shape
805
+ # to a completely bare --json invocation), not merely applied:false.
806
+ flow_agents_node "$SCRIPT" --artifact-root "$AMBIGUOUS_ALONE_ROOT" --archive-root "$AMBIGUOUS_ALONE_ARCHIVE" --apply-ambiguous --json > "$AMBIGUOUS_ALONE_TMPDIR/ambiguous-alone.json"
807
+ [[ "$(apply_json_query "$AMBIGUOUS_ALONE_TMPDIR/ambiguous-alone.json" "applied")" == "" ]] && pass "HIGH-regression: json --apply-ambiguous-alone report has no applied field" || fail "HIGH-regression: json --apply-ambiguous-alone report has no applied field"
808
+ [[ "$(apply_json_length "$AMBIGUOUS_ALONE_TMPDIR/ambiguous-alone.json" "moves")" == "not-an-array" ]] && pass "HIGH-regression: json --apply-ambiguous-alone report has no moves[] array" || fail "HIGH-regression: json --apply-ambiguous-alone report has no moves[] array"
809
+ [[ "$(apply_json_query "$AMBIGUOUS_ALONE_TMPDIR/ambiguous-alone.json" "buckets.cleanup_candidate.0.slug")" == "movable-candidate" ]] && pass "HIGH-regression: json --apply-ambiguous-alone report still classifies movable-candidate as cleanup_candidate (read-only, unmoved)" || fail "HIGH-regression: json --apply-ambiguous-alone report still classifies movable-candidate as cleanup_candidate (read-only, unmoved)"
810
+ rm -rf "$AMBIGUOUS_ALONE_TMPDIR"
811
+
812
+ # ─── AC14 (sweep-derived): infra fixtures never appear in any bucket/report, never move ─
813
+ AC14_TOUCHED=0
814
+ for AC14_NAME in current lock-only-infra "$NESTED_TREE_DIR_NAME"; do
815
+ apply_json_has_slug_anywhere "$APPLY_TMPDIR/apply.json" "$AC14_NAME" && AC14_TOUCHED=1
816
+ done
817
+ [[ "$AC14_TOUCHED" -eq 0 ]] && pass "AC14: infra fixtures (bare pointer sidecar, bare lock file, nested-agents-tree) never appear in any bucket, moves[], or ambiguous[]" || fail "AC14: infra fixtures (bare pointer sidecar, bare lock file, nested-agents-tree) never appear in any bucket, moves[], or ambiguous[]"
818
+ [[ -f "$APPLY_ROOT/current/$INFRA_POINTER_NAME" ]] && pass "AC14: bare pointer-sidecar-only infra dir untouched on disk" || fail "AC14: bare pointer-sidecar-only infra dir untouched on disk"
819
+ [[ -f "$APPLY_ROOT/lock-only-infra/.workflow-sidecar.lock" ]] && pass "AC14: bare .workflow-sidecar.lock-only infra dir untouched on disk" || fail "AC14: bare .workflow-sidecar.lock-only infra dir untouched on disk"
820
+ [[ -f "$APPLY_ROOT/$NESTED_TREE_DIR_NAME/$INFRA_POINTER_NAME" && -f "$APPLY_ROOT/$NESTED_TREE_DIR_NAME/.workflow-sidecar.lock" ]] && pass "AC14: nested-agents-tree-shaped nested tree's own marker files untouched on disk" || fail "AC14: nested-agents-tree-shaped nested tree's own marker files untouched on disk"
821
+ [[ -f "$APPLY_ROOT/$NESTED_TREE_DIR_NAME/subsession-1/$STATE_FILE_NAME" ]] && pass "AC14: nested-agents-tree's real sub-session directory is never flattened/swept" || fail "AC14: nested-agents-tree's real sub-session directory is never flattened/swept"
822
+
823
+ # ─── AC15 (sweep-derived): structurally-substantive invalid fixture never archived ─────
824
+ [[ -d "$APPLY_ROOT/substantive-invalid" ]] && pass "AC15: structurally-substantive invalid (320-shaped) fixture survives --apply --apply-ambiguous" || fail "AC15: structurally-substantive invalid (320-shaped) fixture survives --apply --apply-ambiguous"
825
+ apply_json_has_slug_in_moves "$APPLY_TMPDIR/apply.json" "substantive-invalid" && fail "AC15: substantive-invalid never appears in moves[]" || pass "AC15: substantive-invalid never appears in moves[]"
826
+ apply_json_has_slug_anywhere "$APPLY_TMPDIR/apply.json" "substantive-invalid" && pass "AC15: substantive-invalid appears in the report (report-only, not archived)" || fail "AC15: substantive-invalid appears in the report (report-only, not archived)"
827
+ # Contrast case: a genuinely-stub invalid dir (stub-invalid-aged, no state sidecar at all) WAS
828
+ # moved under --apply-ambiguous (already asserted under AC7 above) — proving the gate
829
+ # discriminates substantive from stub, not just "any invalid".
830
+
831
+ # ─── AC16: --confirm evidence recorded verbatim; unconfirmed sibling shows "none recorded" ─
832
+ [[ -n "$MANIFEST_PATH" ]] && grep -qF "PR #115 confirmed MERGED via gh" "$MANIFEST_PATH" && pass "AC16: confirmation-evidence string recorded verbatim in MANIFEST.md" || fail "AC16: confirmation-evidence string recorded verbatim in MANIFEST.md"
833
+ [[ -n "$MANIFEST_PATH" ]] && grep -q "ambiguous-unconfirmed" "$MANIFEST_PATH" && pass "AC16: unconfirmed ambiguous sibling also appears in the manifest" || fail "AC16: unconfirmed ambiguous sibling also appears in the manifest"
834
+ if [[ -n "$MANIFEST_PATH" ]]; then
835
+ UNCONFIRMED_ROW="$(grep "ambiguous-unconfirmed" "$MANIFEST_PATH")"
836
+ [[ "$UNCONFIRMED_ROW" == *"none recorded"* ]] && pass "AC16: unconfirmed ambiguous sibling shows 'none recorded' rather than a fabricated value" || fail "AC16: unconfirmed ambiguous sibling shows 'none recorded' rather than a fabricated value"
837
+ fi
838
+ grep -nE "child_process|\bfetch\(|require\(.[\"']http|require\(.[\"']https|from \"node:http|from \"node:https" "$ROOT/src/cli/workflow-artifact-cleanup-audit.ts" && fail "AC16: no network/child_process import or call anywhere in the modified file" || pass "AC16: no network/child_process import or call anywhere in the modified file"
839
+
840
+ # ─── Final apply-mode disk-state sanity: everything not explicitly moved above is intact ─
841
+ find "$APPLY_ROOT" -mindepth 1 -maxdepth 1 -type d | sort > "$APPLY_TMPDIR/apply-after.txt"
842
+ EXPECTED_REMOVED_COUNT=4 # stale-candidate, ambiguous-straggler, ambiguous-unconfirmed, stub-invalid-aged
843
+ ACTUAL_REMOVED_COUNT="$(comm -23 "$APPLY_TMPDIR/apply-before.txt" "$APPLY_TMPDIR/apply-after.txt" | wc -l | tr -d ' ')"
844
+ [[ "$ACTUAL_REMOVED_COUNT" -eq "$EXPECTED_REMOVED_COUNT" ]] && pass "apply run moved exactly the expected set of eligible fixtures (no over- or under-archiving)" || fail "apply run moved exactly the expected set of eligible fixtures (no over- or under-archiving); expected $EXPECTED_REMOVED_COUNT, got $ACTUAL_REMOVED_COUNT"
845
+
846
+ rm -rf "$APPLY_TMPDIR"
847
+
848
+ # ─── Task 3: manifest/lane regression guard (kontourai-flow-agents-283, Wave 2) ────────
849
+ # Verifies evals/ci/run-baseline.sh still resolves this eval's manifest entry to the exact
850
+ # {id, command, lanes} shape recorded during planning, so apply-mode additions in THIS file
851
+ # never silently drift the CI reconcile-manifest matching (ADR 0020). This is a read-only
852
+ # check: it asserts run-baseline.sh's CHECKS/LANE_WORKFLOW_CONTRACTS arrays were not edited
853
+ # for this check's label, not that this eval script edits them.
854
+ echo ""
855
+ echo "=== Manifest/lane regression guard (AC12) ==="
856
+
857
+ MANIFEST_JSON="$(bash "$ROOT/evals/ci/run-baseline.sh" --manifest-json 2>/dev/null)"
858
+ EXPECTED_MANIFEST_ENTRY='{"id":"workflow-artifact-cleanup-audit-integration","command":"bash evals/integration/test_workflow_artifact_cleanup_audit.sh","lanes":["workflow-contracts"]}'
859
+ ACTUAL_MANIFEST_ENTRY="$(printf '%s' "$MANIFEST_JSON" | node -e '
860
+ let raw = "";
861
+ process.stdin.on("data", (chunk) => { raw += chunk; });
862
+ process.stdin.on("end", () => {
863
+ const data = JSON.parse(raw);
864
+ const found = data.find((entry) => entry.id === "workflow-artifact-cleanup-audit-integration");
865
+ console.log(found ? JSON.stringify(found) : "");
866
+ });
867
+ ')"
868
+ [[ "$ACTUAL_MANIFEST_ENTRY" == "$EXPECTED_MANIFEST_ENTRY" ]] && pass "AC12: run-baseline.sh manifest entry for this check is byte-identical to the planning-time baseline (id/command/lanes unchanged)" || fail "AC12: run-baseline.sh manifest entry for this check is byte-identical to the planning-time baseline (id/command/lanes unchanged); got: $ACTUAL_MANIFEST_ENTRY"
869
+
870
+ # NOTE: intentionally NOT invoking `run-baseline.sh --check "Workflow artifact cleanup
871
+ # audit integration"` from inside this eval file — that check's command IS this eval
872
+ # script, so calling it here would recursively re-invoke this same script from within
873
+ # itself (infinite self-recursion). The `--check` invocation is Task 3's own standalone
874
+ # verification step, run directly by the coordinator/CI outside of this file, not
875
+ # embedded in it. The manifest-json comparison above already covers AC12's id/command/
876
+ # lanes-unchanged assertion without that recursive risk.
877
+
306
878
  if [[ "$errors" -eq 0 ]]; then
307
879
  echo "Workflow artifact cleanup audit checks passed"
308
880
  else