@frankzhang2026/opencode-android-orchestrator 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/CHANGELOG.md +92 -2
  2. package/LICENSE +21 -0
  3. package/README.md +402 -6
  4. package/THIRD_PARTY_NOTICES.md +119 -0
  5. package/dist/cli.js +360 -1
  6. package/dist/cli.js.map +1 -1
  7. package/dist/compatibility/hooks.d.ts +23 -0
  8. package/dist/compatibility/hooks.d.ts.map +1 -0
  9. package/dist/compatibility/hooks.js +44 -0
  10. package/dist/compatibility/hooks.js.map +1 -0
  11. package/dist/compatibility/versions.d.ts +19 -0
  12. package/dist/compatibility/versions.d.ts.map +1 -1
  13. package/dist/compatibility/versions.js +116 -0
  14. package/dist/compatibility/versions.js.map +1 -1
  15. package/dist/doctor/index.d.ts +36 -0
  16. package/dist/doctor/index.d.ts.map +1 -0
  17. package/dist/doctor/index.js +289 -0
  18. package/dist/doctor/index.js.map +1 -0
  19. package/dist/doctor/installation.d.ts +4 -0
  20. package/dist/doctor/installation.d.ts.map +1 -0
  21. package/dist/doctor/installation.js +497 -0
  22. package/dist/doctor/installation.js.map +1 -0
  23. package/dist/index.d.ts +13 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +13 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/installer/adaptive-templates.d.ts +78 -0
  28. package/dist/installer/adaptive-templates.d.ts.map +1 -0
  29. package/dist/installer/adaptive-templates.js +233 -0
  30. package/dist/installer/adaptive-templates.js.map +1 -0
  31. package/dist/installer/agents-config.d.ts +19 -0
  32. package/dist/installer/agents-config.d.ts.map +1 -0
  33. package/dist/installer/agents-config.js +147 -0
  34. package/dist/installer/agents-config.js.map +1 -0
  35. package/dist/installer/android-project.d.ts +36 -0
  36. package/dist/installer/android-project.d.ts.map +1 -0
  37. package/dist/installer/android-project.js +418 -0
  38. package/dist/installer/android-project.js.map +1 -0
  39. package/dist/installer/index.d.ts +8 -0
  40. package/dist/installer/index.d.ts.map +1 -1
  41. package/dist/installer/index.js +8 -1
  42. package/dist/installer/index.js.map +1 -1
  43. package/dist/installer/init.d.ts +68 -0
  44. package/dist/installer/init.d.ts.map +1 -0
  45. package/dist/installer/init.js +398 -0
  46. package/dist/installer/init.js.map +1 -0
  47. package/dist/installer/install-manifest.d.ts +132 -0
  48. package/dist/installer/install-manifest.d.ts.map +1 -0
  49. package/dist/installer/install-manifest.js +1047 -0
  50. package/dist/installer/install-manifest.js.map +1 -0
  51. package/dist/installer/opencode-config.d.ts +32 -0
  52. package/dist/installer/opencode-config.d.ts.map +1 -0
  53. package/dist/installer/opencode-config.js +302 -0
  54. package/dist/installer/opencode-config.js.map +1 -0
  55. package/dist/installer/uninstall.d.ts +70 -0
  56. package/dist/installer/uninstall.d.ts.map +1 -0
  57. package/dist/installer/uninstall.js +770 -0
  58. package/dist/installer/uninstall.js.map +1 -0
  59. package/dist/installer/upgrade.d.ts +101 -0
  60. package/dist/installer/upgrade.d.ts.map +1 -0
  61. package/dist/installer/upgrade.js +1140 -0
  62. package/dist/installer/upgrade.js.map +1 -0
  63. package/dist/opencode-plugin.d.ts +2 -0
  64. package/dist/opencode-plugin.d.ts.map +1 -0
  65. package/dist/opencode-plugin.js +4 -0
  66. package/dist/opencode-plugin.js.map +1 -0
  67. package/dist/plugin/index.d.ts +7 -2
  68. package/dist/plugin/index.d.ts.map +1 -1
  69. package/dist/plugin/index.js +23 -6
  70. package/dist/plugin/index.js.map +1 -1
  71. package/dist/tools/index.d.ts +35 -1
  72. package/dist/tools/index.d.ts.map +1 -1
  73. package/dist/tools/index.js +197 -1
  74. package/dist/tools/index.js.map +1 -1
  75. package/docs/MIGRATION.md +184 -0
  76. package/docs/SECURITY.md +219 -0
  77. package/docs/TROUBLESHOOTING.md +171 -0
  78. package/package.json +23 -5
  79. package/templates/.opencode/agents/scheduled-coder.md +115 -0
  80. package/templates/.opencode/agents/scheduled-planner.md +166 -0
  81. package/templates/.opencode/agents/scheduled-reviewer.md +88 -0
  82. package/templates/.opencode/commands/abort-task.md +20 -0
  83. package/templates/.opencode/commands/acceptance.md +20 -0
  84. package/templates/.opencode/commands/change.md +23 -0
  85. package/templates/.opencode/commands/resume-review.md +19 -0
  86. package/templates/.opencode/skills/scheduled-quality-coder/SKILL.md +93 -0
  87. package/templates/.opencode/skills/scheduled-quality-orchestrator/SKILL.md +216 -0
  88. package/templates/.opencode/skills/scheduled-quality-reviewer/SKILL.md +68 -0
  89. package/templates/AGENTS.md.fragment +19 -0
  90. package/templates/README.md +55 -9
  91. package/templates/automation/config.json +67 -0
  92. package/templates/automation/config.schema.json +194 -0
  93. package/templates/automation/task-contract.schema.json +92 -0
  94. package/templates/automation/tasks/TASK-TEMPLATE.json.example +56 -0
  95. package/templates/docs/plans/README.md +26 -0
  96. package/templates/installation-manifest.schema.json +201 -0
  97. package/templates/scripts/automation/abort-task.sh +171 -0
  98. package/templates/scripts/automation/accept-and-integrate.sh +263 -0
  99. package/templates/scripts/automation/acceptance-report.sh +132 -0
  100. package/templates/scripts/automation/approve-and-run.sh +175 -0
  101. package/templates/scripts/automation/begin-review.sh +18 -0
  102. package/templates/scripts/automation/block-task.sh +26 -0
  103. package/templates/scripts/automation/claim-task.sh +74 -0
  104. package/templates/scripts/automation/integration-scope-gate.sh +103 -0
  105. package/templates/scripts/automation/lib.sh +815 -0
  106. package/templates/scripts/automation/orchestrate-task.sh +119 -0
  107. package/templates/scripts/automation/preflight.sh +206 -0
  108. package/templates/scripts/automation/prepare-contract-review.sh +101 -0
  109. package/templates/scripts/automation/quality-gate.sh +72 -0
  110. package/templates/scripts/automation/queue-task.sh +35 -0
  111. package/templates/scripts/automation/record-red.sh +55 -0
  112. package/templates/scripts/automation/resume-review-fix.sh +32 -0
  113. package/templates/scripts/automation/resume-review.sh +120 -0
  114. package/templates/scripts/automation/scope-gate.sh +110 -0
  115. package/templates/scripts/automation/select-task.sh +34 -0
  116. package/templates/scripts/automation/shadow-run.sh +24 -0
  117. package/templates/scripts/automation/show-acceptance-review.sh +111 -0
  118. package/templates/scripts/automation/status.sh +125 -0
  119. package/templates/scripts/automation/submit-review.sh +73 -0
  120. package/templates/scripts/automation/tests/run-tests.sh +716 -0
  121. package/templates/scripts/automation/transition-state.sh +21 -0
  122. package/templates/scripts/automation/validate-contract.sh +98 -0
  123. package/templates/scripts/automation/verify-integration.sh +38 -0
  124. package/templates/scripts/automation/verify-task.sh +39 -0
@@ -0,0 +1,815 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # Shared helpers for the scheduled coding quality gate. This file is sourced by
4
+ # command scripts; it intentionally does not enable set -e for its callers.
5
+
6
+ AUTOMATION_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
+
8
+ if [[ -n "${AUTOMATION_PROJECT_ROOT:-}" ]]; then
9
+ if [[ "${AUTOMATION_TEST_MODE:-0}" != "1" ]]; then
10
+ printf 'ERROR: AUTOMATION_PROJECT_ROOT is reserved for the test suite.\n' >&2
11
+ return 1 2>/dev/null || exit 1
12
+ fi
13
+ AUTOMATION_ROOT="$(cd "$AUTOMATION_PROJECT_ROOT" && pwd)"
14
+ else
15
+ AUTOMATION_ROOT="$(git -C "$AUTOMATION_SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null)" || {
16
+ printf 'ERROR: scripts must run inside a Git worktree.\n' >&2
17
+ return 1 2>/dev/null || exit 1
18
+ }
19
+ fi
20
+
21
+ AUTOMATION_DIR="$AUTOMATION_ROOT/automation"
22
+ AUTOMATION_CONFIG="$AUTOMATION_DIR/config.json"
23
+ AUTOMATION_TASKS_DIR="$AUTOMATION_DIR/tasks"
24
+
25
+ automation_git_common_dir="$(git -C "$AUTOMATION_ROOT" rev-parse --git-common-dir 2>/dev/null)" || {
26
+ printf 'ERROR: unable to resolve Git common directory.\n' >&2
27
+ return 1 2>/dev/null || exit 1
28
+ }
29
+ if [[ "$automation_git_common_dir" != /* ]]; then
30
+ automation_git_common_dir="$AUTOMATION_ROOT/$automation_git_common_dir"
31
+ fi
32
+ AUTOMATION_GIT_COMMON_DIR="$(cd "$automation_git_common_dir" && pwd)"
33
+ unset automation_git_common_dir
34
+
35
+ if [[ -n "${AUTOMATION_RUNTIME_ROOT:-}" ]]; then
36
+ if [[ "${AUTOMATION_TEST_MODE:-0}" != "1" ]]; then
37
+ printf 'ERROR: AUTOMATION_RUNTIME_ROOT is reserved for the test suite.\n' >&2
38
+ return 1 2>/dev/null || exit 1
39
+ fi
40
+ elif [[ "${AUTOMATION_TEST_MODE:-0}" == "1" ]]; then
41
+ AUTOMATION_RUNTIME_ROOT="$AUTOMATION_DIR"
42
+ else
43
+ AUTOMATION_RUNTIME_ROOT="$AUTOMATION_GIT_COMMON_DIR/automation-runtime"
44
+ fi
45
+
46
+ AUTOMATION_STATE_DIR="$AUTOMATION_RUNTIME_ROOT/state"
47
+ AUTOMATION_EVIDENCE_DIR="$AUTOMATION_RUNTIME_ROOT/evidence"
48
+ AUTOMATION_LOCKS_DIR="$AUTOMATION_RUNTIME_ROOT/locks"
49
+ AUTOMATION_WORKSPACES_DIR="$AUTOMATION_RUNTIME_ROOT/workspaces"
50
+
51
+ automation_info() {
52
+ printf '[automation] %s\n' "$*"
53
+ }
54
+
55
+ automation_warn() {
56
+ printf '[automation] WARN: %s\n' "$*" >&2
57
+ }
58
+
59
+ automation_die() {
60
+ printf '[automation] ERROR: %s\n' "$*" >&2
61
+ return 1
62
+ }
63
+
64
+ automation_now() {
65
+ date -u '+%Y-%m-%dT%H:%M:%SZ'
66
+ }
67
+
68
+ automation_require_command() {
69
+ command -v "$1" >/dev/null 2>&1 || automation_die "required command is missing: $1"
70
+ }
71
+
72
+ automation_resolve_opencode_config() {
73
+ local output_file="$1"
74
+ local error_file="$2"
75
+ local checkpoint_error="Failed to run the query 'PRAGMA wal_checkpoint(PASSIVE)'"
76
+
77
+ if opencode debug config > "$output_file" 2> "$error_file"; then
78
+ return 0
79
+ fi
80
+ if ! rg -F "$checkpoint_error" "$error_file" >/dev/null; then
81
+ return 1
82
+ fi
83
+
84
+ automation_warn "OpenCode database checkpoint failed once; retrying resolved config discovery"
85
+ sleep 1
86
+ opencode debug config > "$output_file" 2> "$error_file"
87
+ }
88
+
89
+ automation_require_layout() {
90
+ [[ -f "$AUTOMATION_CONFIG" ]] || automation_die "missing $AUTOMATION_CONFIG"
91
+ [[ -d "$AUTOMATION_TASKS_DIR" ]] || automation_die "missing $AUTOMATION_TASKS_DIR"
92
+ }
93
+
94
+ automation_validate_config() {
95
+ automation_require_layout
96
+ jq -e '
97
+ def repository_path:
98
+ type == "string" and
99
+ length > 0 and
100
+ (startswith("/") | not) and
101
+ (test("^[A-Za-z]:[/\\\\]") | not) and
102
+ (test("(^|/)\\.\\.(/|$)") | not);
103
+ def gradle_task:
104
+ type == "string" and
105
+ test("^(?:[A-Za-z][A-Za-z0-9_.-]*|(?::[A-Za-z0-9_.-]+)+)$");
106
+ def gradle_task_list:
107
+ type == "array" and
108
+ length > 0 and
109
+ length == (unique | length) and
110
+ all(.[]; gradle_task);
111
+ .schemaVersion == 3 and
112
+ (.enabled | type == "boolean") and
113
+ (.mode == "shadow" or .mode == "orchestrated") and
114
+ (.workspaceStrategy == "inPlaceExclusive" or .workspaceStrategy == "isolatedWorktree") and
115
+ .originalBranchDriftPolicy == "block" and
116
+ (.worktreeBase | type == "string") and
117
+ (.maxFixLoops | type == "number" and . >= 0 and . <= 1 and floor == .) and
118
+ (.maxReviewCycles | type == "number" and . >= 0 and . <= 2 and floor == .) and
119
+ (.maxReviewerRestarts | type == "number" and . >= 0 and . <= 3 and floor == .) and
120
+ (.autoCleanupWorktrees | type == "boolean") and
121
+ .pushAfterAcceptance == false and
122
+ (.approvalPhrases.proposal | type == "string" and length >= 4) and
123
+ (.approvalPhrases.contract | type == "string" and length >= 4) and
124
+ (.approvalPhrases.acceptance | type == "string" and length >= 4) and
125
+ (.approvalPhrases.abort | type == "string" and length >= 4) and
126
+ (.plugins | type == "object" and keys == ["superpowers"]) and
127
+ (.plugins.superpowers | type == "string" and length > 0) and
128
+ (.requiredSkills | type == "array" and length >= 6) and
129
+ (.gradleVerification as $verification |
130
+ ($verification | type == "object") and
131
+ ($verification | keys == [
132
+ "assembleTasks",
133
+ "deviceTestTasks",
134
+ "focusedTestTasks",
135
+ "fullUnitTestTasks",
136
+ "lintTasks"
137
+ ]) and
138
+ ($verification.fullUnitTestTasks | gradle_task_list) and
139
+ ($verification.focusedTestTasks | gradle_task_list) and
140
+ ($verification.assembleTasks | gradle_task_list) and
141
+ ($verification.lintTasks | gradle_task_list) and
142
+ ($verification.deviceTestTasks | gradle_task_list)) and
143
+ (.androidProject as $project |
144
+ ($project | type == "object") and
145
+ ($project.name | type == "string" and length > 0) and
146
+ ($project.gradleDsl == "kotlin" or $project.gradleDsl == "groovy" or $project.gradleDsl == "mixed") and
147
+ ($project.settingsFile | repository_path) and
148
+ ((($project | has("moduleScope")) | not) or $project.moduleScope == "all" or $project.moduleScope == "primary") and
149
+ ($project.primaryModule | type == "string" and test("^:(?:[A-Za-z0-9_.-]+(?::[A-Za-z0-9_.-]+)*)?$")) and
150
+ ($project.modules | type == "array" and length > 0 and all(.[];
151
+ (.gradlePath | type == "string" and test("^:(?:[A-Za-z0-9_.-]+(?::[A-Za-z0-9_.-]+)*)?$")) and
152
+ (.directory | repository_path) and
153
+ (.buildFile | repository_path) and
154
+ (.dsl == "kotlin" or .dsl == "groovy") and
155
+ (.type == "application" or .type == "library" or .type == "dynamic-feature" or .type == "test" or .type == "asset-pack") and
156
+ (.namespace == null or (.namespace | type == "string" and length > 0)) and
157
+ (.applicationId == null or (.applicationId | type == "string" and length > 0)))) and
158
+ any($project.modules[]; .gradlePath == $project.primaryModule) and
159
+ ($project.productionPaths | type == "array" and length > 0 and all(.[];
160
+ repository_path and test("(^|/)src/main/\\*\\*$"))) and
161
+ ($project.testPaths | type == "array" and length > 0 and all(.[];
162
+ repository_path and test("(^|/)src/(?:test|androidTest)/\\*\\*$")))) and
163
+ (.protectedPaths | type == "array" and length > 0 and all(.[]; repository_path))
164
+ ' "$AUTOMATION_CONFIG" >/dev/null || automation_die "automation/config.json is invalid"
165
+ }
166
+
167
+ automation_require_orchestrated() {
168
+ automation_validate_config
169
+ [[ "$(automation_config_value '.enabled')" == "true" ]] || automation_die "automation is disabled"
170
+ [[ "$(automation_config_value '.mode')" == "orchestrated" ]] || automation_die "automation mode is not orchestrated"
171
+ }
172
+
173
+ automation_ensure_runtime_layout() {
174
+ mkdir -p \
175
+ "$AUTOMATION_STATE_DIR" \
176
+ "$AUTOMATION_EVIDENCE_DIR" \
177
+ "$AUTOMATION_LOCKS_DIR" \
178
+ "$AUTOMATION_WORKSPACES_DIR"
179
+ }
180
+
181
+ automation_validate_task_id() {
182
+ local task_id="${1:-}"
183
+ [[ "$task_id" =~ ^TASK-[A-Z0-9-]+$ ]] || automation_die "invalid task ID: ${task_id:-<empty>}"
184
+ }
185
+
186
+ automation_contract_path() {
187
+ local task_id="$1"
188
+ printf '%s/%s.json\n' "$AUTOMATION_TASKS_DIR" "$task_id"
189
+ }
190
+
191
+ automation_contract_relative_path() {
192
+ local task_id="$1"
193
+ printf 'automation/tasks/%s.json\n' "$task_id"
194
+ }
195
+
196
+ automation_state_path() {
197
+ local task_id="$1"
198
+ printf '%s/%s.json\n' "$AUTOMATION_STATE_DIR" "$task_id"
199
+ }
200
+
201
+ automation_evidence_path() {
202
+ local task_id="$1"
203
+ printf '%s/%s\n' "$AUTOMATION_EVIDENCE_DIR" "$task_id"
204
+ }
205
+
206
+ automation_workspace_path() {
207
+ local task_id="$1"
208
+ printf '%s/%s.json\n' "$AUTOMATION_WORKSPACES_DIR" "$task_id"
209
+ }
210
+
211
+ automation_workspace_strategy() {
212
+ local workspace_file="$1"
213
+ jq -er '.workspaceStrategy // "isolatedWorktree"' "$workspace_file"
214
+ }
215
+
216
+ automation_workspace_task_root() {
217
+ local workspace_file="$1"
218
+ jq -er '.taskRoot // .taskWorktree' "$workspace_file"
219
+ }
220
+
221
+ automation_origin_path() {
222
+ local task_id="$1"
223
+ printf '%s/origin.json\n' "$(automation_evidence_path "$task_id")"
224
+ }
225
+
226
+ automation_approvals_path() {
227
+ local task_id="$1"
228
+ printf '%s/approvals.jsonl\n' "$(automation_evidence_path "$task_id")"
229
+ }
230
+
231
+ automation_read_state() {
232
+ local task_id="$1"
233
+ local state_file
234
+ state_file="$(automation_state_path "$task_id")"
235
+ [[ -f "$state_file" ]] || automation_die "state does not exist for $task_id"
236
+ jq -er '.state' "$state_file"
237
+ }
238
+
239
+ automation_config_value() {
240
+ local query="$1"
241
+ jq -r "$query" "$AUTOMATION_CONFIG"
242
+ }
243
+
244
+ automation_validate_gradle_task() {
245
+ local task="${1:-}"
246
+ local safe_task_pattern='^([A-Za-z][A-Za-z0-9_.-]*|(:[A-Za-z0-9_.-]+)+)$'
247
+ [[ "$task" =~ $safe_task_pattern ]] || automation_die "unsafe Gradle task: ${task:-<empty>}"
248
+ }
249
+
250
+ automation_validate_test_filter() {
251
+ local filter="${1:-}"
252
+ local safe_filter_pattern='^[A-Za-z0-9_.#$*-]+$'
253
+ [[ "$filter" =~ $safe_filter_pattern ]] || automation_die "unsafe test filter: ${filter:-<empty>}"
254
+ }
255
+
256
+ automation_validate_gradle_group() {
257
+ local group="${1:-}"
258
+ case "$group" in
259
+ fullUnitTestTasks|focusedTestTasks|assembleTasks|lintTasks|deviceTestTasks) ;;
260
+ *)
261
+ automation_die "unknown Gradle verification group: ${group:-<empty>}"
262
+ return 1
263
+ ;;
264
+ esac
265
+ }
266
+
267
+ automation_gradle_group_command_json() {
268
+ local group="$1"
269
+ automation_validate_gradle_group "$group" || return 1
270
+ automation_validate_config || return 1
271
+ jq -ce \
272
+ --arg group "$group" \
273
+ '["./gradlew"] + .gradleVerification[$group]' \
274
+ "$AUTOMATION_CONFIG"
275
+ }
276
+
277
+ automation_run_gradle_group() {
278
+ local group="$1"
279
+ local root="${2:-$AUTOMATION_ROOT}"
280
+ local task
281
+ local -a tasks=()
282
+
283
+ automation_validate_gradle_group "$group" || return 1
284
+ automation_validate_config || return 1
285
+ while IFS= read -r task; do
286
+ automation_validate_gradle_task "$task" || return 1
287
+ tasks[${#tasks[@]}]="$task"
288
+ done < <(jq -er --arg group "$group" '.gradleVerification[$group][]' "$AUTOMATION_CONFIG")
289
+ [[ "${#tasks[@]}" -gt 0 ]] || {
290
+ automation_die "Gradle verification group is empty: $group"
291
+ return 1
292
+ }
293
+
294
+ (
295
+ cd "$root"
296
+ ./gradlew "${tasks[@]}"
297
+ )
298
+ }
299
+
300
+ automation_run_focused_test() {
301
+ local task="$1"
302
+ local filter="$2"
303
+ local root="${3:-$AUTOMATION_ROOT}"
304
+
305
+ automation_validate_config || return 1
306
+ automation_validate_gradle_task "$task" || return 1
307
+ automation_validate_test_filter "$filter" || return 1
308
+ jq -e \
309
+ --arg task "$task" \
310
+ '.gradleVerification.focusedTestTasks | index($task) != null' \
311
+ "$AUTOMATION_CONFIG" >/dev/null || {
312
+ automation_die "focused Gradle task is not allowed by automation/config.json: $task"
313
+ return 1
314
+ }
315
+
316
+ (
317
+ cd "$root"
318
+ ./gradlew "$task" --tests "$filter"
319
+ )
320
+ }
321
+
322
+ automation_require_approval() {
323
+ local kind="$1"
324
+ local supplied="$2"
325
+ local expected
326
+ expected="$(jq -er --arg kind "$kind" '.approvalPhrases[$kind]' "$AUTOMATION_CONFIG")" || {
327
+ automation_die "unknown approval kind: $kind"
328
+ return 1
329
+ }
330
+ [[ "$supplied" == "$expected" ]] || automation_die "approval option token does not match the configured $kind confirmation"
331
+ }
332
+
333
+ automation_file_sha256() {
334
+ local file="$1"
335
+ [[ -f "$file" ]] || automation_die "file does not exist: $file"
336
+ shasum -a 256 "$file" | awk '{print $1}'
337
+ }
338
+
339
+ automation_current_branch() {
340
+ local root="${1:-$AUTOMATION_ROOT}"
341
+ local branch
342
+ branch="$(git -C "$root" symbolic-ref --quiet --short HEAD 2>/dev/null)" || {
343
+ automation_die "detached HEAD is not supported: $root"
344
+ return 1
345
+ }
346
+ printf '%s\n' "$branch"
347
+ }
348
+
349
+ automation_path_matches() {
350
+ local path="$1"
351
+ local pattern="$2"
352
+ local prefix
353
+
354
+ if [[ "$pattern" == */ ]]; then
355
+ [[ "$path" == "$pattern"* ]]
356
+ return
357
+ fi
358
+
359
+ if [[ "$pattern" == *'/**' ]]; then
360
+ prefix="${pattern:0:${#pattern}-3}"
361
+ [[ "$path" == "$prefix" || "$path" == "$prefix/"* ]]
362
+ return
363
+ fi
364
+
365
+ case "$path" in
366
+ $pattern) return 0 ;;
367
+ *) return 1 ;;
368
+ esac
369
+ }
370
+
371
+ automation_array_matches_path() {
372
+ local json_file="$1"
373
+ local query="$2"
374
+ local path="$3"
375
+ local pattern
376
+
377
+ while IFS= read -r pattern; do
378
+ if automation_path_matches "$path" "$pattern"; then
379
+ return 0
380
+ fi
381
+ done < <(jq -r "$query[]" "$json_file")
382
+
383
+ return 1
384
+ }
385
+
386
+ automation_changed_paths_at() {
387
+ 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
392
+ }
393
+
394
+ automation_changed_paths() {
395
+ automation_changed_paths_at "$AUTOMATION_ROOT"
396
+ }
397
+
398
+ automation_assert_planning_artifacts_sealed() {
399
+ local task_id="$1"
400
+ local root="${2:-$AUTOMATION_ROOT}"
401
+ local origin_file contract_rel plan_rel contract_path plan_path
402
+
403
+ origin_file="$(automation_origin_path "$task_id")"
404
+ [[ -f "$origin_file" ]] || automation_die "origin evidence is missing for $task_id"
405
+ contract_rel="$(jq -er '.contractPath' "$origin_file")"
406
+ plan_rel="$(jq -er '.planPath' "$origin_file")"
407
+ contract_path="$root/$contract_rel"
408
+ plan_path="$root/$plan_rel"
409
+
410
+ [[ "$(automation_file_sha256 "$contract_path")" == "$(jq -er '.contractSha256' "$origin_file")" ]] || \
411
+ automation_die "approved contract changed after contract review"
412
+ [[ "$(automation_file_sha256 "$plan_path")" == "$(jq -er '.planSha256' "$origin_file")" ]] || \
413
+ automation_die "approved plan changed after contract review"
414
+ }
415
+
416
+ automation_is_planning_artifact() {
417
+ local task_id="$1"
418
+ local path="$2"
419
+ local origin_file contract_rel plan_rel
420
+
421
+ origin_file="$(automation_origin_path "$task_id")"
422
+ [[ -f "$origin_file" ]] || return 1
423
+ contract_rel="$(jq -er '.contractPath' "$origin_file")" || return 1
424
+ plan_rel="$(jq -er '.planPath' "$origin_file")" || return 1
425
+ [[ "$path" == "$contract_rel" || "$path" == "$plan_rel" ]]
426
+ }
427
+
428
+ automation_product_changed_paths_at() {
429
+ local task_id="$1"
430
+ local root="${2:-$AUTOMATION_ROOT}"
431
+ local path
432
+
433
+ while IFS= read -r path; do
434
+ [[ -n "$path" ]] || continue
435
+ if ! automation_is_planning_artifact "$task_id" "$path"; then
436
+ printf '%s\n' "$path"
437
+ fi
438
+ done < <(automation_changed_paths_at "$root")
439
+ }
440
+
441
+ automation_product_changed_paths_between() {
442
+ local task_id="$1"
443
+ local base="$2"
444
+ local head="$3"
445
+ local root="${4:-$AUTOMATION_ROOT}"
446
+ local path
447
+
448
+ while IFS= read -r path; do
449
+ [[ -n "$path" ]] || continue
450
+ if ! automation_is_planning_artifact "$task_id" "$path"; then
451
+ printf '%s\n' "$path"
452
+ fi
453
+ done < <(git -C "$root" diff --name-only "$base" "$head" -- | LC_ALL=C sort -u)
454
+ }
455
+
456
+ automation_changed_paths_between() {
457
+ local base="$1"
458
+ local head="$2"
459
+ git -C "$AUTOMATION_ROOT" diff --name-only "$base" "$head" -- | LC_ALL=C sort -u
460
+ }
461
+
462
+ automation_worktree_diff_sha() {
463
+ local root="${1:-$AUTOMATION_ROOT}"
464
+ local path
465
+ {
466
+ git -C "$root" diff --binary HEAD --
467
+ while IFS= read -r path; do
468
+ [[ -n "$path" ]] || continue
469
+ printf 'UNTRACKED %s\0' "$path"
470
+ git -C "$root" hash-object -- "$path"
471
+ done < <(git -C "$root" ls-files --others --exclude-standard | LC_ALL=C sort)
472
+ } | shasum -a 256 | awk '{print $1}'
473
+ }
474
+
475
+ automation_worktree_is_clean() {
476
+ local root="${1:-$AUTOMATION_ROOT}"
477
+ [[ -z "$(git -C "$root" status --porcelain --untracked-files=all)" ]]
478
+ }
479
+
480
+ automation_create_lock_dir() {
481
+ local lock_dir="$1"
482
+ local label="$2"
483
+ local owner_pid=""
484
+ if mkdir "$lock_dir" 2>/dev/null; then
485
+ return 0
486
+ fi
487
+
488
+ if [[ -f "$lock_dir/pid" ]]; then
489
+ owner_pid="$(cat "$lock_dir/pid" 2>/dev/null || true)"
490
+ fi
491
+ if [[ "$owner_pid" =~ ^[0-9]+$ ]] && ! kill -0 "$owner_pid" 2>/dev/null; then
492
+ automation_warn "removing stale $label lock owned by stopped PID $owner_pid"
493
+ rm -f "$lock_dir/pid" "$lock_dir/acquired-at"
494
+ rmdir "$lock_dir" 2>/dev/null || true
495
+ if mkdir "$lock_dir" 2>/dev/null; then
496
+ return 0
497
+ fi
498
+ fi
499
+
500
+ automation_die "$label lock is already held${owner_pid:+ by PID $owner_pid}"
501
+ }
502
+
503
+ automation_acquire_lock() {
504
+ local task_id="$1"
505
+ local lock_dir="$AUTOMATION_LOCKS_DIR/$task_id.state.lock"
506
+ automation_ensure_runtime_layout
507
+ automation_create_lock_dir "$lock_dir" "task state $task_id" || return 1
508
+ printf '%s\n' "$$" > "$lock_dir/pid"
509
+ printf '%s\n' "$(automation_now)" > "$lock_dir/acquired-at"
510
+ AUTOMATION_HELD_LOCK="$lock_dir"
511
+ }
512
+
513
+ automation_release_lock() {
514
+ if [[ -n "${AUTOMATION_HELD_LOCK:-}" && -d "$AUTOMATION_HELD_LOCK" ]]; then
515
+ rm -f "$AUTOMATION_HELD_LOCK/pid" "$AUTOMATION_HELD_LOCK/acquired-at"
516
+ rmdir "$AUTOMATION_HELD_LOCK" 2>/dev/null || true
517
+ fi
518
+ AUTOMATION_HELD_LOCK=""
519
+ }
520
+
521
+ automation_acquire_run_lock() {
522
+ local task_id="$1"
523
+ local lock_dir="$AUTOMATION_LOCKS_DIR/$task_id.run.lock"
524
+ automation_ensure_runtime_layout
525
+ automation_create_lock_dir "$lock_dir" "task orchestration $task_id" || return 1
526
+ printf '%s\n' "$$" > "$lock_dir/pid"
527
+ printf '%s\n' "$(automation_now)" > "$lock_dir/acquired-at"
528
+ AUTOMATION_HELD_RUN_LOCK="$lock_dir"
529
+ }
530
+
531
+ automation_release_run_lock() {
532
+ if [[ -n "${AUTOMATION_HELD_RUN_LOCK:-}" && -d "$AUTOMATION_HELD_RUN_LOCK" ]]; then
533
+ rm -f "$AUTOMATION_HELD_RUN_LOCK/pid" "$AUTOMATION_HELD_RUN_LOCK/acquired-at"
534
+ rmdir "$AUTOMATION_HELD_RUN_LOCK" 2>/dev/null || true
535
+ fi
536
+ AUTOMATION_HELD_RUN_LOCK=""
537
+ }
538
+
539
+ automation_repository_lease_dir() {
540
+ printf '%s/repository.workspace.lease\n' "$AUTOMATION_LOCKS_DIR"
541
+ }
542
+
543
+ automation_assert_repository_lease_available() {
544
+ local task_id="$1"
545
+ local lease_dir lease_file owner
546
+ lease_dir="$(automation_repository_lease_dir)"
547
+ lease_file="$lease_dir/lease.json"
548
+ [[ -d "$lease_dir" ]] || return 0
549
+ [[ -f "$lease_file" ]] || {
550
+ automation_die "repository workspace lease is malformed: $lease_dir"
551
+ return 1
552
+ }
553
+ owner="$(jq -er '.taskId' "$lease_file")" || return 1
554
+ [[ "$owner" == "$task_id" ]] || {
555
+ automation_die "repository workspace is leased by $owner"
556
+ return 1
557
+ }
558
+ }
559
+
560
+ automation_acquire_repository_lease() {
561
+ local task_id="$1"
562
+ local source_root="$2"
563
+ local strategy="$3"
564
+ local lease_dir lease_file tmp
565
+ automation_ensure_runtime_layout
566
+ lease_dir="$(automation_repository_lease_dir)"
567
+ lease_file="$lease_dir/lease.json"
568
+
569
+ if ! mkdir "$lease_dir" 2>/dev/null; then
570
+ automation_assert_repository_lease_available "$task_id" || return 1
571
+ jq -e \
572
+ --arg taskId "$task_id" \
573
+ --arg sourceRoot "$source_root" \
574
+ --arg strategy "$strategy" \
575
+ '.taskId == $taskId and .sourceRoot == $sourceRoot and .workspaceStrategy == $strategy' \
576
+ "$lease_file" >/dev/null || {
577
+ automation_die "repository workspace lease does not match the current task metadata"
578
+ return 1
579
+ }
580
+ return 0
581
+ fi
582
+
583
+ tmp="$(mktemp "$lease_dir/.lease.XXXXXX")"
584
+ jq -n \
585
+ --arg taskId "$task_id" \
586
+ --arg sourceRoot "$source_root" \
587
+ --arg strategy "$strategy" \
588
+ --arg acquiredAt "$(automation_now)" \
589
+ '{taskId: $taskId, sourceRoot: $sourceRoot,
590
+ workspaceStrategy: $strategy, acquiredAt: $acquiredAt}' > "$tmp"
591
+ mv "$tmp" "$lease_file"
592
+ }
593
+
594
+ automation_require_repository_lease() {
595
+ local task_id="$1"
596
+ local source_root="$2"
597
+ local strategy="$3"
598
+ local lease_file
599
+ lease_file="$(automation_repository_lease_dir)/lease.json"
600
+ [[ -f "$lease_file" ]] || {
601
+ automation_die "repository workspace lease is missing for $task_id"
602
+ return 1
603
+ }
604
+ jq -e \
605
+ --arg taskId "$task_id" \
606
+ --arg sourceRoot "$source_root" \
607
+ --arg strategy "$strategy" \
608
+ '.taskId == $taskId and .sourceRoot == $sourceRoot and .workspaceStrategy == $strategy' \
609
+ "$lease_file" >/dev/null || {
610
+ automation_die "repository workspace lease is owned by another task or workspace"
611
+ return 1
612
+ }
613
+ }
614
+
615
+ automation_release_repository_lease() {
616
+ local task_id="$1"
617
+ local lease_dir lease_file owner
618
+ lease_dir="$(automation_repository_lease_dir)"
619
+ lease_file="$lease_dir/lease.json"
620
+ [[ -d "$lease_dir" ]] || return 0
621
+ [[ -f "$lease_file" ]] || {
622
+ automation_die "repository workspace lease is malformed: $lease_dir"
623
+ return 1
624
+ }
625
+ owner="$(jq -er '.taskId' "$lease_file")" || return 1
626
+ [[ "$owner" == "$task_id" ]] || {
627
+ automation_die "cannot release repository workspace lease owned by $owner"
628
+ return 1
629
+ }
630
+ rm -f "$lease_file"
631
+ rmdir "$lease_dir"
632
+ }
633
+
634
+ automation_transition_allowed() {
635
+ local from="$1"
636
+ local to="$2"
637
+ local actor="$3"
638
+
639
+ case "$from:$to:$actor" in
640
+ CONTRACT_REVIEW:APPROVED_CONTRACT:human) return 0 ;;
641
+ APPROVED_CONTRACT:PREPARING:orchestrator) return 0 ;;
642
+ PREPARING:PENDING:orchestrator) return 0 ;;
643
+ PREPARING:BLOCKED:orchestrator) return 0 ;;
644
+ APPROVED_CONTRACT:PENDING:human) return 0 ;;
645
+ TEST_FAILED:PENDING:human) return 0 ;;
646
+ BLOCKED:PENDING:human) return 0 ;;
647
+ CHANGES_REQUESTED:PENDING:human) return 0 ;;
648
+ PENDING:CODING:coder-launcher) return 0 ;;
649
+ PENDING:BLOCKED:preflight) return 0 ;;
650
+ PENDING:BLOCKED:coder) return 0 ;;
651
+ PENDING:BLOCKED:orchestrator) return 0 ;;
652
+ CODING:BLOCKED:preflight) return 0 ;;
653
+ CODING:BLOCKED:coder) return 0 ;;
654
+ CODING:BLOCKED:orchestrator) return 0 ;;
655
+ CODING:READY_FOR_REVIEW:quality-gate) return 0 ;;
656
+ CODING:TEST_FAILED:quality-gate) return 0 ;;
657
+ CODING:BLOCKED:quality-gate) return 0 ;;
658
+ READY_FOR_REVIEW:REVIEWING:orchestrator) return 0 ;;
659
+ READY_FOR_REVIEW:BLOCKED:orchestrator) return 0 ;;
660
+ REVIEWING:AWAITING_HUMAN:reviewer) return 0 ;;
661
+ REVIEWING:CHANGES_REQUESTED:reviewer) return 0 ;;
662
+ REVIEWING:BLOCKED:orchestrator) return 0 ;;
663
+ BLOCKED:REVIEWING:human) return 0 ;;
664
+ CHANGES_REQUESTED:CODING:orchestrator) return 0 ;;
665
+ CHANGES_REQUESTED:NEEDS_HUMAN:orchestrator) return 0 ;;
666
+ AWAITING_HUMAN:INTEGRATING:integrator) return 0 ;;
667
+ INTEGRATING:COMPLETED:integrator) return 0 ;;
668
+ INTEGRATING:INTEGRATION_BLOCKED:integrator) return 0 ;;
669
+ PREPARING:ABORTED:human|PENDING:ABORTED:human|CODING:ABORTED:human|READY_FOR_REVIEW:ABORTED:human|REVIEWING:ABORTED:human|CHANGES_REQUESTED:ABORTED:human|AWAITING_HUMAN:ABORTED:human|BLOCKED:ABORTED:human|TEST_FAILED:ABORTED:human|NEEDS_HUMAN:ABORTED:human|INTEGRATION_BLOCKED:ABORTED:human) return 0 ;;
670
+ *) return 1 ;;
671
+ esac
672
+ }
673
+
674
+ automation_initialize_state() {
675
+ local task_id="$1"
676
+ local initial_state="$2"
677
+ local actor="$3"
678
+ local note="$4"
679
+ local state_file evidence_dir tmp now
680
+
681
+ automation_ensure_runtime_layout
682
+ state_file="$(automation_state_path "$task_id")"
683
+ [[ ! -e "$state_file" ]] || automation_die "state already exists for $task_id"
684
+ evidence_dir="$(automation_evidence_path "$task_id")"
685
+ mkdir -p "$evidence_dir"
686
+ now="$(automation_now)"
687
+ tmp="$(mktemp "$AUTOMATION_STATE_DIR/.${task_id}.XXXXXX")"
688
+ jq -n \
689
+ --arg taskId "$task_id" \
690
+ --arg state "$initial_state" \
691
+ --arg updatedAt "$now" \
692
+ --arg updatedBy "$actor" \
693
+ --arg note "$note" \
694
+ '{taskId: $taskId, state: $state, revision: 1, updatedAt: $updatedAt, updatedBy: $updatedBy, note: $note}' \
695
+ > "$tmp"
696
+ mv "$tmp" "$state_file"
697
+ jq -nc \
698
+ --arg taskId "$task_id" \
699
+ --arg from "" \
700
+ --arg to "$initial_state" \
701
+ --arg actor "$actor" \
702
+ --arg at "$now" \
703
+ --arg note "$note" \
704
+ '{taskId: $taskId, from: $from, to: $to, actor: $actor, at: $at, note: $note}' \
705
+ >> "$evidence_dir/transitions.jsonl"
706
+ }
707
+
708
+ automation_transition_state() {
709
+ local task_id="$1"
710
+ local expected="$2"
711
+ local next="$3"
712
+ local actor="$4"
713
+ local note="$5"
714
+ local state_file evidence_dir actual revision now tmp
715
+
716
+ automation_acquire_lock "$task_id" || return 1
717
+ state_file="$(automation_state_path "$task_id")"
718
+ evidence_dir="$(automation_evidence_path "$task_id")"
719
+ mkdir -p "$evidence_dir"
720
+
721
+ if [[ ! -f "$state_file" ]]; then
722
+ automation_release_lock
723
+ automation_die "state does not exist for $task_id"
724
+ return 1
725
+ fi
726
+
727
+ actual="$(jq -er '.state' "$state_file")" || {
728
+ automation_release_lock
729
+ automation_die "invalid state file for $task_id"
730
+ return 1
731
+ }
732
+ if [[ "$actual" != "$expected" ]]; then
733
+ automation_release_lock
734
+ automation_die "state mismatch for $task_id: expected $expected, found $actual"
735
+ return 1
736
+ fi
737
+ if ! automation_transition_allowed "$expected" "$next" "$actor"; then
738
+ automation_release_lock
739
+ automation_die "forbidden transition: $expected -> $next by $actor"
740
+ return 1
741
+ fi
742
+
743
+ revision="$(jq -er '.revision' "$state_file")"
744
+ revision=$((revision + 1))
745
+ now="$(automation_now)"
746
+ tmp="$(mktemp "$AUTOMATION_STATE_DIR/.${task_id}.XXXXXX")"
747
+ jq \
748
+ --arg state "$next" \
749
+ --argjson revision "$revision" \
750
+ --arg updatedAt "$now" \
751
+ --arg updatedBy "$actor" \
752
+ --arg note "$note" \
753
+ '.state = $state | .revision = $revision | .updatedAt = $updatedAt | .updatedBy = $updatedBy | .note = $note' \
754
+ "$state_file" > "$tmp"
755
+ mv "$tmp" "$state_file"
756
+ jq -nc \
757
+ --arg taskId "$task_id" \
758
+ --arg from "$expected" \
759
+ --arg to "$next" \
760
+ --arg actor "$actor" \
761
+ --arg at "$now" \
762
+ --arg note "$note" \
763
+ '{taskId: $taskId, from: $from, to: $to, actor: $actor, at: $at, note: $note}' \
764
+ >> "$evidence_dir/transitions.jsonl"
765
+ automation_release_lock
766
+ automation_info "$task_id: $expected -> $next"
767
+ }
768
+
769
+ automation_record_json() {
770
+ local destination="$1"
771
+ local tmp
772
+ mkdir -p "$(dirname "$destination")"
773
+ tmp="$(mktemp "$(dirname "$destination")/.record.XXXXXX")"
774
+ cat > "$tmp"
775
+ mv "$tmp" "$destination"
776
+ }
777
+
778
+ automation_append_json() {
779
+ local destination="$1"
780
+ local value
781
+ mkdir -p "$(dirname "$destination")"
782
+ value="$(cat)"
783
+ jq -ce . <<< "$value" >> "$destination"
784
+ }
785
+
786
+ automation_worktree_base() {
787
+ local configured repo_parent repo_name
788
+ configured="$(jq -r '.worktreeBase // ""' "$AUTOMATION_CONFIG")"
789
+ if [[ -n "${AUTOMATION_WORKTREE_BASE:-}" ]]; then
790
+ if [[ "${AUTOMATION_TEST_MODE:-0}" != "1" ]]; then
791
+ automation_die "AUTOMATION_WORKTREE_BASE is reserved for the test suite"
792
+ return 1
793
+ fi
794
+ configured="$AUTOMATION_WORKTREE_BASE"
795
+ fi
796
+ if [[ -z "$configured" ]]; then
797
+ repo_parent="$(dirname "$AUTOMATION_ROOT")"
798
+ repo_name="$(basename "$AUTOMATION_ROOT")"
799
+ configured="$repo_parent/$repo_name-worktrees"
800
+ elif [[ "$configured" != /* ]]; then
801
+ configured="$AUTOMATION_ROOT/$configured"
802
+ fi
803
+ case "$configured/" in
804
+ "$AUTOMATION_ROOT/"*)
805
+ automation_die "worktree base must be outside the source repository: $configured"
806
+ return 1
807
+ ;;
808
+ esac
809
+ printf '%s\n' "$configured"
810
+ }
811
+
812
+ automation_task_branch() {
813
+ local task_id="$1"
814
+ printf 'automation/%s\n' "$(tr '[:upper:]' '[:lower:]' <<< "$task_id")"
815
+ }