@frankzhang2026/opencode-android-orchestrator 0.2.0 → 0.4.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 (62) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/README.md +85 -43
  3. package/THIRD_PARTY_NOTICES.md +1 -1
  4. package/dist/cli.js +127 -10
  5. package/dist/cli.js.map +1 -1
  6. package/dist/doctor/installation.d.ts.map +1 -1
  7. package/dist/doctor/installation.js +29 -4
  8. package/dist/doctor/installation.js.map +1 -1
  9. package/dist/index.d.ts +2 -2
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +2 -2
  12. package/dist/index.js.map +1 -1
  13. package/dist/installer/adaptive-templates.d.ts +25 -3
  14. package/dist/installer/adaptive-templates.d.ts.map +1 -1
  15. package/dist/installer/adaptive-templates.js +75 -9
  16. package/dist/installer/adaptive-templates.js.map +1 -1
  17. package/dist/installer/index.d.ts +2 -2
  18. package/dist/installer/index.d.ts.map +1 -1
  19. package/dist/installer/index.js +2 -2
  20. package/dist/installer/index.js.map +1 -1
  21. package/dist/installer/init.d.ts +2 -1
  22. package/dist/installer/init.d.ts.map +1 -1
  23. package/dist/installer/init.js +11 -2
  24. package/dist/installer/init.js.map +1 -1
  25. package/dist/installer/opencode-config.d.ts +4 -3
  26. package/dist/installer/opencode-config.d.ts.map +1 -1
  27. package/dist/installer/opencode-config.js +10 -4
  28. package/dist/installer/opencode-config.js.map +1 -1
  29. package/dist/installer/upgrade.d.ts +5 -0
  30. package/dist/installer/upgrade.d.ts.map +1 -1
  31. package/dist/installer/upgrade.js +44 -10
  32. package/dist/installer/upgrade.js.map +1 -1
  33. package/docs/MIGRATION.md +25 -14
  34. package/docs/SECURITY.md +29 -5
  35. package/docs/TROUBLESHOOTING.md +25 -6
  36. package/package.json +1 -1
  37. package/templates/.opencode/agents/scheduled-coder.md +3 -0
  38. package/templates/.opencode/agents/scheduled-reviewer.md +5 -4
  39. package/templates/.opencode/skills/scheduled-quality-coder/SKILL.md +10 -6
  40. package/templates/.opencode/skills/scheduled-quality-reviewer/SKILL.md +7 -4
  41. package/templates/AGENTS.md.fragment +4 -0
  42. package/templates/README.md +5 -2
  43. package/templates/automation/config.json +18 -0
  44. package/templates/automation/config.schema.json +31 -0
  45. package/templates/automation/task-contract.schema.json +22 -1
  46. package/templates/automation/tasks/TASK-TEMPLATE.json.example +5 -1
  47. package/templates/scripts/automation/abort-task.sh +5 -10
  48. package/templates/scripts/automation/accept-and-integrate.sh +1 -1
  49. package/templates/scripts/automation/acceptance-report.sh +2 -9
  50. package/templates/scripts/automation/approve-and-run.sh +3 -0
  51. package/templates/scripts/automation/claim-task.sh +6 -6
  52. package/templates/scripts/automation/lib.sh +403 -8
  53. package/templates/scripts/automation/orchestrate-task.sh +1 -1
  54. package/templates/scripts/automation/preflight.sh +1 -1
  55. package/templates/scripts/automation/record-red.sh +7 -9
  56. package/templates/scripts/automation/scope-gate.sh +12 -9
  57. package/templates/scripts/automation/show-acceptance-review.sh +1 -1
  58. package/templates/scripts/automation/status.sh +4 -0
  59. package/templates/scripts/automation/tests/run-tests.sh +164 -9
  60. package/templates/scripts/automation/validate-contract.sh +16 -5
  61. package/templates/scripts/automation/verify-integration.sh +12 -11
  62. package/templates/scripts/automation/verify-task.sh +13 -28
@@ -47,6 +47,9 @@ AUTOMATION_STATE_DIR="$AUTOMATION_RUNTIME_ROOT/state"
47
47
  AUTOMATION_EVIDENCE_DIR="$AUTOMATION_RUNTIME_ROOT/evidence"
48
48
  AUTOMATION_LOCKS_DIR="$AUTOMATION_RUNTIME_ROOT/locks"
49
49
  AUTOMATION_WORKSPACES_DIR="$AUTOMATION_RUNTIME_ROOT/workspaces"
50
+ AUTOMATION_WORKTREE_ALLOWLIST_RELATIVE_PATH=".automation-worktree-allowlist"
51
+ AUTOMATION_WORKTREE_ALLOWLIST_MAX_BYTES=65536
52
+ AUTOMATION_WORKTREE_ALLOWLIST_MAX_ENTRIES=256
50
53
 
51
54
  automation_info() {
52
55
  printf '[automation] %s\n' "$*"
@@ -69,6 +72,23 @@ automation_require_command() {
69
72
  command -v "$1" >/dev/null 2>&1 || automation_die "required command is missing: $1"
70
73
  }
71
74
 
75
+ automation_resolve_opencode_config() {
76
+ local output_file="$1"
77
+ local error_file="$2"
78
+ local checkpoint_error="Failed to run the query 'PRAGMA wal_checkpoint(PASSIVE)'"
79
+
80
+ if opencode debug config > "$output_file" 2> "$error_file"; then
81
+ return 0
82
+ fi
83
+ if ! rg -F "$checkpoint_error" "$error_file" >/dev/null; then
84
+ return 1
85
+ fi
86
+
87
+ automation_warn "OpenCode database checkpoint failed once; retrying resolved config discovery"
88
+ sleep 1
89
+ opencode debug config > "$output_file" 2> "$error_file"
90
+ }
91
+
72
92
  automation_require_layout() {
73
93
  [[ -f "$AUTOMATION_CONFIG" ]] || automation_die "missing $AUTOMATION_CONFIG"
74
94
  [[ -d "$AUTOMATION_TASKS_DIR" ]] || automation_die "missing $AUTOMATION_TASKS_DIR"
@@ -83,6 +103,14 @@ automation_validate_config() {
83
103
  (startswith("/") | not) and
84
104
  (test("^[A-Za-z]:[/\\\\]") | not) and
85
105
  (test("(^|/)\\.\\.(/|$)") | not);
106
+ def gradle_task:
107
+ type == "string" and
108
+ test("^(?:[A-Za-z][A-Za-z0-9_.-]*|(?::[A-Za-z0-9_.-]+)+)$");
109
+ def gradle_task_list:
110
+ type == "array" and
111
+ length > 0 and
112
+ length == (unique | length) and
113
+ all(.[]; gradle_task);
86
114
  .schemaVersion == 3 and
87
115
  (.enabled | type == "boolean") and
88
116
  (.mode == "shadow" or .mode == "orchestrated") and
@@ -101,11 +129,26 @@ automation_validate_config() {
101
129
  (.plugins | type == "object" and keys == ["superpowers"]) and
102
130
  (.plugins.superpowers | type == "string" and length > 0) and
103
131
  (.requiredSkills | type == "array" and length >= 6) and
132
+ (.gradleVerification as $verification |
133
+ ($verification | type == "object") and
134
+ ($verification | keys == [
135
+ "assembleTasks",
136
+ "deviceTestTasks",
137
+ "focusedTestTasks",
138
+ "fullUnitTestTasks",
139
+ "lintTasks"
140
+ ]) and
141
+ ($verification.fullUnitTestTasks | gradle_task_list) and
142
+ ($verification.focusedTestTasks | gradle_task_list) and
143
+ ($verification.assembleTasks | gradle_task_list) and
144
+ ($verification.lintTasks | gradle_task_list) and
145
+ ($verification.deviceTestTasks | gradle_task_list)) and
104
146
  (.androidProject as $project |
105
147
  ($project | type == "object") and
106
148
  ($project.name | type == "string" and length > 0) and
107
149
  ($project.gradleDsl == "kotlin" or $project.gradleDsl == "groovy" or $project.gradleDsl == "mixed") and
108
150
  ($project.settingsFile | repository_path) and
151
+ ((($project | has("moduleScope")) | not) or $project.moduleScope == "all" or $project.moduleScope == "primary") and
109
152
  ($project.primaryModule | type == "string" and test("^:(?:[A-Za-z0-9_.-]+(?::[A-Za-z0-9_.-]+)*)?$")) and
110
153
  ($project.modules | type == "array" and length > 0 and all(.[];
111
154
  (.gradlePath | type == "string" and test("^:(?:[A-Za-z0-9_.-]+(?::[A-Za-z0-9_.-]+)*)?$")) and
@@ -201,6 +244,84 @@ automation_config_value() {
201
244
  jq -r "$query" "$AUTOMATION_CONFIG"
202
245
  }
203
246
 
247
+ automation_validate_gradle_task() {
248
+ local task="${1:-}"
249
+ local safe_task_pattern='^([A-Za-z][A-Za-z0-9_.-]*|(:[A-Za-z0-9_.-]+)+)$'
250
+ [[ "$task" =~ $safe_task_pattern ]] || automation_die "unsafe Gradle task: ${task:-<empty>}"
251
+ }
252
+
253
+ automation_validate_test_filter() {
254
+ local filter="${1:-}"
255
+ local safe_filter_pattern='^[A-Za-z0-9_.#$*-]+$'
256
+ [[ "$filter" =~ $safe_filter_pattern ]] || automation_die "unsafe test filter: ${filter:-<empty>}"
257
+ }
258
+
259
+ automation_validate_gradle_group() {
260
+ local group="${1:-}"
261
+ case "$group" in
262
+ fullUnitTestTasks|focusedTestTasks|assembleTasks|lintTasks|deviceTestTasks) ;;
263
+ *)
264
+ automation_die "unknown Gradle verification group: ${group:-<empty>}"
265
+ return 1
266
+ ;;
267
+ esac
268
+ }
269
+
270
+ automation_gradle_group_command_json() {
271
+ local group="$1"
272
+ automation_validate_gradle_group "$group" || return 1
273
+ automation_validate_config || return 1
274
+ jq -ce \
275
+ --arg group "$group" \
276
+ '["./gradlew"] + .gradleVerification[$group]' \
277
+ "$AUTOMATION_CONFIG"
278
+ }
279
+
280
+ automation_run_gradle_group() {
281
+ local group="$1"
282
+ local root="${2:-$AUTOMATION_ROOT}"
283
+ local task
284
+ local -a tasks=()
285
+
286
+ automation_validate_gradle_group "$group" || return 1
287
+ automation_validate_config || return 1
288
+ while IFS= read -r task; do
289
+ automation_validate_gradle_task "$task" || return 1
290
+ tasks[${#tasks[@]}]="$task"
291
+ done < <(jq -er --arg group "$group" '.gradleVerification[$group][]' "$AUTOMATION_CONFIG")
292
+ [[ "${#tasks[@]}" -gt 0 ]] || {
293
+ automation_die "Gradle verification group is empty: $group"
294
+ return 1
295
+ }
296
+
297
+ (
298
+ cd "$root"
299
+ ./gradlew "${tasks[@]}"
300
+ )
301
+ }
302
+
303
+ automation_run_focused_test() {
304
+ local task="$1"
305
+ local filter="$2"
306
+ local root="${3:-$AUTOMATION_ROOT}"
307
+
308
+ automation_validate_config || return 1
309
+ automation_validate_gradle_task "$task" || return 1
310
+ automation_validate_test_filter "$filter" || return 1
311
+ jq -e \
312
+ --arg task "$task" \
313
+ '.gradleVerification.focusedTestTasks | index($task) != null' \
314
+ "$AUTOMATION_CONFIG" >/dev/null || {
315
+ automation_die "focused Gradle task is not allowed by automation/config.json: $task"
316
+ return 1
317
+ }
318
+
319
+ (
320
+ cd "$root"
321
+ ./gradlew "$task" --tests "$filter"
322
+ )
323
+ }
324
+
204
325
  automation_require_approval() {
205
326
  local kind="$1"
206
327
  local supplied="$2"
@@ -265,12 +386,235 @@ automation_array_matches_path() {
265
386
  return 1
266
387
  }
267
388
 
389
+ automation_validate_worktree_allowlist_entry_at() {
390
+ local root="$1"
391
+ local path="$2"
392
+ local config_file="$root/automation/config.json"
393
+ local protected
394
+
395
+ if [[ -z "$path" ]]; then
396
+ automation_die "worktree allowlist entries must not be empty"
397
+ return 1
398
+ fi
399
+ if [[ "$path" == [[:space:]]* || "$path" == *[[:space:]] ]]; then
400
+ automation_die "worktree allowlist entries must not have leading or trailing whitespace: $path"
401
+ return 1
402
+ fi
403
+ case "$path" in
404
+ *[[:cntrl:]]*|*'\\'*)
405
+ automation_die "worktree allowlist entries must use plain repository-relative paths: $path"
406
+ return 1
407
+ ;;
408
+ *'*'*|*'?'*|*'['*|*']'*)
409
+ automation_die "worktree allowlist entries must be exact file paths, not patterns: $path"
410
+ return 1
411
+ ;;
412
+ esac
413
+ case "/$path/" in
414
+ *'//'*|*'/./'*|*'/../'*)
415
+ automation_die "worktree allowlist entries must be normalized repository-relative paths: $path"
416
+ return 1
417
+ ;;
418
+ esac
419
+ case "$path" in
420
+ /*|./*|.|..|.git|.git/*|docs/plans|docs/plans/*|"$AUTOMATION_WORKTREE_ALLOWLIST_RELATIVE_PATH")
421
+ automation_die "worktree allowlist entry is reserved or unsafe: $path"
422
+ return 1
423
+ ;;
424
+ esac
425
+ if [[ -d "$root/$path" ]]; then
426
+ automation_die "worktree allowlist entries must identify files, not directories: $path"
427
+ return 1
428
+ fi
429
+
430
+ if [[ ! -f "$config_file" ]]; then
431
+ automation_die "missing automation configuration while validating worktree allowlist: $config_file"
432
+ return 1
433
+ fi
434
+ if ! jq -e '.protectedPaths | type == "array" and all(.[]; type == "string" and length > 0)' \
435
+ "$config_file" >/dev/null; then
436
+ automation_die "automation protectedPaths are invalid while validating the worktree allowlist"
437
+ return 1
438
+ fi
439
+ while IFS= read -r protected; do
440
+ if automation_path_matches "$path" "$protected"; then
441
+ automation_die "worktree allowlist must not include a protected path: $path"
442
+ return 1
443
+ fi
444
+ done < <(jq -r '.protectedPaths[]' "$config_file")
445
+ return 0
446
+ }
447
+
448
+ automation_worktree_allowlist_file_entries_at() {
449
+ local root="$1"
450
+ local allowlist_file="$root/$AUTOMATION_WORKTREE_ALLOWLIST_RELATIVE_PATH"
451
+ local byte_count line existing
452
+ local entry_count=0
453
+ local -a entries=()
454
+
455
+ if [[ ! -e "$allowlist_file" && ! -L "$allowlist_file" ]]; then
456
+ return 0
457
+ fi
458
+ if [[ -L "$allowlist_file" || ! -f "$allowlist_file" ]]; then
459
+ automation_die "worktree allowlist must be a regular file, not a symlink: $allowlist_file"
460
+ return 1
461
+ fi
462
+ byte_count="$(wc -c < "$allowlist_file" | tr -d '[:space:]')"
463
+ if [[ ! "$byte_count" =~ ^[0-9]+$ ]] || \
464
+ [[ "$byte_count" -gt "$AUTOMATION_WORKTREE_ALLOWLIST_MAX_BYTES" ]]; then
465
+ automation_die "worktree allowlist exceeds $AUTOMATION_WORKTREE_ALLOWLIST_MAX_BYTES bytes: $allowlist_file"
466
+ return 1
467
+ fi
468
+
469
+ while IFS= read -r line || [[ -n "$line" ]]; do
470
+ line="${line%$'\r'}"
471
+ [[ -n "$line" && "$line" != \#* ]] || continue
472
+ automation_validate_worktree_allowlist_entry_at "$root" "$line" || return 1
473
+ if [[ "$entry_count" -gt 0 ]]; then
474
+ for existing in "${entries[@]}"; do
475
+ if [[ "$existing" == "$line" ]]; then
476
+ automation_die "duplicate worktree allowlist entry: $line"
477
+ return 1
478
+ fi
479
+ done
480
+ fi
481
+ entry_count=$((entry_count + 1))
482
+ if [[ "$entry_count" -gt "$AUTOMATION_WORKTREE_ALLOWLIST_MAX_ENTRIES" ]]; then
483
+ automation_die "worktree allowlist exceeds $AUTOMATION_WORKTREE_ALLOWLIST_MAX_ENTRIES entries"
484
+ return 1
485
+ fi
486
+ entries[${#entries[@]}]="$line"
487
+ done < "$allowlist_file"
488
+
489
+ if [[ "$entry_count" -gt 0 ]]; then
490
+ for line in "${entries[@]}"; do
491
+ printf '%s\n' "$line"
492
+ done
493
+ fi
494
+ return 0
495
+ }
496
+
497
+ automation_worktree_allowlist_entries_json() {
498
+ local entries="$1"
499
+ if [[ -z "$entries" ]]; then
500
+ printf '[]\n'
501
+ else
502
+ printf '%s\n' "$entries" | jq -Rsc 'split("\n") | map(select(length > 0))'
503
+ fi
504
+ }
505
+
506
+ automation_worktree_allowlist_file_json_at() {
507
+ local root="$1"
508
+ local entries
509
+ entries="$(automation_worktree_allowlist_file_entries_at "$root")" || return 1
510
+ automation_worktree_allowlist_entries_json "$entries"
511
+ }
512
+
513
+ automation_effective_worktree_allowlist_entries_at() {
514
+ local root="$1"
515
+ local resolved_root
516
+ local lease_file="$AUTOMATION_LOCKS_DIR/repository.workspace.lease/lease.json"
517
+ local task_id workspace_file source_root task_root resolved_source resolved_task entry
518
+
519
+ resolved_root="$(cd "$root" && pwd -P)" || {
520
+ automation_die "unable to resolve worktree root for allowlist: $root"
521
+ return 1
522
+ }
523
+ if [[ -f "$lease_file" ]]; then
524
+ task_id="$(jq -er '.taskId' "$lease_file")" || {
525
+ automation_die "repository lease is invalid while resolving the worktree allowlist"
526
+ return 1
527
+ }
528
+ workspace_file="$(automation_workspace_path "$task_id")"
529
+ if [[ -f "$workspace_file" ]] && jq -e 'has("worktreeAllowlist")' "$workspace_file" >/dev/null; then
530
+ jq -e \
531
+ --argjson max "$AUTOMATION_WORKTREE_ALLOWLIST_MAX_ENTRIES" \
532
+ '.worktreeAllowlist as $entries |
533
+ ($entries | type == "array") and
534
+ ($entries | length <= $max) and
535
+ ($entries | length == (unique | length)) and
536
+ all($entries[]; type == "string" and length > 0)' \
537
+ "$workspace_file" >/dev/null || {
538
+ automation_die "workspace worktreeAllowlist snapshot is invalid: $workspace_file"
539
+ return 1
540
+ }
541
+ source_root="$(jq -er '.sourceRoot' "$workspace_file")" || return 1
542
+ task_root="$(automation_workspace_task_root "$workspace_file")" || return 1
543
+ resolved_source="$(cd "$source_root" 2>/dev/null && pwd -P || true)"
544
+ resolved_task="$(cd "$task_root" 2>/dev/null && pwd -P || true)"
545
+ if [[ "$resolved_root" == "$resolved_source" ]]; then
546
+ while IFS= read -r entry; do
547
+ [[ -n "$entry" ]] || continue
548
+ automation_validate_worktree_allowlist_entry_at "$root" "$entry" || return 1
549
+ printf '%s\n' "$entry"
550
+ done < <(jq -r '.worktreeAllowlist[]' "$workspace_file")
551
+ return 0
552
+ fi
553
+ if [[ "$resolved_root" == "$resolved_task" ]]; then
554
+ return 0
555
+ fi
556
+ fi
557
+ fi
558
+
559
+ automation_worktree_allowlist_file_entries_at "$root"
560
+ }
561
+
562
+ automation_effective_worktree_allowlist_json_at() {
563
+ local root="$1"
564
+ local entries
565
+ entries="$(automation_effective_worktree_allowlist_entries_at "$root")" || return 1
566
+ automation_worktree_allowlist_entries_json "$entries"
567
+ }
568
+
569
+ automation_worktree_path_is_allowlisted() {
570
+ local path="$1"
571
+ local entries="$2"
572
+ local entry
573
+
574
+ [[ "$path" == "$AUTOMATION_WORKTREE_ALLOWLIST_RELATIVE_PATH" ]] && return 0
575
+ while IFS= read -r entry; do
576
+ [[ -n "$entry" ]] || continue
577
+ [[ "$path" == "$entry" ]] && return 0
578
+ done <<< "$entries"
579
+ return 1
580
+ }
581
+
582
+ automation_filter_worktree_paths_at() {
583
+ local root="$1"
584
+ local paths="$2"
585
+ local allowlist path
586
+
587
+ allowlist="$(automation_effective_worktree_allowlist_entries_at "$root")" || return 1
588
+ while IFS= read -r path; do
589
+ [[ -n "$path" ]] || continue
590
+ if ! automation_worktree_path_is_allowlisted "$path" "$allowlist"; then
591
+ printf '%s\n' "$path"
592
+ fi
593
+ done <<< "$paths"
594
+ return 0
595
+ }
596
+
597
+ automation_tracked_changed_paths_at() {
598
+ local root="$1"
599
+ local paths
600
+ paths="$(git -C "$root" diff --no-renames --name-only HEAD -- | LC_ALL=C sort -u)" || return 1
601
+ automation_filter_worktree_paths_at "$root" "$paths"
602
+ }
603
+
604
+ automation_untracked_paths_at() {
605
+ local root="$1"
606
+ local paths
607
+ paths="$(git -C "$root" ls-files --others --exclude-standard | LC_ALL=C sort -u)" || return 1
608
+ automation_filter_worktree_paths_at "$root" "$paths"
609
+ }
610
+
268
611
  automation_changed_paths_at() {
269
612
  local root="$1"
270
- {
271
- git -C "$root" diff --name-only HEAD --
272
- git -C "$root" ls-files --others --exclude-standard
273
- } | LC_ALL=C sort -u
613
+ local tracked untracked
614
+
615
+ tracked="$(automation_tracked_changed_paths_at "$root")" || return 1
616
+ untracked="$(automation_untracked_paths_at "$root")" || return 1
617
+ printf '%s\n%s\n' "$tracked" "$untracked" | awk 'NF' | LC_ALL=C sort -u
274
618
  }
275
619
 
276
620
  automation_changed_paths() {
@@ -343,20 +687,71 @@ automation_changed_paths_between() {
343
687
 
344
688
  automation_worktree_diff_sha() {
345
689
  local root="${1:-$AUTOMATION_ROOT}"
346
- local path
690
+ local tracked_paths untracked_paths path
691
+ tracked_paths="$(automation_tracked_changed_paths_at "$root")" || return 1
692
+ untracked_paths="$(automation_untracked_paths_at "$root")" || return 1
347
693
  {
348
- git -C "$root" diff --binary HEAD --
694
+ while IFS= read -r path; do
695
+ [[ -n "$path" ]] || continue
696
+ git -C "$root" diff --binary --no-renames HEAD -- "$path"
697
+ done <<< "$tracked_paths"
349
698
  while IFS= read -r path; do
350
699
  [[ -n "$path" ]] || continue
351
700
  printf 'UNTRACKED %s\0' "$path"
352
701
  git -C "$root" hash-object -- "$path"
353
- done < <(git -C "$root" ls-files --others --exclude-standard | LC_ALL=C sort)
702
+ done <<< "$untracked_paths"
354
703
  } | shasum -a 256 | awk '{print $1}'
355
704
  }
356
705
 
706
+ automation_worktree_diff_stat_at() {
707
+ local root="${1:-$AUTOMATION_ROOT}"
708
+ local tracked_paths path
709
+ local -a paths=()
710
+
711
+ tracked_paths="$(automation_tracked_changed_paths_at "$root")" || return 1
712
+ while IFS= read -r path; do
713
+ [[ -n "$path" ]] && paths[${#paths[@]}]="$path"
714
+ done <<< "$tracked_paths"
715
+ if [[ "${#paths[@]}" -gt 0 ]]; then
716
+ git -C "$root" diff --stat --no-renames HEAD -- "${paths[@]}"
717
+ fi
718
+ return 0
719
+ }
720
+
721
+ automation_worktree_patch_at() {
722
+ local root="${1:-$AUTOMATION_ROOT}"
723
+ local tracked_paths untracked_paths path
724
+
725
+ tracked_paths="$(automation_tracked_changed_paths_at "$root")" || return 1
726
+ untracked_paths="$(automation_untracked_paths_at "$root")" || return 1
727
+ while IFS= read -r path; do
728
+ [[ -n "$path" ]] || continue
729
+ git -C "$root" diff --binary --no-renames HEAD -- "$path"
730
+ done <<< "$tracked_paths"
731
+ while IFS= read -r path; do
732
+ [[ -n "$path" ]] || continue
733
+ git -C "$root" diff --binary --no-index -- /dev/null "$root/$path" || [[ "$?" -eq 1 ]]
734
+ done <<< "$untracked_paths"
735
+ return 0
736
+ }
737
+
738
+ automation_worktree_status_at() {
739
+ local root="${1:-$AUTOMATION_ROOT}"
740
+ local changed_paths path
741
+
742
+ changed_paths="$(automation_changed_paths_at "$root")" || return 1
743
+ printf 'branch %s\n' "$(automation_current_branch "$root")"
744
+ while IFS= read -r path; do
745
+ [[ -n "$path" ]] && printf 'changed %s\n' "$path"
746
+ done <<< "$changed_paths"
747
+ return 0
748
+ }
749
+
357
750
  automation_worktree_is_clean() {
358
751
  local root="${1:-$AUTOMATION_ROOT}"
359
- [[ -z "$(git -C "$root" status --porcelain --untracked-files=all)" ]]
752
+ local changed_paths
753
+ changed_paths="$(automation_changed_paths_at "$root")" || return 1
754
+ [[ -z "$changed_paths" ]]
360
755
  }
361
756
 
362
757
  automation_create_lock_dir() {
@@ -86,7 +86,7 @@ for _step in 1 2 3 4 5 6 7 8; do
86
86
  if [[ -f "$agent_runs" ]]; then
87
87
  review_attempt="$(jq -s --argjson cycle "$review_cycle" '[.[] | select(.role == "scheduled-reviewer" and .cycle == $cycle)] | length' "$agent_runs")"
88
88
  fi
89
- reviewer_prompt="Use \$scheduled-quality-reviewer with $task_id. This is fresh, non-interactive, read-only review attempt $review_attempt for sealed review cycle $review_cycle. Work within the step budget. Run exactly ./scripts/automation/status.sh $task_id once; its JSON already includes baseline, RED, ready, latest gate, current diff SHA, and the sealed-SHA comparison, so do not browse the external evidence directory. Then inspect the approved contract and run exactly git diff. Submit exactly one decision. Do not browse generated build reports, list the whole repository, retry denied command variants, or run Gradle commands separately before deciding: submit-review.sh performs and records the focused tests, full unit suite, assembleDebug, and lint for APPROVED. Reserve a tool call for submit-review.sh. Never edit, commit, merge, or push."
89
+ reviewer_prompt="Use \$scheduled-quality-reviewer with $task_id. This is fresh, non-interactive, read-only review attempt $review_attempt for sealed review cycle $review_cycle. Work within the step budget. Run exactly ./scripts/automation/status.sh $task_id once; its JSON already includes baseline, RED, ready, latest gate, current diff SHA, the sealed-SHA comparison, and runtime.effectiveWorktreeAllowlist, so do not browse the external evidence directory. Then inspect the approved contract and run exactly git diff, excluding the control file and every exact path in that effective list from review findings. Submit exactly one decision. Do not browse generated build reports, list the whole repository, retry denied command variants, or run Gradle commands separately before deciding: submit-review.sh performs and records the focused tests, full unit suite, assembleDebug, and lint for APPROVED. Reserve a tool call for submit-review.sh. Never edit, commit, merge, or push."
90
90
  run_agent scheduled-reviewer "$reviewer_prompt" "$review_cycle" "$review_attempt" || true
91
91
  if [[ "$(automation_read_state "$task_id")" == "REVIEWING" ]]; then
92
92
  automation_transition_state "$task_id" "REVIEWING" "BLOCKED" "orchestrator" "reviewer exited without submitting a decision; sealed diff preserved for reviewer-only recovery"
@@ -124,7 +124,7 @@ else
124
124
  discovery_dir="$(mktemp -d "${TMPDIR:-/tmp}/opencode-android-orchestrator-preflight.XXXXXX")"
125
125
  trap 'rm -rf "$discovery_dir"' EXIT
126
126
 
127
- if ! opencode debug config > "$discovery_dir/config.json" 2> "$discovery_dir/config.err"; then
127
+ if ! automation_resolve_opencode_config "$discovery_dir/config.json" "$discovery_dir/config.err"; then
128
128
  automation_die "OpenCode resolved config failed; see $discovery_dir/config.err"
129
129
  fi
130
130
  superpowers_pin="$(jq -r '.plugins.superpowers' "$AUTOMATION_CONFIG")"
@@ -16,15 +16,15 @@ if [[ -z "$task_id" || -z "$expected" || "$separator" != "--" || -z "$filter" ||
16
16
  fi
17
17
 
18
18
  automation_validate_task_id "$task_id"
19
- safe_filter_pattern='^[A-Za-z0-9_.#$*-]+$'
20
- [[ "$filter" =~ $safe_filter_pattern ]] || automation_die "unsafe test filter: $filter"
19
+ automation_validate_test_filter "$filter"
21
20
  [[ ${#expected} -ge 3 ]] || automation_die "expected failure text is too short"
22
21
  [[ "$(automation_read_state "$task_id")" == "CODING" ]] || automation_die "$task_id is not CODING"
23
22
  "$SCRIPT_DIR/validate-contract.sh" "$task_id"
24
23
 
25
24
  contract="$(automation_contract_path "$task_id")"
26
- jq -e --arg filter "$filter" '.targetTests | index($filter) != null' "$contract" >/dev/null || \
27
- automation_die "test filter is not declared in the contract: $filter"
25
+ target_count="$(jq -er --arg filter "$filter" '[.targetTests[] | select(.filter == $filter)] | length' "$contract")"
26
+ [[ "$target_count" -eq 1 ]] || automation_die "test filter must identify exactly one declared contract target: $filter"
27
+ gradle_task="$(jq -er --arg filter "$filter" '.targetTests[] | select(.filter == $filter) | .gradleTask' "$contract")"
28
28
 
29
29
  evidence_dir="$(automation_evidence_path "$task_id")"
30
30
  mkdir -p "$evidence_dir"
@@ -34,10 +34,7 @@ red_meta="$evidence_dir/red.json"
34
34
  started_at="$(automation_now)"
35
35
 
36
36
  set +e
37
- (
38
- cd "$AUTOMATION_ROOT"
39
- ./gradlew testDebugUnitTest --tests "$filter"
40
- ) 2>&1 | tee "$red_log"
37
+ automation_run_focused_test "$gradle_task" "$filter" "$AUTOMATION_ROOT" 2>&1 | tee "$red_log"
41
38
  red_status=${PIPESTATUS[0]}
42
39
  set -e
43
40
 
@@ -49,9 +46,10 @@ jq -n \
49
46
  --arg startedAt "$started_at" \
50
47
  --arg finishedAt "$(automation_now)" \
51
48
  --arg expectedFailure "$expected" \
49
+ --arg gradleTask "$gradle_task" \
52
50
  --arg testFilter "$filter" \
53
51
  --argjson exitCode "$red_status" \
54
- '{taskId: $taskId, startedAt: $startedAt, finishedAt: $finishedAt, command: ["./gradlew", "testDebugUnitTest", "--tests", $testFilter], expectedFailure: $expectedFailure, exitCode: $exitCode}' \
52
+ '{taskId: $taskId, startedAt: $startedAt, finishedAt: $finishedAt, command: ["./gradlew", $gradleTask, "--tests", $testFilter], expectedFailure: $expectedFailure, exitCode: $exitCode}' \
55
53
  | automation_record_json "$red_meta"
56
54
 
57
55
  automation_info "$task_id RED evidence recorded"
@@ -64,12 +64,13 @@ while IFS= read -r path; do
64
64
  done <<< "$changed_file_list"
65
65
 
66
66
  deleted_tests="$(
67
- git -C "$AUTOMATION_ROOT" diff --name-only --diff-filter=D HEAD -- \
68
- | while IFS= read -r path; do
69
- if automation_array_matches_path "$AUTOMATION_CONFIG" '.androidProject.testPaths' "$path"; then
70
- printf '%s\n' "$path"
71
- fi
72
- done
67
+ while IFS= read -r path; do
68
+ [[ -n "$path" ]] || continue
69
+ if [[ -n "$(git -C "$AUTOMATION_ROOT" diff --no-renames --name-only --diff-filter=D HEAD -- "$path")" ]] && \
70
+ automation_array_matches_path "$AUTOMATION_CONFIG" '.androidProject.testPaths' "$path"; then
71
+ printf '%s\n' "$path"
72
+ fi
73
+ done <<< "$changed_file_list"
73
74
  )"
74
75
  [[ -z "$deleted_tests" ]] || { automation_die "test deletion is forbidden: $deleted_tests"; exit 40; }
75
76
 
@@ -83,7 +84,7 @@ added_test_lines="$(
83
84
  while IFS= read -r path; do
84
85
  [[ -n "$path" ]] || continue
85
86
  if automation_array_matches_path "$AUTOMATION_CONFIG" '.androidProject.testPaths' "$path"; then
86
- git -C "$AUTOMATION_ROOT" diff --unified=0 HEAD -- "$path"
87
+ git -C "$AUTOMATION_ROOT" diff --no-renames --unified=0 HEAD -- "$path"
87
88
  fi
88
89
  done <<< "$changed_file_list" \
89
90
  | awk '/^\+\+\+/ { next } /^\+/ { sub(/^\+/, ""); print }'
@@ -95,12 +96,14 @@ if printf '%s\n' "$added_test_lines" | rg -n "$weakening_pattern" >/dev/null; th
95
96
  fi
96
97
 
97
98
  while IFS= read -r untracked_test; do
98
- if automation_array_matches_path "$AUTOMATION_CONFIG" '.androidProject.testPaths' "$untracked_test" && \
99
+ [[ -n "$untracked_test" ]] || continue
100
+ if ! git -C "$AUTOMATION_ROOT" ls-files --error-unmatch -- "$untracked_test" >/dev/null 2>&1 && \
101
+ automation_array_matches_path "$AUTOMATION_CONFIG" '.androidProject.testPaths' "$untracked_test" && \
99
102
  rg -n "$weakening_pattern" "$AUTOMATION_ROOT/$untracked_test" >/dev/null; then
100
103
  automation_die "potential test weakening detected in new test: $untracked_test"
101
104
  exit 40
102
105
  fi
103
- done < <(git -C "$AUTOMATION_ROOT" ls-files --others --exclude-standard)
106
+ done <<< "$changed_file_list"
104
107
 
105
108
  if [[ "$planning_commit_policy" == "withProductChanges" ]]; then
106
109
  automation_info "scope gate passed ($changed_count product files; sealed planning artifacts excluded from the contract limit)"
@@ -85,7 +85,7 @@ printf -- '- Reviewer:`%s`,独立复验退出码 `%s`\n' \
85
85
  "$(jq -r '.evidence.reviewerVerificationExitCode' "$report_file")"
86
86
  printf -- '- Reviewer 摘要:%s\n' "$(jq -r '.reviewSummary' "$report_file")"
87
87
  printf -- '- 聚焦测试:\n'
88
- jq -r '.targetTests[] | " - `\(.)`"' "$report_file"
88
+ jq -r '.targetTests[] | " - `./gradlew \(.gradleTask) --tests \(.filter)`"' "$report_file"
89
89
 
90
90
  printf '\n### P1 · 绑定与剩余风险\n\n'
91
91
  printf -- '- 当前 diff 与质量门、独立 Review、验收包的 SHA 三方一致。\n'
@@ -33,6 +33,7 @@ if [[ "$ready_json" != "null" ]]; then
33
33
  fi
34
34
 
35
35
  current_diff_sha=""
36
+ effective_worktree_allowlist_json='[]'
36
37
  lease_json=null
37
38
  lease_matches=null
38
39
  original_head_current=""
@@ -41,6 +42,7 @@ task_branch_exists=null
41
42
  if [[ "$workspace_json" != "null" ]]; then
42
43
  task_root="$(jq -r '.taskRoot // .taskWorktree // empty' <<< "$workspace_json")"
43
44
  if [[ -n "$task_root" && -d "$task_root" ]]; then
45
+ effective_worktree_allowlist_json="$(automation_effective_worktree_allowlist_json_at "$task_root")"
44
46
  current_diff_sha="$(automation_worktree_diff_sha "$task_root")"
45
47
  fi
46
48
  source_root="$(jq -r '.sourceRoot // empty' <<< "$workspace_json")"
@@ -97,6 +99,7 @@ jq -n \
97
99
  --argjson repositoryLease "$lease_json" \
98
100
  --argjson repositoryLeaseMatches "$lease_matches" \
99
101
  --argjson taskBranchExists "$task_branch_exists" \
102
+ --argjson effectiveWorktreeAllowlist "$effective_worktree_allowlist_json" \
100
103
  --arg originalHeadCurrent "$original_head_current" \
101
104
  --argjson originalBranchDrifted "$original_branch_drifted" \
102
105
  --arg evidence "$evidence_dir" \
@@ -106,6 +109,7 @@ jq -n \
106
109
  repositoryLease: $repositoryLease,
107
110
  repositoryLeaseMatches: $repositoryLeaseMatches,
108
111
  taskBranchExists: $taskBranchExists,
112
+ effectiveWorktreeAllowlist: $effectiveWorktreeAllowlist,
109
113
  originalHeadCurrent: ($originalHeadCurrent | if length == 0 then null else . end),
110
114
  originalBranchDrifted: $originalBranchDrifted
111
115
  },