@frankzhang2026/opencode-android-orchestrator 1.0.4 → 1.1.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 (107) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +79 -18
  3. package/dist/config/queue-policy.d.ts.map +1 -1
  4. package/dist/config/queue-policy.js +1 -2
  5. package/dist/config/queue-policy.js.map +1 -1
  6. package/dist/doctor/index.d.ts.map +1 -1
  7. package/dist/doctor/index.js +14 -3
  8. package/dist/doctor/index.js.map +1 -1
  9. package/dist/doctor/installation.d.ts +1 -1
  10. package/dist/doctor/installation.d.ts.map +1 -1
  11. package/dist/doctor/installation.js +10 -1
  12. package/dist/doctor/installation.js.map +1 -1
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/installer/adaptive-templates.d.ts +4 -1
  18. package/dist/installer/adaptive-templates.d.ts.map +1 -1
  19. package/dist/installer/adaptive-templates.js +49 -13
  20. package/dist/installer/adaptive-templates.js.map +1 -1
  21. package/dist/installer/android-project.d.ts +3 -1
  22. package/dist/installer/android-project.d.ts.map +1 -1
  23. package/dist/installer/android-project.js.map +1 -1
  24. package/dist/installer/capabilities-script.d.ts +2 -0
  25. package/dist/installer/capabilities-script.d.ts.map +1 -0
  26. package/dist/installer/capabilities-script.js +77 -0
  27. package/dist/installer/capabilities-script.js.map +1 -0
  28. package/dist/installer/gradle-verification.d.ts.map +1 -1
  29. package/dist/installer/gradle-verification.js +13 -6
  30. package/dist/installer/gradle-verification.js.map +1 -1
  31. package/dist/installer/init.d.ts.map +1 -1
  32. package/dist/installer/init.js +2 -1
  33. package/dist/installer/init.js.map +1 -1
  34. package/dist/installer/opencode-config.d.ts +3 -3
  35. package/dist/installer/opencode-config.js +1 -1
  36. package/dist/installer/project-capabilities.d.ts +35 -0
  37. package/dist/installer/project-capabilities.d.ts.map +1 -0
  38. package/dist/installer/project-capabilities.js +82 -0
  39. package/dist/installer/project-capabilities.js.map +1 -0
  40. package/dist/installer/upgrade.d.ts.map +1 -1
  41. package/dist/installer/upgrade.js +9 -1
  42. package/dist/installer/upgrade.js.map +1 -1
  43. package/dist/queue/cli.d.ts.map +1 -1
  44. package/dist/queue/cli.js +32 -5
  45. package/dist/queue/cli.js.map +1 -1
  46. package/dist/queue/executor.d.ts +1 -0
  47. package/dist/queue/executor.d.ts.map +1 -1
  48. package/dist/queue/executor.js +155 -11
  49. package/dist/queue/executor.js.map +1 -1
  50. package/dist/queue/process-ownership.d.ts +12 -0
  51. package/dist/queue/process-ownership.d.ts.map +1 -0
  52. package/dist/queue/process-ownership.js +58 -0
  53. package/dist/queue/process-ownership.js.map +1 -0
  54. package/dist/queue/queue.d.ts +25 -1
  55. package/dist/queue/queue.d.ts.map +1 -1
  56. package/dist/queue/queue.js +152 -11
  57. package/dist/queue/queue.js.map +1 -1
  58. package/dist/queue/service.d.ts.map +1 -1
  59. package/dist/queue/service.js +19 -2
  60. package/dist/queue/service.js.map +1 -1
  61. package/dist/queue/storage.d.ts +3 -1
  62. package/dist/queue/storage.d.ts.map +1 -1
  63. package/dist/queue/storage.js +25 -15
  64. package/dist/queue/storage.js.map +1 -1
  65. package/dist/queue/supervision.d.ts +38 -0
  66. package/dist/queue/supervision.d.ts.map +1 -0
  67. package/dist/queue/supervision.js +247 -0
  68. package/dist/queue/supervision.js.map +1 -0
  69. package/dist/queue/tools.d.ts.map +1 -1
  70. package/dist/queue/tools.js +4 -2
  71. package/dist/queue/tools.js.map +1 -1
  72. package/docs/MIGRATION.md +110 -9
  73. package/docs/QUEUE.md +360 -6
  74. package/docs/SECURITY.md +5 -2
  75. package/docs/TROUBLESHOOTING.md +82 -8
  76. package/package.json +1 -1
  77. package/templates/.opencode/agents/scheduled-coder.md +22 -0
  78. package/templates/.opencode/agents/scheduled-planner.md +73 -2
  79. package/templates/.opencode/skills/scheduled-quality-coder/SKILL.md +34 -2
  80. package/templates/.opencode/skills/scheduled-quality-orchestrator/SKILL.md +65 -4
  81. package/templates/.opencode/skills/scheduled-quality-reviewer/SKILL.md +22 -0
  82. package/templates/AGENTS.md.fragment +10 -4
  83. package/templates/README.md +3 -1
  84. package/templates/automation/config.schema.json +212 -22
  85. package/templates/automation/task-contract.schema.json +617 -21
  86. package/templates/automation/tasks/TASK-TEMPLATE.json.example +31 -3
  87. package/templates/automation/verification/collect.init.gradle +98 -0
  88. package/templates/automation/verification/contract.cjs +93 -0
  89. package/templates/automation/verification/inventory.cjs +433 -0
  90. package/templates/automation/verification/project.cjs +172 -0
  91. package/templates/automation/verification/recovery.cjs +426 -0
  92. package/templates/scripts/automation/acceptance-report.sh +29 -0
  93. package/templates/scripts/automation/claim-task.sh +35 -2
  94. package/templates/scripts/automation/integration-scope-gate.sh +3 -3
  95. package/templates/scripts/automation/lib.sh +167 -17
  96. package/templates/scripts/automation/orchestrate-task.sh +7 -1
  97. package/templates/scripts/automation/preflight.sh +1 -2
  98. package/templates/scripts/automation/quality-gate.sh +10 -1
  99. package/templates/scripts/automation/record-red.sh +182 -32
  100. package/templates/scripts/automation/resume-task.sh +3 -0
  101. package/templates/scripts/automation/scope-gate.sh +4 -4
  102. package/templates/scripts/automation/show-acceptance-review.sh +8 -0
  103. package/templates/scripts/automation/status.sh +38 -0
  104. package/templates/scripts/automation/submit-review.sh +10 -1
  105. package/templates/scripts/automation/validate-contract.sh +56 -3
  106. package/templates/scripts/automation/verify-integration.sh +3 -0
  107. package/templates/scripts/automation/verify-task.sh +17 -0
@@ -109,14 +109,14 @@ automation_validate_config() {
109
109
  def gradle_task:
110
110
  type == "string" and
111
111
  test("^(?:[A-Za-z][A-Za-z0-9_.-]*|(?::[A-Za-z0-9_.-]+)+)$");
112
- def gradle_task_list:
112
+ def optional_gradle_task_list:
113
113
  type == "array" and
114
- length > 0 and
115
114
  length == (unique | length) and
116
115
  all(.[]; gradle_task);
116
+ def gradle_task_list:
117
+ optional_gradle_task_list and length > 0;
117
118
  (.schemaVersion == 5 or .schemaVersion == 6) and
118
119
  ((.commitPolicy // "humanApproval") == "humanApproval" or .commitPolicy == "autoCommit") and
119
- (.workspaceStrategy != "isolatedWorktree" or (.commitPolicy // "humanApproval") == "humanApproval") and
120
120
  (.enabled | type == "boolean") and
121
121
  (.mode == "shadow" or .mode == "orchestrated") and
122
122
  (.workspaceStrategy == "inPlaceExclusive" or .workspaceStrategy == "isolatedWorktree") and
@@ -149,8 +149,9 @@ automation_validate_config() {
149
149
  ($verification.fullUnitTestTasks | gradle_task_list) and
150
150
  ($verification.focusedTestTasks | gradle_task_list) and
151
151
  ($verification.assembleTasks | gradle_task_list) and
152
- ($verification.lintTasks | gradle_task_list) and
153
- ($verification.deviceTestTasks | gradle_task_list)) and
152
+ ($verification.lintTasks | optional_gradle_task_list) and
153
+ ($verification.deviceTestTasks | optional_gradle_task_list)) and
154
+ ((.lintEnabled | not) or (.gradleVerification.lintTasks | length > 0)) and
154
155
  (.androidProject as $project |
155
156
  ($project | type == "object") and
156
157
  ($project.name | type == "string" and length > 0) and
@@ -163,16 +164,19 @@ automation_validate_config() {
163
164
  (.directory | repository_path) and
164
165
  (.buildFile | repository_path) and
165
166
  (.dsl == "kotlin" or .dsl == "groovy") and
166
- (.type == "application" or .type == "library" or .type == "dynamic-feature" or .type == "test" or .type == "asset-pack") and
167
+ (.type == "application" or .type == "library" or .type == "dynamic-feature" or .type == "test" or .type == "asset-pack" or ($project.capabilities.version == 1 and .type == "jvm-library")) and
167
168
  (.namespace == null or (.namespace | type == "string" and length > 0)) and
168
169
  (.applicationId == null or (.applicationId | type == "string" and length > 0)))) and
169
170
  any($project.modules[]; .gradlePath == $project.primaryModule) and
170
171
  ($project.productionPaths | type == "array" and length > 0 and all(.[];
171
- repository_path and test("(^|/)src/main/\\*\\*$"))) and
172
+ repository_path and ($project.capabilities.version == 1 or test("(^|/)src/main/\\*\\*$")))) and
172
173
  ($project.testPaths | type == "array" and length > 0 and all(.[];
173
- repository_path and test("(^|/)src/(?:test|androidTest)/\\*\\*$")))) and
174
+ repository_path and ($project.capabilities.version == 1 or test("(^|/)src/(?:test|androidTest)/\\*\\*$"))))) and
174
175
  (.protectedPaths | type == "array" and length > 0 and all(.[]; repository_path))
175
176
  ' "$AUTOMATION_CONFIG" >/dev/null || automation_die "automation/config.json is invalid"
177
+ if jq -e '.androidProject | has("capabilities")' "$AUTOMATION_CONFIG" >/dev/null; then
178
+ node "$AUTOMATION_ROOT/automation/verification/project.cjs" "$AUTOMATION_CONFIG" "$AUTOMATION_ROOT" || automation_die "Android capability snapshot is invalid"
179
+ fi
176
180
  }
177
181
 
178
182
  automation_require_orchestrated() {
@@ -250,7 +254,7 @@ automation_require_queue_execution() {
250
254
  return 1
251
255
  }
252
256
  if ! worker_pid="$(jq -er --arg key "$queue_key" --arg run "$AUTOMATION_QUEUE_RUN_ID" \
253
- 'select(.active.key == $key and .active.id == $run) | .active.worker.pid' \
257
+ 'select(.active.key == $key and .active.id == $run and (.active.supervision == null or .active.supervision.state == "RUNNING")) | .active.worker.pid' \
254
258
  "$AUTOMATION_RUNTIME_ROOT/inbox/queue.json")"; then
255
259
  automation_die "current process does not own this task queue execution"
256
260
  return 1
@@ -414,14 +418,26 @@ automation_gradle_group_command_json() {
414
418
  automation_validate_config || return 1
415
419
  jq -ce \
416
420
  --arg group "$group" \
417
- '["./gradlew"] + .gradleVerification[$group]' \
421
+ --arg supervised "${AUTOMATION_WORKER_TOKEN:+yes}" \
422
+ '["./gradlew"] + .gradleVerification[$group] + (if $supervised == "yes" then ["--no-daemon"] else [] end)' \
418
423
  "$AUTOMATION_CONFIG"
419
424
  }
420
425
 
426
+ # Keep Git/scope/evidence operations at the repository root. Only Gradle changes
427
+ # cwd, resolving the persisted relative build selection in the current worktree.
428
+ automation_gradle_build_root() {
429
+ local root="${1:-$AUTOMATION_ROOT}"
430
+ if jq -e '.androidProject.capabilities != null' "$AUTOMATION_CONFIG" >/dev/null; then
431
+ node "$AUTOMATION_ROOT/automation/verification/project.cjs" "$AUTOMATION_CONFIG" "$root" --build-root
432
+ else
433
+ printf '%s\n' "$root"
434
+ fi
435
+ }
436
+
421
437
  automation_run_gradle_group() {
422
438
  local group="$1"
423
439
  local root="${2:-$AUTOMATION_ROOT}"
424
- local task
440
+ local task build_root
425
441
  local -a tasks=()
426
442
 
427
443
  automation_validate_gradle_group "$group" || return 1
@@ -440,8 +456,10 @@ automation_run_gradle_group() {
440
456
  return $?
441
457
  fi
442
458
  (
443
- cd "$root"
444
- ./gradlew "${tasks[@]}"
459
+ local build_root
460
+ build_root="$(automation_gradle_build_root "$root")" || exit 1
461
+ cd "$build_root" || exit 1
462
+ ./gradlew "${tasks[@]}" ${AUTOMATION_WORKER_TOKEN:+--no-daemon}
445
463
  )
446
464
  }
447
465
 
@@ -451,7 +469,7 @@ automation_run_gradle_group() {
451
469
  automation_run_fresh_unit_tests() {
452
470
  local root="$1"
453
471
  shift
454
- local init_file log_file result elapsed start
472
+ local init_file log_file result elapsed start build_root
455
473
  mkdir -p "$AUTOMATION_RUNTIME_ROOT/gradle"
456
474
  init_file="$(mktemp "$AUTOMATION_RUNTIME_ROOT/gradle/fresh-tests.XXXXXX")"
457
475
  log_file="${init_file}.log"
@@ -481,7 +499,7 @@ gradle.taskGraph.afterTask { task, state ->
481
499
  GRADLE
482
500
  start="$(date +%s)"
483
501
  set +e
484
- (cd "$root" && ./gradlew "$@" --no-configuration-cache --console=plain --init-script "$init_file") 2>&1 | tee "$log_file"
502
+ (build_root="$(automation_gradle_build_root "$root")" && cd "$build_root" && ./gradlew "$@" ${AUTOMATION_WORKER_TOKEN:+--no-daemon} --no-configuration-cache --console=plain --init-script "$init_file") 2>&1 | tee "$log_file"
485
503
  result=${PIPESTATUS[0]}
486
504
  set -e
487
505
  elapsed=$(( $(date +%s) - start ))
@@ -522,10 +540,14 @@ automation_run_configured_unit_tests() {
522
540
  return 0
523
541
  fi
524
542
 
543
+ if [[ "$(jq -r '.schemaVersion' "$contract")" -ge "4" ]]; then
544
+ automation_run_inventory green "$(jq -er '.id' "$contract")" "$root" || return 1
545
+ else
525
546
  while IFS=$'\t' read -r gradle_task filter; do
526
547
  automation_info "running ${context}focused test ($gradle_task): $filter"
527
548
  automation_run_focused_test "$gradle_task" "$filter" "$root"
528
549
  done < <(jq -r '.targetTests[] | [.gradleTask, .filter] | @tsv' "$contract")
550
+ fi
529
551
 
530
552
  automation_info "running ${context}full unit tests"
531
553
  automation_run_gradle_group "fullUnitTestTasks" "$root"
@@ -561,11 +583,139 @@ automation_run_focused_test() {
561
583
  }
562
584
 
563
585
  (
564
- cd "$root"
565
- ./gradlew "$task" --tests "$filter"
586
+ local build_root
587
+ build_root="$(automation_gradle_build_root "$root")" || exit 1
588
+ cd "$build_root" || exit 1
589
+ ./gradlew "$task" --tests "$filter" ${AUTOMATION_WORKER_TOKEN:+--no-daemon}
566
590
  )
567
591
  }
568
592
 
593
+ # Run one focused target with fresh Test execution and machine-readable case
594
+ # results. Test assertion failures are collected instead of failing Gradle so
595
+ # the caller can distinguish approved RED from build and fixture failures.
596
+ automation_run_classified_focused_test() {
597
+ local task="$1"
598
+ local filter="$2"
599
+ local root="$3"
600
+ local result_file="$4"
601
+ local log_file="$5"
602
+ local init_file status build_root
603
+
604
+ automation_validate_config || return 1
605
+ automation_validate_gradle_task "$task" || return 1
606
+ automation_validate_test_filter "$filter" || return 1
607
+ jq -e --arg task "$task" \
608
+ '.gradleVerification.focusedTestTasks | index($task) != null' \
609
+ "$AUTOMATION_CONFIG" >/dev/null || {
610
+ automation_die "focused Gradle task is not allowed by automation/config.json: $task"
611
+ return 1
612
+ }
613
+
614
+ mkdir -p "$AUTOMATION_RUNTIME_ROOT/gradle" "$(dirname "$result_file")" "$(dirname "$log_file")"
615
+ init_file="$(mktemp "$AUTOMATION_RUNTIME_ROOT/gradle/classified-tests.XXXXXX")"
616
+ : > "$result_file"
617
+ cat > "$init_file" <<'GRADLE'
618
+ import groovy.json.JsonOutput
619
+ import org.gradle.api.tasks.testing.Test
620
+ import org.gradle.api.tasks.testing.TestDescriptor
621
+ import org.gradle.api.tasks.testing.TestListener
622
+ import org.gradle.api.tasks.testing.TestResult
623
+
624
+ def outputPath = System.getProperty('orchestrator.caseResultFile')
625
+ if (outputPath == null || outputPath.isEmpty()) {
626
+ throw new GradleException('orchestrator.caseResultFile is required')
627
+ }
628
+ def outputFile = new File(outputPath)
629
+ def appendResult = { Map value ->
630
+ synchronized (gradle) {
631
+ outputFile << JsonOutput.toJson(value) << System.lineSeparator()
632
+ }
633
+ }
634
+
635
+ gradle.allprojects { project ->
636
+ project.tasks.withType(Test).configureEach { testTask ->
637
+ outputs.upToDateWhen { false }
638
+ outputs.doNotCacheIf('Orchestrator requires fresh classified test execution') { true }
639
+ ignoreFailures = true
640
+ failFast = false
641
+ if (testTask.hasProperty('dryRun')) testTask.dryRun = false
642
+ addTestListener(new TestListener() {
643
+ void beforeSuite(TestDescriptor descriptor) {}
644
+ void beforeTest(TestDescriptor descriptor) {}
645
+ void afterTest(TestDescriptor descriptor, TestResult result) {
646
+ def failure = result.exceptions == null || result.exceptions.isEmpty() ? null : result.exceptions[0]
647
+ appendResult([
648
+ kind: 'case', taskPath: testTask.path,
649
+ className: descriptor.className ?: '', name: descriptor.name ?: '',
650
+ result: result.resultType.toString(),
651
+ exceptionType: failure == null ? null : failure.class.name,
652
+ exceptionMessage: failure == null ? null : (failure.message ?: '')
653
+ ])
654
+ }
655
+ void afterSuite(TestDescriptor descriptor, TestResult result) {
656
+ if (descriptor.parent == null) {
657
+ appendResult([
658
+ kind: 'suite', taskPath: testTask.path,
659
+ tests: result.testCount, failures: result.failedTestCount,
660
+ skipped: result.skippedTestCount,
661
+ result: result.resultType.toString()
662
+ ])
663
+ }
664
+ }
665
+ })
666
+ }
667
+ }
668
+ GRADLE
669
+
670
+ set +e
671
+ (build_root="$(automation_gradle_build_root "$root")" && cd "$build_root" && ./gradlew "$task" --tests "$filter" ${AUTOMATION_WORKER_TOKEN:+--no-daemon} \
672
+ --no-configuration-cache --console=plain --init-script "$init_file" \
673
+ "-Dorchestrator.caseResultFile=$result_file") 2>&1 | tee "$log_file"
674
+ status=${PIPESTATUS[0]}
675
+ set -e
676
+ rm -f "$init_file"
677
+ return "$status"
678
+ }
679
+
680
+ automation_test_diff_sha() {
681
+ local task_id="$1"
682
+ local root="${2:-$AUTOMATION_ROOT}"
683
+ local path tracked=0
684
+ {
685
+ while IFS= read -r path; do
686
+ [[ -n "$path" ]] || continue
687
+ if automation_array_matches_path "$AUTOMATION_CONFIG" '.androidProject.testPaths' "$path"; then
688
+ tracked=1
689
+ if git -C "$root" ls-files --error-unmatch -- "$path" >/dev/null 2>&1; then
690
+ git -C "$root" diff --binary --no-renames HEAD -- "$path"
691
+ else
692
+ printf 'UNTRACKED %s\0' "$path"
693
+ git -C "$root" hash-object -- "$path"
694
+ fi
695
+ fi
696
+ done < <(automation_product_changed_paths_at "$task_id" "$root")
697
+ [[ "$tracked" == "1" ]] || printf 'NO-TEST-CHANGES'
698
+ } | shasum -a 256 | awk '{print $1}'
699
+ }
700
+
701
+ automation_run_inventory() {
702
+ local phase="$1" task_id="$2" root="${3:-$AUTOMATION_ROOT}"
703
+ node "$AUTOMATION_ROOT/automation/verification/inventory.cjs" "$phase" \
704
+ "$(automation_contract_path "$task_id")" "$AUTOMATION_CONFIG" "$root" "$(automation_evidence_path "$task_id")"
705
+ }
706
+
707
+ # Discovery only classifies evidence inputs. Scope gates still enforce the
708
+ # approved paths, forbidden paths, protected files and the file-count limit.
709
+ automation_is_task_test_path() {
710
+ local task_id="$1" path="$2"
711
+ if [[ "$(jq -r '.schemaVersion' "$(automation_contract_path "$task_id")")" -ge "4" ]]; then
712
+ node "$AUTOMATION_ROOT/automation/verification/inventory.cjs" is-test \
713
+ "$(automation_evidence_path "$task_id")/baseline-inventory.json" "$AUTOMATION_CONFIG" "$AUTOMATION_ROOT" "$path"
714
+ else
715
+ automation_array_matches_path "$AUTOMATION_CONFIG" '.androidProject.testPaths' "$path"
716
+ fi
717
+ }
718
+
569
719
  automation_require_approval() {
570
720
  local kind="$1"
571
721
  local supplied="$2"
@@ -63,7 +63,13 @@ run_agent() {
63
63
  return "$exit_code"
64
64
  }
65
65
 
66
- for _step in 1 2 3 4 5 6 7 8; do
66
+ # Each review correction consumes four transitions: resume, code, begin review,
67
+ # and review. Include the initial three transitions, budget-exhaustion
68
+ # transition, and the final state check.
69
+ # The bound must agree with the approved number of review correction cycles.
70
+ max_review_cycles="$(automation_config_value '.maxReviewCycles')"
71
+ max_steps=$((4 * (max_review_cycles + 1) + 1))
72
+ for ((_step = 0; _step < max_steps; _step++)); do
67
73
  if [[ -n "${AUTOMATION_QUEUE_RUN_ID:-}" ]] && jq -e --arg id "$task_id" \
68
74
  'any(.items[]; .taskId == $id and .request.kind == "abort")' \
69
75
  "$AUTOMATION_RUNTIME_ROOT/inbox/queue.json" >/dev/null; then
@@ -34,9 +34,8 @@ automation_require_command shasum
34
34
  if [[ "${AUTOMATION_TEST_MODE:-0}" != "1" ]]; then
35
35
  automation_require_command opencode
36
36
  fi
37
- [[ -x "$AUTOMATION_ROOT/gradlew" ]] || automation_die "gradlew is missing or not executable"
38
-
39
37
  automation_validate_config
38
+ [[ -x "$(automation_gradle_build_root)/gradlew" ]] || automation_die "selected Gradle build wrapper is missing or not executable"
40
39
  workspace_strategy="$(automation_config_value '.workspaceStrategy')"
41
40
  if [[ "$workspace_strategy" == "isolatedWorktree" ]]; then
42
41
  automation_worktree_base >/dev/null
@@ -33,10 +33,19 @@ max_attempts=$((max_fix_loops + 1))
33
33
  attempt_log="$evidence_dir/gate-cycle-$coding_cycle-attempt-$attempt.log"
34
34
  started_at="$(automation_now)"
35
35
  set +e
36
- "$SCRIPT_DIR/verify-task.sh" "$task_id" 2>&1 | tee "$attempt_log"
36
+ if [[ "$(jq -r '.schemaVersion' "$contract")" -ge "7" ]]; then
37
+ node "$AUTOMATION_ROOT/automation/verification/recovery.cjs" --stage green "$contract" "$AUTOMATION_CONFIG" "$AUTOMATION_ROOT" "$evidence_dir" 2>&1 | tee "$attempt_log"
38
+ else
39
+ "$SCRIPT_DIR/verify-task.sh" "$task_id" 2>&1 | tee "$attempt_log"
40
+ fi
37
41
  gate_status=${PIPESTATUS[0]}
38
42
  set -e
39
43
 
44
+ if [[ "$(jq -r '.schemaVersion' "$contract")" -ge "7" && ( "$gate_status" -eq 75 || "$gate_status" -eq 76 ) ]]; then
45
+ automation_transition_state "$task_id" "CODING" "BLOCKED" "quality-gate" "verification stopped; inspect stage-recovery/green.json; implementation budget retained"
46
+ exit "$gate_status"
47
+ fi
48
+
40
49
  jq -n \
41
50
  --arg taskId "$task_id" \
42
51
  --argjson attempts "$attempt" \
@@ -6,51 +6,201 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
6
6
  source "$SCRIPT_DIR/lib.sh"
7
7
 
8
8
  task_id="${1:-}"
9
- expected="${2:-}"
10
- separator="${3:-}"
11
- filter="${4:-}"
12
-
13
- if [[ -z "$task_id" || -z "$expected" || "$separator" != "--" || -z "$filter" || "$#" -ne 4 ]]; then
14
- printf 'Usage: %s TASK-ID EXPECTED-FAILURE-TEXT -- TEST-FILTER\n' "$0" >&2
15
- exit 2
16
- fi
17
-
9
+ [[ -n "$task_id" ]] || { printf 'Usage: %s TASK-ID [LEGACY-EXPECTED -- LEGACY-FILTER]\n' "$0" >&2; exit 2; }
18
10
  automation_validate_task_id "$task_id"
19
11
  automation_require_queue_execution "$task_id"
20
- automation_validate_test_filter "$filter"
21
- [[ ${#expected} -ge 3 ]] || automation_die "expected failure text is too short"
22
12
  [[ "$(automation_read_state "$task_id")" == "CODING" ]] || automation_die "$task_id is not CODING"
23
13
  "$SCRIPT_DIR/validate-contract.sh" "$task_id"
24
14
 
25
15
  contract="$(automation_contract_path "$task_id")"
26
- target_count="$(jq -er --arg filter "$filter" '[.targetTests[] | select(.filter == $filter)] | length' "$contract")"
27
- [[ "$target_count" -eq 1 ]] || automation_die "test filter must identify exactly one declared contract target: $filter"
28
- gradle_task="$(jq -er --arg filter "$filter" '.targetTests[] | select(.filter == $filter) | .gradleTask' "$contract")"
29
-
16
+ schema_version="$(jq -er '.schemaVersion' "$contract")"
30
17
  evidence_dir="$(automation_evidence_path "$task_id")"
31
18
  mkdir -p "$evidence_dir"
32
- red_log="$evidence_dir/red.log"
33
19
  red_meta="$evidence_dir/red.json"
34
20
  [[ ! -e "$red_meta" ]] || automation_die "RED evidence already exists for $task_id"
35
- started_at="$(automation_now)"
36
21
 
37
- set +e
38
- automation_run_focused_test "$gradle_task" "$filter" "$AUTOMATION_ROOT" 2>&1 | tee "$red_log"
39
- red_status=${PIPESTATUS[0]}
40
- set -e
22
+ if [[ "$schema_version" -ge "4" ]]; then
23
+ [[ "$#" -eq 1 ]] || automation_die "Schema V4/V5 usage: record-red.sh TASK-ID"
24
+ "$SCRIPT_DIR/scope-gate.sh" "$task_id" >/dev/null
25
+ if [[ "$schema_version" -ge "7" ]]; then
26
+ node "$AUTOMATION_ROOT/automation/verification/recovery.cjs" --stage red "$contract" "$AUTOMATION_CONFIG" "$AUTOMATION_ROOT" "$evidence_dir"
27
+ exit $?
28
+ fi
29
+ automation_run_inventory red "$task_id"
30
+ exit 0
31
+ fi
32
+
33
+ # Preserve the exact V1/V2 behavior for already approved contracts.
34
+ if [[ "$schema_version" != "3" ]]; then
35
+ expected="${2:-}"
36
+ separator="${3:-}"
37
+ filter="${4:-}"
38
+ if [[ -z "$expected" || "$separator" != "--" || -z "$filter" || "$#" -ne 4 ]]; then
39
+ printf 'Usage: %s TASK-ID EXPECTED-FAILURE-TEXT -- TEST-FILTER\n' "$0" >&2
40
+ exit 2
41
+ fi
42
+ automation_validate_test_filter "$filter"
43
+ [[ ${#expected} -ge 3 ]] || automation_die "expected failure text is too short"
44
+ target_count="$(jq -er --arg filter "$filter" '[.targetTests[] | select(.filter == $filter)] | length' "$contract")"
45
+ [[ "$target_count" -eq 1 ]] || automation_die "test filter must identify exactly one declared contract target: $filter"
46
+ gradle_task="$(jq -er --arg filter "$filter" '.targetTests[] | select(.filter == $filter) | .gradleTask' "$contract")"
47
+ red_log="$evidence_dir/red.log"
48
+ started_at="$(automation_now)"
49
+ set +e
50
+ automation_run_focused_test "$gradle_task" "$filter" "$AUTOMATION_ROOT" 2>&1 | tee "$red_log"
51
+ red_status=${PIPESTATUS[0]}
52
+ set -e
53
+ [[ "$red_status" -ne 0 ]] || automation_die "RED capture failed: focused test passed before implementation"
54
+ rg -F "$expected" "$red_log" >/dev/null || automation_die "RED output does not contain the expected failure text"
55
+ jq -n --arg taskId "$task_id" --arg startedAt "$started_at" --arg finishedAt "$(automation_now)" \
56
+ --arg expectedFailure "$expected" --arg gradleTask "$gradle_task" --arg testFilter "$filter" \
57
+ --arg cwd "$(automation_gradle_build_root)" \
58
+ --argjson exitCode "$red_status" \
59
+ '{taskId: $taskId, startedAt: $startedAt, finishedAt: $finishedAt,
60
+ command: ["./gradlew", $gradleTask, "--tests", $testFilter], cwd: $cwd,
61
+ expectedFailure: $expectedFailure, exitCode: $exitCode}' | automation_record_json "$red_meta"
62
+ automation_info "$task_id legacy RED evidence recorded"
63
+ exit 0
64
+ fi
41
65
 
42
- [[ "$red_status" -ne 0 ]] || automation_die "RED capture failed: focused test passed before implementation"
43
- rg -F "$expected" "$red_log" >/dev/null || automation_die "RED output does not contain the expected failure text"
66
+ [[ "$#" -eq 1 ]] || { printf 'Schema V3 usage: %s TASK-ID\n' "$0" >&2; exit 2; }
67
+ "$SCRIPT_DIR/scope-gate.sh" "$task_id" >/dev/null
68
+
69
+ # RED must be captured before any production change. Planning artifacts are
70
+ # excluded; test sources and fixtures are the only permitted product changes.
71
+ while IFS= read -r changed_path; do
72
+ [[ -n "$changed_path" ]] || continue
73
+ if ! automation_array_matches_path "$AUTOMATION_CONFIG" '.androidProject.testPaths' "$changed_path"; then
74
+ automation_die "RED preflight requires unchanged production code; non-test path changed: $changed_path"
75
+ fi
76
+ done < <(automation_product_changed_paths_at "$task_id" "$AUTOMATION_ROOT")
77
+
78
+ previous_attempt=0
79
+ preflight_meta="$evidence_dir/test-preflight.json"
80
+ if [[ -f "$preflight_meta" ]]; then
81
+ previous_attempt="$(jq -er '.attempt' "$preflight_meta")"
82
+ fi
83
+ attempt=$((previous_attempt + 1))
84
+ max_fixes="$(jq -er '.verification.maxPreparationFixes' "$contract")"
85
+ if [[ "$attempt" -gt $((max_fixes + 1)) ]]; then
86
+ automation_die "test preparation retry budget exhausted; revise the contract or abort the task"
87
+ fi
88
+
89
+ attempt_id="$(printf '%03d' "$attempt")"
90
+ attempt_dir="$evidence_dir/attempts/red-preflight-$attempt_id"
91
+ mkdir -p "$attempt_dir"
92
+ observed_file="$attempt_dir/observed.jsonl"
93
+ : > "$observed_file"
94
+ execution_failures='[]'
95
+ started_at="$(automation_now)"
96
+ target_index=0
97
+ while IFS=$'\t' read -r gradle_task filter; do
98
+ result_file="$attempt_dir/target-$target_index.jsonl"
99
+ log_file="$attempt_dir/target-$target_index.log"
100
+ set +e
101
+ automation_run_classified_focused_test "$gradle_task" "$filter" "$AUTOMATION_ROOT" "$result_file" "$log_file"
102
+ gradle_status=$?
103
+ set -e
104
+ if [[ "$gradle_status" -ne 0 ]]; then
105
+ execution_failures="$(jq -nc --argjson current "$execution_failures" --argjson target "$target_index" \
106
+ --arg task "$gradle_task" --arg filter "$filter" --argjson exitCode "$gradle_status" \
107
+ '$current + [{target: $target, gradleTask: $task, filter: $filter, exitCode: $exitCode}]')"
108
+ fi
109
+ if [[ -s "$result_file" ]]; then
110
+ while IFS= read -r result_line; do
111
+ if ! jq -e . >/dev/null 2>&1 <<< "$result_line"; then
112
+ execution_failures="$(jq -nc --argjson current "$execution_failures" --argjson target "$target_index" \
113
+ '$current + [{target: $target, reason: "invalid structured test output"}]')"
114
+ continue
115
+ fi
116
+ jq -c --argjson target "$target_index" '. + {target: $target}' <<< "$result_line" >> "$observed_file"
117
+ done < "$result_file"
118
+ else
119
+ execution_failures="$(jq -nc --argjson current "$execution_failures" --argjson target "$target_index" \
120
+ '$current + [{target: $target, reason: "no structured test results"}]')"
121
+ fi
122
+ target_index=$((target_index + 1))
123
+ done < <(jq -r '.targetTests[] | [.gradleTask, .filter] | @tsv' "$contract")
124
+
125
+ contract_sha="$(automation_file_sha256 "$contract")"
126
+ baseline_head="$(jq -er '.head' "$evidence_dir/baseline.json")"
127
+ test_diff_sha="$(automation_test_diff_sha "$task_id" "$AUTOMATION_ROOT")"
128
+ finished_at="$(automation_now)"
44
129
 
45
130
  jq -n \
131
+ --slurpfile contract "$contract" \
132
+ --slurpfile observed "$observed_file" \
46
133
  --arg taskId "$task_id" \
47
134
  --arg startedAt "$started_at" \
48
- --arg finishedAt "$(automation_now)" \
49
- --arg expectedFailure "$expected" \
50
- --arg gradleTask "$gradle_task" \
51
- --arg testFilter "$filter" \
52
- --argjson exitCode "$red_status" \
53
- '{taskId: $taskId, startedAt: $startedAt, finishedAt: $finishedAt, command: ["./gradlew", $gradleTask, "--tests", $testFilter], expectedFailure: $expectedFailure, exitCode: $exitCode}' \
54
- | automation_record_json "$red_meta"
55
-
56
- automation_info "$task_id RED evidence recorded"
135
+ --arg finishedAt "$finished_at" \
136
+ --arg contractSha256 "$contract_sha" \
137
+ --arg baselineHead "$baseline_head" \
138
+ --arg testDiffSha256 "$test_diff_sha" \
139
+ --argjson attempt "$attempt" \
140
+ --argjson executionFailures "$execution_failures" '
141
+ def failure_matches($decl; $actual):
142
+ ($actual.result == "FAILURE") and
143
+ ($actual.exceptionType == $decl.expectedFailure.type) and
144
+ (($decl.expectedFailure.messageIncludes // "") as $fragment |
145
+ ($fragment == "" or (($actual.exceptionMessage // "") | contains($fragment))));
146
+ ($contract[0].verification.cases | map(
147
+ . as $decl |
148
+ [$observed[] | select(.kind == "case" and .target == $decl.test.target and
149
+ .className == $decl.test.className and .name == $decl.test.name)] as $matches |
150
+ (if ($matches | length) == 1 then $matches[0] else null end) as $actual |
151
+ (if ($matches | length) != 1 then false
152
+ elif $decl.before == "pass" then $actual.result == "SUCCESS"
153
+ elif $decl.before == "fail" then failure_matches($decl; $actual)
154
+ elif $actual.result == "SUCCESS" then true
155
+ elif ($decl | has("expectedFailure")) then failure_matches($decl; $actual)
156
+ else false end) as $valid |
157
+ {id: $decl.id, criterion: $decl.criterion, intent: $decl.intent,
158
+ expectedBefore: $decl.before, test: $decl.test, expectedFailure: ($decl.expectedFailure // null),
159
+ matches: ($matches | length), actual: $actual, valid: $valid}
160
+ )) as $cases |
161
+ [$observed[] | select(.kind == "case") as $actual |
162
+ select(any($contract[0].verification.cases[];
163
+ .test.target == $actual.target and .test.className == $actual.className and .test.name == $actual.name) | not)] as $undeclared |
164
+ [$observed[] | select(.kind == "suite")] as $suites |
165
+ ($executionFailures | length == 0 and
166
+ ($suites | length) >= ($contract[0].targetTests | length) and
167
+ ($cases | all(.valid)) and ($undeclared | length == 0) and
168
+ ($cases | any(.intent == "change" and .actual.result == "FAILURE"))) as $valid |
169
+ {taskId: $taskId, attempt: $attempt, startedAt: $startedAt, finishedAt: $finishedAt,
170
+ contractSha256: $contractSha256, baselineHead: $baselineHead,
171
+ testDiffSha256: $testDiffSha256, valid: $valid,
172
+ reasonCode: (if $valid then "VALID_RED"
173
+ elif ($executionFailures | length) > 0 then "EXECUTION_FAILURE"
174
+ elif ($undeclared | length) > 0 then "UNDECLARED_TEST_RESULT"
175
+ else "CASE_EXPECTATION_MISMATCH" end),
176
+ summary: {declared: ($cases | length), valid: ([$cases[] | select(.valid)] | length),
177
+ invalid: ([$cases[] | select(.valid | not)] | length),
178
+ expectedRed: ([$cases[] | select(.intent == "change" and .actual.result == "FAILURE")] | length),
179
+ undeclared: ($undeclared | length)},
180
+ cases: $cases, undeclaredCases: $undeclared, suites: $suites,
181
+ executionFailures: $executionFailures}' | automation_record_json "$preflight_meta"
182
+
183
+ cp "$preflight_meta" "$attempt_dir/evaluation.json"
184
+ if [[ "$(jq -r '.valid' "$preflight_meta")" != "true" ]]; then
185
+ reason="$(jq -r '.reasonCode' "$preflight_meta")"
186
+ invalid="$(jq -r '[.cases[] | select(.valid | not) | .id] | join(", ")' "$preflight_meta")"
187
+ automation_die "RED preflight rejected ($reason); invalid cases: ${invalid:-none}; evidence: $preflight_meta"
188
+ fi
189
+
190
+ jq '{taskId, contractSha256, baselineHead, testDiffSha256,
191
+ cases: [.cases[] | {id, criterion, intent, test, actual}]}' "$preflight_meta" \
192
+ | automation_record_json "$evidence_dir/test-manifest.json"
193
+ preflight_sha="$(automation_file_sha256 "$preflight_meta")"
194
+ manifest_sha="$(automation_file_sha256 "$evidence_dir/test-manifest.json")"
195
+ jq -n --arg taskId "$task_id" --arg startedAt "$started_at" --arg finishedAt "$finished_at" \
196
+ --arg contractSha256 "$contract_sha" --arg baselineHead "$baseline_head" \
197
+ --arg testDiffSha256 "$test_diff_sha" --arg preflightSha256 "$preflight_sha" \
198
+ --arg manifestSha256 "$manifest_sha" --arg attemptPath "attempts/red-preflight-$attempt_id" \
199
+ --argjson attempt "$attempt" \
200
+ '{taskId: $taskId, schemaVersion: 3, startedAt: $startedAt, finishedAt: $finishedAt,
201
+ contractSha256: $contractSha256, baselineHead: $baselineHead,
202
+ testDiffSha256: $testDiffSha256, preflightSha256: $preflightSha256,
203
+ manifestSha256: $manifestSha256, attempt: $attempt, attemptPath: $attemptPath,
204
+ structuredCasesVerified: true, exitCode: 1}' | automation_record_json "$red_meta"
205
+
206
+ automation_info "$task_id structured RED evidence recorded"
@@ -55,6 +55,9 @@ source_root="$(cd "$source_root" && pwd -P)"
55
55
  git -C "$source_root" show-ref --verify --quiet "refs/heads/$original_branch" || automation_die "recorded original branch no longer exists"
56
56
  [[ "$(git -C "$source_root" rev-parse "refs/heads/$original_branch")" == "$baseline_head" ]] || automation_die "original branch drifted after task preparation"
57
57
 
58
+ if [[ "$(jq -r '.schemaVersion' "$(automation_contract_path "$task_id")")" == "4" && -e "$evidence_dir/baseline.json" ]]; then
59
+ automation_die "V4 baseline.json already exists; /resume-task cannot retry inventory capture. Inspect the retained attempt, request approved abort/archive, then approve a new task; do not delete or overwrite baseline evidence."
60
+ fi
58
61
  for sealed_evidence in baseline.json red.json ready.json review.json reviews.jsonl; do
59
62
  [[ ! -e "$evidence_dir/$sealed_evidence" ]] || \
60
63
  automation_die "$sealed_evidence already exists; baseline-only recovery is forbidden"
@@ -66,7 +66,7 @@ while IFS= read -r path; do
66
66
  if automation_array_matches_path "$AUTOMATION_CONFIG" '.androidProject.productionPaths' "$path"; then
67
67
  production_changed=1
68
68
  fi
69
- if automation_array_matches_path "$AUTOMATION_CONFIG" '.androidProject.testPaths' "$path"; then
69
+ if automation_is_task_test_path "$task_id" "$path"; then
70
70
  test_changed=1
71
71
  fi
72
72
  done <<< "$changed_file_list"
@@ -75,7 +75,7 @@ deleted_tests="$(
75
75
  while IFS= read -r path; do
76
76
  [[ -n "$path" ]] || continue
77
77
  if [[ -n "$(git -C "$AUTOMATION_ROOT" diff --no-renames --name-only --diff-filter=D HEAD -- "$path")" ]] && \
78
- automation_array_matches_path "$AUTOMATION_CONFIG" '.androidProject.testPaths' "$path"; then
78
+ automation_is_task_test_path "$task_id" "$path"; then
79
79
  printf '%s\n' "$path"
80
80
  fi
81
81
  done <<< "$changed_file_list"
@@ -91,7 +91,7 @@ fi
91
91
  added_test_lines="$(
92
92
  while IFS= read -r path; do
93
93
  [[ -n "$path" ]] || continue
94
- if automation_array_matches_path "$AUTOMATION_CONFIG" '.androidProject.testPaths' "$path"; then
94
+ if automation_is_task_test_path "$task_id" "$path"; then
95
95
  git -C "$AUTOMATION_ROOT" diff --no-renames --unified=0 HEAD -- "$path"
96
96
  fi
97
97
  done <<< "$changed_file_list" \
@@ -106,7 +106,7 @@ fi
106
106
  while IFS= read -r untracked_test; do
107
107
  [[ -n "$untracked_test" ]] || continue
108
108
  if ! git -C "$AUTOMATION_ROOT" ls-files --error-unmatch -- "$untracked_test" >/dev/null 2>&1 && \
109
- automation_array_matches_path "$AUTOMATION_CONFIG" '.androidProject.testPaths' "$untracked_test" && \
109
+ automation_is_task_test_path "$task_id" "$untracked_test" && \
110
110
  rg -n "$weakening_pattern" "$AUTOMATION_ROOT/$untracked_test" >/dev/null; then
111
111
  automation_die "potential test weakening detected in new test: $untracked_test"
112
112
  exit 40
@@ -79,6 +79,13 @@ jq -r '.nonGoals[] | "- \(.)"' "$report_file"
79
79
 
80
80
  printf '\n### P1 · 自动证据是否可信\n\n'
81
81
  printf -- '- RED 退出码:`%s`(应为非 0,证明测试先真实失败)\n' "$(jq -r '.evidence.redExitCode' "$report_file")"
82
+ if [[ "$(jq -r '.evidence.structuredRed != null' "$report_file")" == "true" ]]; then
83
+ printf -- '- 结构化 RED:声明 `%s` 个,用例匹配 `%s` 个,额外用例 `%s` 个\n' \
84
+ "$(jq -r '.evidence.structuredRed.summary.declared' "$report_file")" \
85
+ "$(jq -r '.evidence.structuredRed.summary.valid' "$report_file")" \
86
+ "$(jq -r '.evidence.structuredRed.summary.undeclared' "$report_file")"
87
+ jq -r '.evidence.structuredRed.cases[] | " - `\(.id)` · \(.intent) · 修改前应 \(.expectedBefore) · 已核验 \(.valid)"' "$report_file"
88
+ fi
82
89
  printf -- '- 质量门:`%s`,第 %s 个编码周期内共运行 %s 次\n' \
83
90
  "$(jq -r '.evidence.qualityGate' "$report_file")" \
84
91
  "$(jq -r '.evidence.codingCycle' "$report_file")" \
@@ -88,6 +95,7 @@ printf -- '- Reviewer:`%s`,独立复验退出码 `%s`\n' \
88
95
  "$(jq -r '.evidence.reviewerVerificationExitCode' "$report_file")"
89
96
  printf -- '- Reviewer 摘要:%s\n' "$(jq -r '.reviewSummary' "$report_file")"
90
97
  printf -- '- 聚焦测试:\n'
98
+ printf -- ' - Gradle 工作目录:`%s`\n' "$(automation_gradle_build_root)"
91
99
  jq -r '.targetTests[] | " - `./gradlew \(.gradleTask) --tests \(.filter)`"' "$report_file"
92
100
 
93
101
  printf '\n### P1 · 绑定与剩余风险\n\n'