@frankzhang2026/opencode-android-orchestrator 0.3.0 → 0.5.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 (46) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +81 -20
  3. package/THIRD_PARTY_NOTICES.md +1 -1
  4. package/dist/cli.js +2 -0
  5. package/dist/cli.js.map +1 -1
  6. package/dist/index.d.ts +2 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +2 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/installer/android-project.js +1 -1
  11. package/dist/installer/android-project.js.map +1 -1
  12. package/dist/installer/gradle-verification.d.ts +23 -0
  13. package/dist/installer/gradle-verification.d.ts.map +1 -0
  14. package/dist/installer/gradle-verification.js +231 -0
  15. package/dist/installer/gradle-verification.js.map +1 -0
  16. package/dist/installer/index.d.ts +1 -1
  17. package/dist/installer/index.d.ts.map +1 -1
  18. package/dist/installer/index.js +1 -1
  19. package/dist/installer/index.js.map +1 -1
  20. package/dist/installer/init.d.ts +6 -1
  21. package/dist/installer/init.d.ts.map +1 -1
  22. package/dist/installer/init.js +144 -19
  23. package/dist/installer/init.js.map +1 -1
  24. package/dist/installer/opencode-config.d.ts +3 -3
  25. package/dist/installer/opencode-config.js +1 -1
  26. package/docs/MIGRATION.md +11 -11
  27. package/docs/SECURITY.md +21 -4
  28. package/docs/TROUBLESHOOTING.md +28 -2
  29. package/package.json +1 -1
  30. package/templates/.opencode/agents/scheduled-coder.md +3 -0
  31. package/templates/.opencode/agents/scheduled-reviewer.md +5 -4
  32. package/templates/.opencode/skills/scheduled-quality-coder/SKILL.md +10 -6
  33. package/templates/.opencode/skills/scheduled-quality-reviewer/SKILL.md +7 -4
  34. package/templates/AGENTS.md.fragment +4 -0
  35. package/templates/README.md +16 -0
  36. package/templates/automation/config.json +1 -0
  37. package/templates/automation/tasks/TASK-TEMPLATE.json.example +1 -0
  38. package/templates/scripts/automation/abort-task.sh +5 -10
  39. package/templates/scripts/automation/accept-and-integrate.sh +1 -1
  40. package/templates/scripts/automation/acceptance-report.sh +2 -9
  41. package/templates/scripts/automation/approve-and-run.sh +3 -0
  42. package/templates/scripts/automation/lib.sh +285 -8
  43. package/templates/scripts/automation/orchestrate-task.sh +1 -1
  44. package/templates/scripts/automation/scope-gate.sh +12 -9
  45. package/templates/scripts/automation/status.sh +4 -0
  46. package/templates/scripts/automation/tests/run-tests.sh +138 -0
@@ -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' "$*"
@@ -383,12 +386,235 @@ automation_array_matches_path() {
383
386
  return 1
384
387
  }
385
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
+
386
611
  automation_changed_paths_at() {
387
612
  local root="$1"
388
- {
389
- git -C "$root" diff --name-only HEAD --
390
- git -C "$root" ls-files --others --exclude-standard
391
- } | 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
392
618
  }
393
619
 
394
620
  automation_changed_paths() {
@@ -461,20 +687,71 @@ automation_changed_paths_between() {
461
687
 
462
688
  automation_worktree_diff_sha() {
463
689
  local root="${1:-$AUTOMATION_ROOT}"
464
- 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
465
693
  {
466
- 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"
467
698
  while IFS= read -r path; do
468
699
  [[ -n "$path" ]] || continue
469
700
  printf 'UNTRACKED %s\0' "$path"
470
701
  git -C "$root" hash-object -- "$path"
471
- done < <(git -C "$root" ls-files --others --exclude-standard | LC_ALL=C sort)
702
+ done <<< "$untracked_paths"
472
703
  } | shasum -a 256 | awk '{print $1}'
473
704
  }
474
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
+
475
750
  automation_worktree_is_clean() {
476
751
  local root="${1:-$AUTOMATION_ROOT}"
477
- [[ -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" ]]
478
755
  }
479
756
 
480
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"
@@ -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)"
@@ -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
  },
@@ -82,6 +82,7 @@ write_workspace() {
82
82
  mkdir -p \
83
83
  "$fixture/automation/tasks" \
84
84
  "$fixture/docs/plans" \
85
+ "$fixture/local" \
85
86
  "$fixture/scripts/automation" \
86
87
  "$fixture/mobile-client/src/main/java/dev/example/orchestratorfixture" \
87
88
  "$fixture/mobile-client/src/test/java/dev/example/orchestratorfixture"
@@ -89,6 +90,8 @@ mkdir -p \
89
90
  cp "$SOURCE_SCRIPT_DIR"/*.sh "$fixture/scripts/automation/"
90
91
  chmod +x "$fixture/scripts/automation/"*.sh
91
92
  printf '%s\n' '# Approved test plan' > "$fixture/docs/plans/TASK-TEST-001.md"
93
+ printf '%s\n' 'operator baseline' > "$fixture/local/operator-note.txt"
94
+ printf '%s\n' 'rename baseline' > "$fixture/local/rename-source.txt"
92
95
  printf '%s\n' 'class ExistingFeature' > "$fixture/mobile-client/src/main/java/dev/example/orchestratorfixture/ExistingFeature.kt"
93
96
 
94
97
  printf '%s\n' \
@@ -166,6 +169,7 @@ jq -n '{
166
169
  ]
167
170
  },
168
171
  protectedPaths: [
172
+ ".automation-worktree-allowlist",
169
173
  ".opencode/",
170
174
  ".automation-plugin/",
171
175
  "automation/",
@@ -200,6 +204,7 @@ jq -n '{
200
204
  "mobile-client/src/test/java/dev/example/orchestratorfixture/**"
201
205
  ],
202
206
  forbiddenPaths: [
207
+ ".automation-worktree-allowlist",
203
208
  ".opencode/**",
204
209
  ".automation-plugin/**",
205
210
  "automation/**",
@@ -243,6 +248,74 @@ jq -n '{
243
248
  git commit -qm 'Create automation fixture'
244
249
  )
245
250
 
251
+ printf '%s\n' \
252
+ '# Exact repository-relative paths intentionally kept local' \
253
+ 'local/operator-note.txt' \
254
+ 'local/generated-note.txt' \
255
+ > "$fixture/.automation-worktree-allowlist"
256
+ printf '%s\n' 'operator local edit' > "$fixture/local/operator-note.txt"
257
+ printf '%s\n' 'generated local state' > "$fixture/local/generated-note.txt"
258
+ run_fixture ./scripts/automation/preflight.sh --source >/dev/null
259
+ allowlisted_changes="$(run_fixture bash -c 'source ./scripts/automation/lib.sh; automation_changed_paths')"
260
+ [[ -z "$allowlisted_changes" ]] || fail "allowlisted paths remained visible: $allowlisted_changes"
261
+
262
+ printf '%s\n' \
263
+ 'local/operator-note.txt' \
264
+ 'local/generated-note.txt' \
265
+ 'local/rename-target.txt' \
266
+ > "$fixture/.automation-worktree-allowlist"
267
+ git -C "$fixture" mv local/rename-source.txt local/rename-target.txt
268
+ rename_changes="$(run_fixture bash -c 'source ./scripts/automation/lib.sh; automation_changed_paths')"
269
+ [[ "$rename_changes" == "local/rename-source.txt" ]] || \
270
+ fail "an allowlisted rename target hid its non-allowlisted source: $rename_changes"
271
+ git -C "$fixture" mv local/rename-target.txt local/rename-source.txt
272
+ printf '%s\n' \
273
+ '# Exact repository-relative paths intentionally kept local' \
274
+ 'local/operator-note.txt' \
275
+ 'local/generated-note.txt' \
276
+ > "$fixture/.automation-worktree-allowlist"
277
+ pass 'exact tracked and untracked entries are ignored without hiding rename sources'
278
+
279
+ printf '%s\n' 'must still block' > "$fixture/unapproved-change.txt"
280
+ if run_fixture ./scripts/automation/preflight.sh --source >/dev/null 2>&1; then
281
+ fail 'preflight ignored a path that was not allowlisted'
282
+ fi
283
+ rm "$fixture/unapproved-change.txt"
284
+ pass 'a non-allowlisted worktree change still blocks orchestration startup'
285
+
286
+ printf '%s\n' 'automation/config.json' > "$fixture/.automation-worktree-allowlist"
287
+ if run_fixture bash -c 'source ./scripts/automation/lib.sh; automation_changed_paths >/dev/null' 2>/dev/null; then
288
+ fail 'worktree allowlist accepted a protected automation path'
289
+ fi
290
+ printf '%s\n' 'local/*.json' > "$fixture/.automation-worktree-allowlist"
291
+ if run_fixture bash -c 'source ./scripts/automation/lib.sh; automation_changed_paths >/dev/null' 2>/dev/null; then
292
+ fail 'worktree allowlist accepted a glob pattern'
293
+ fi
294
+ printf '%s\n' 'local' > "$fixture/.automation-worktree-allowlist"
295
+ if run_fixture bash -c 'source ./scripts/automation/lib.sh; automation_changed_paths >/dev/null' 2>/dev/null; then
296
+ fail 'worktree allowlist accepted a directory path'
297
+ fi
298
+ printf '%s\n' 'local/operator-note.txt' 'local/operator-note.txt' > "$fixture/.automation-worktree-allowlist"
299
+ if run_fixture bash -c 'source ./scripts/automation/lib.sh; automation_changed_paths >/dev/null' 2>/dev/null; then
300
+ fail 'worktree allowlist accepted duplicate entries'
301
+ fi
302
+ printf '%s\n' '../outside.txt' > "$fixture/.automation-worktree-allowlist"
303
+ if run_fixture bash -c 'source ./scripts/automation/lib.sh; automation_changed_paths >/dev/null' 2>/dev/null; then
304
+ fail 'worktree allowlist accepted a path outside the repository'
305
+ fi
306
+ rm "$fixture/.automation-worktree-allowlist"
307
+ ln -s local/operator-note.txt "$fixture/.automation-worktree-allowlist"
308
+ if run_fixture bash -c 'source ./scripts/automation/lib.sh; automation_changed_paths >/dev/null' 2>/dev/null; then
309
+ fail 'worktree allowlist accepted a symbolic-link control file'
310
+ fi
311
+ rm "$fixture/.automation-worktree-allowlist"
312
+ printf '%s\n' \
313
+ '# Exact repository-relative paths intentionally kept local' \
314
+ 'local/operator-note.txt' \
315
+ 'local/generated-note.txt' \
316
+ > "$fixture/.automation-worktree-allowlist"
317
+ pass 'invalid, duplicate, symbolic-link, patterned, and protected allowlist input fails closed'
318
+
246
319
  run_fixture ./scripts/automation/validate-contract.sh TASK-TEST-001 >/dev/null
247
320
  pass 'valid version-3 configuration and task contract are accepted'
248
321
 
@@ -500,6 +573,8 @@ approved_baseline="$(jq -er '.baselineHead' "$runtime_root/workspaces/TASK-TEST-
500
573
  leased_status="$(run_fixture ./scripts/automation/status.sh TASK-TEST-004)"
501
574
  [[ "$(jq -r '.runtime.repositoryLeaseMatches' <<< "$leased_status")" == "true" ]] || fail 'status did not verify the repository lease'
502
575
  [[ "$(jq -r '.runtime.originalBranchDrifted' <<< "$leased_status")" == "false" ]] || fail 'status reported false branch drift'
576
+ [[ "$(jq -c '.runtime.effectiveWorktreeAllowlist' <<< "$leased_status")" == '["local/operator-note.txt","local/generated-note.txt"]' ]] || \
577
+ fail 'status did not expose the approved in-place allowlist snapshot'
503
578
  if run_fixture bash -c 'source ./scripts/automation/lib.sh; automation_acquire_repository_lease TASK-TEST-999 "$PWD" inPlaceExclusive' >/dev/null 2>&1; then
504
579
  fail 'a second task acquired the persistent repository lease'
505
580
  fi
@@ -508,6 +583,10 @@ pass 'persistent repository lease rejects a concurrent automation task'
508
583
  [[ "$(jq -r '.state' "$runtime_root/state/TASK-TEST-004.json")" == "PENDING" ]] || fail 'prepared task did not become PENDING'
509
584
  pass 'contract approval switches to a leased task branch while deferring the sealed plan and contract to the product commit'
510
585
 
586
+ git -C "$fixture" add -- \
587
+ .automation-worktree-allowlist \
588
+ local/operator-note.txt \
589
+ local/generated-note.txt
511
590
  run_task "$task_root" ./scripts/automation/claim-task.sh TASK-TEST-004 >/dev/null
512
591
  printf '%s\n' 'class OrchestratedFlowTest { fun expectedBehavior() = Unit }' > "$task_root/mobile-client/src/test/java/dev/example/orchestratorfixture/OrchestratedFlowTest.kt"
513
592
  run_task "$task_root" ./scripts/automation/record-red.sh TASK-TEST-004 'expected missing behavior' -- dev.example.orchestratorfixture.OrchestratedFlowTest >/dev/null
@@ -517,6 +596,15 @@ run_task "$task_root" ./scripts/automation/begin-review.sh TASK-TEST-004 >/dev/n
517
596
  run_task "$task_root" env AUTOMATION_FAKE_GREEN=1 ./scripts/automation/submit-review.sh TASK-TEST-004 APPROVED 'Fresh review confirms the sealed behavior and scope.' >/dev/null
518
597
  run_task "$task_root" ./scripts/automation/acceptance-report.sh TASK-TEST-004 >/dev/null
519
598
  [[ "$(jq -r '.changedPaths | length' "$runtime_root/evidence/TASK-TEST-004/acceptance-report.json")" -eq 2 ]] || fail 'acceptance package omitted product paths'
599
+ if jq -e '.changedPaths | any(. == "local/operator-note.txt" or . == "local/generated-note.txt")' \
600
+ "$runtime_root/evidence/TASK-TEST-004/acceptance-report.json" >/dev/null; then
601
+ fail 'acceptance package included an allowlisted local path'
602
+ fi
603
+ if rg -F 'diff --git a/local/operator-note.txt b/local/operator-note.txt' "$runtime_root/evidence/TASK-TEST-004/sealed.diff" >/dev/null || \
604
+ rg -F 'diff --git a/local/generated-note.txt b/local/generated-note.txt' "$runtime_root/evidence/TASK-TEST-004/sealed.diff" >/dev/null || \
605
+ rg -F 'diff --git a/.automation-worktree-allowlist b/.automation-worktree-allowlist' "$runtime_root/evidence/TASK-TEST-004/sealed.diff" >/dev/null; then
606
+ fail 'sealed diff included allowlisted local content'
607
+ fi
520
608
  [[ "$(jq -r '.evidence.qualityGate' "$runtime_root/evidence/TASK-TEST-004/acceptance-report.json")" == "PASSED" ]] || fail 'acceptance package omitted quality-gate status'
521
609
  acceptance_card="$(run_fixture ./scripts/automation/show-acceptance-review.sh TASK-TEST-004)"
522
610
  [[ "$acceptance_card" == *"人工验收提醒"* ]] || fail 'acceptance review did not actively identify the human gate'
@@ -555,6 +643,27 @@ for combined_path in \
555
643
  awk -v expected="$combined_path" '$0 == expected { found = 1 } END { exit !found }' || \
556
644
  fail "combined task commit omitted $combined_path"
557
645
  done
646
+ if git -C "$fixture" diff-tree --no-commit-id --name-only -r "$combined_commit" | \
647
+ rg -x 'local/(operator-note|generated-note)\.txt|\.automation-worktree-allowlist' >/dev/null; then
648
+ fail 'combined task commit included an allowlisted local path or its control file'
649
+ fi
650
+ [[ "$(cat "$fixture/local/operator-note.txt")" == "operator local edit" ]] || fail 'tracked allowlisted content changed during integration'
651
+ [[ "$(cat "$fixture/local/generated-note.txt")" == "generated local state" ]] || fail 'untracked allowlisted content changed during integration'
652
+ staged_allowlist_paths="$(git -C "$fixture" diff --cached --name-only HEAD -- | LC_ALL=C sort)"
653
+ for staged_allowlist_path in \
654
+ .automation-worktree-allowlist \
655
+ local/generated-note.txt \
656
+ local/operator-note.txt; do
657
+ printf '%s\n' "$staged_allowlist_paths" | \
658
+ awk -v expected="$staged_allowlist_path" '$0 == expected { found = 1 } END { exit !found }' || \
659
+ fail "integration consumed a staged allowlisted path: $staged_allowlist_path"
660
+ done
661
+ run_fixture bash -c 'source ./scripts/automation/lib.sh; automation_worktree_is_clean' || fail 'allowlisted local state made the integrated source root dirty'
662
+ pass 'in-place integration preserves allowlisted local state without evidence or commit leakage'
663
+ git -C "$fixture" restore --staged -- \
664
+ .automation-worktree-allowlist \
665
+ local/operator-note.txt \
666
+ local/generated-note.txt
558
667
  [[ "$(jq -r '.contractCommit' "$runtime_root/evidence/TASK-TEST-004/origin.json")" == "$combined_commit" ]] || fail 'origin evidence did not bind the contract to the combined task commit'
559
668
  [[ "$(jq -r '.pushed' "$runtime_root/evidence/TASK-TEST-004/integration.json")" == "false" ]] || fail 'integration evidence did not forbid push'
560
669
  [[ "$(jq -r '.method' "$runtime_root/evidence/TASK-TEST-004/integration.json")" == "inPlaceExclusive-fast-forward" ]] || fail 'integration did not use the in-place fast-forward path'
@@ -579,6 +688,10 @@ jq \
579
688
  > "$fixture/automation/tasks/TASK-TEST-008.json"
580
689
  run_fixture ./scripts/automation/prepare-contract-review.sh TASK-TEST-008 '批准方案,生成计划和任务合同。' >/dev/null
581
690
  run_fixture env AUTOMATION_SKIP_AGENT_RUN=1 ./scripts/automation/approve-and-run.sh TASK-TEST-008 '合同已复核,批准自动执行到人工验收阶段。' >/dev/null
691
+ git -C "$fixture" add -- \
692
+ .automation-worktree-allowlist \
693
+ local/operator-note.txt \
694
+ local/generated-note.txt
582
695
  printf '%s\n' 'class AbortArchive { fun value() = "preserved" }' > "$fixture/mobile-client/src/main/java/dev/example/orchestratorfixture/AbortArchive.kt"
583
696
  printf '%s\n' 'must not be archived automatically' > "$fixture/out-of-contract.txt"
584
697
  if run_fixture ./scripts/automation/abort-task.sh TASK-TEST-008 '中止任务,封存修改并恢复原分支。' >/dev/null 2>&1; then
@@ -592,10 +705,27 @@ abort_recovery_commit="$(jq -er '.recoveryCommit' "$runtime_root/evidence/TASK-T
592
705
  git -C "$fixture" cat-file -e "$abort_recovery_commit:mobile-client/src/main/java/dev/example/orchestratorfixture/AbortArchive.kt" || fail 'abort recovery commit omitted the allowed change'
593
706
  git -C "$fixture" cat-file -e "$abort_recovery_commit:docs/plans/TASK-TEST-008.md" || fail 'abort recovery commit omitted the sealed plan'
594
707
  git -C "$fixture" cat-file -e "$abort_recovery_commit:automation/tasks/TASK-TEST-008.json" || fail 'abort recovery commit omitted the sealed contract'
708
+ if git -C "$fixture" diff-tree --no-commit-id --name-only -r "$abort_recovery_commit" | \
709
+ rg -x 'local/(operator-note|generated-note)\.txt|\.automation-worktree-allowlist' >/dev/null; then
710
+ fail 'abort recovery commit included an allowlisted local path or its control file'
711
+ fi
712
+ staged_abort_allowlist_paths="$(git -C "$fixture" diff --cached --name-only HEAD -- | LC_ALL=C sort)"
713
+ for staged_allowlist_path in \
714
+ .automation-worktree-allowlist \
715
+ local/generated-note.txt \
716
+ local/operator-note.txt; do
717
+ printf '%s\n' "$staged_abort_allowlist_paths" | \
718
+ awk -v expected="$staged_allowlist_path" '$0 == expected { found = 1 } END { exit !found }' || \
719
+ fail "abort consumed a staged allowlisted path: $staged_allowlist_path"
720
+ done
595
721
  [[ ! -f "$fixture/mobile-client/src/main/java/dev/example/orchestratorfixture/AbortArchive.kt" ]] || fail 'abort left archived code on the original branch'
596
722
  [[ "$(jq -r '.state' "$runtime_root/state/TASK-TEST-008.json")" == "ABORTED" ]] || fail 'allowed abort did not reach ABORTED'
597
723
  [[ ! -d "$runtime_root/locks/repository.workspace.lease" ]] || fail 'allowed abort did not release the repository lease'
598
724
  pass 'abort refuses unrelated files and archives allowed uncommitted changes in a recovery commit'
725
+ git -C "$fixture" restore --staged -- \
726
+ .automation-worktree-allowlist \
727
+ local/operator-note.txt \
728
+ local/generated-note.txt
599
729
 
600
730
  planning_only_baseline="$(git -C "$fixture" rev-parse HEAD)"
601
731
  printf '%s\n' '# Planning-only abort plan' > "$fixture/docs/plans/TASK-TEST-009.md"
@@ -635,9 +765,17 @@ run_fixture ./scripts/automation/prepare-contract-review.sh TASK-TEST-007 '批
635
765
  run_fixture env AUTOMATION_SKIP_AGENT_RUN=1 ./scripts/automation/approve-and-run.sh TASK-TEST-007 '合同已复核,批准自动执行到人工验收阶段。' >/dev/null
636
766
  isolated_task_root="$(jq -er '.taskRoot' "$runtime_root/workspaces/TASK-TEST-007.json")"
637
767
  [[ "$isolated_task_root" != "$fixture" && -d "$isolated_task_root" ]] || fail 'optional isolated strategy did not create a separate task root'
768
+ [[ "$(jq -r '.worktreeAllowlist | length' "$runtime_root/workspaces/TASK-TEST-007.json")" -eq 2 ]] || fail 'isolated workspace did not retain the approved allowlist snapshot'
638
769
  [[ -f "$isolated_task_root/docs/plans/TASK-TEST-007.md" && -f "$isolated_task_root/automation/tasks/TASK-TEST-007.json" ]] || fail 'isolated task root did not receive the sealed planning artifacts'
639
770
  [[ ! -e "$fixture/docs/plans/TASK-TEST-007.md" && ! -e "$fixture/automation/tasks/TASK-TEST-007.json" ]] || fail 'isolated preparation left duplicate planning artifacts in the source root'
640
771
  [[ "$(git -C "$fixture" worktree list --porcelain | awk '/^worktree / { count++ } END { print count + 0 }')" -eq 2 ]] || fail 'isolated strategy created more than one additional worktree'
772
+ isolated_status="$(run_task "$isolated_task_root" ./scripts/automation/status.sh TASK-TEST-007)"
773
+ [[ "$(jq -c '.runtime.effectiveWorktreeAllowlist' <<< "$isolated_status")" == '[]' ]] || \
774
+ fail 'status applied the source allowlist inside an isolated task root'
775
+ printf '%s\n' 'task-local edit must stay visible' > "$isolated_task_root/local/operator-note.txt"
776
+ isolated_visible_paths="$(run_task "$isolated_task_root" bash -c 'source ./scripts/automation/lib.sh; automation_changed_paths')"
777
+ printf '%s\n' "$isolated_visible_paths" | rg -x 'local/operator-note.txt' >/dev/null || fail 'source allowlist hid a task-local isolated-worktree edit'
778
+ git -C "$isolated_task_root" restore -- local/operator-note.txt
641
779
  run_task "$isolated_task_root" ./scripts/automation/claim-task.sh TASK-TEST-007 >/dev/null
642
780
  printf '%s\n' 'class IsolatedFlowTest { fun expectedBehavior() = Unit }' > "$isolated_task_root/mobile-client/src/test/java/dev/example/orchestratorfixture/IsolatedFlowTest.kt"
643
781
  run_task "$isolated_task_root" ./scripts/automation/record-red.sh TASK-TEST-007 'expected missing behavior' -- dev.example.orchestratorfixture.IsolatedFlowTest >/dev/null